Illustration for waitforemail: async email testing with the lobstermail sdk

waitforemail: async email testing with the lobstermail sdk

waitForEmail() blocks until a matching email arrives in your agent's test inbox. Here's how to use it in Jest, Vitest, and Playwright.

6 min read

Testing email flows is annoying in a specific way. Not "the build is broken" annoying. More like "add a sleep and hope" annoying. Your agent triggers a signup, an email goes out, and your test has no idea when it's going to arrive. So you sprinkle in setTimeout(3000) and pray the email shows up before the assertion runs.

It works fine until your CI runner is having a bad day. Then the email takes four seconds and your test is red, your PR is blocked, and you're doing a rerun at 11pm wondering if this is the career you chose.

waitForEmail() is LobsterMail's answer to this. It's a blocking call that holds until a matching email arrives in the inbox, or times out cleanly with an error you can actually debug. No polling loop, no arbitrary sleep.

The setup#

Install the SDK if you haven't already:

npm install @lobsterkit/lobstermail

The pattern is simple: create a dedicated inbox, trigger the flow that sends email to it, then call waitForEmail() to block until the email lands.

import { LobsterMail } from '@lobsterkit/lobstermail';

const lm = await LobsterMail.create();
const inbox = await lm.createSmartInbox({ name: 'test-agent' });

// Trigger your flow — signup, password reset, verification, whatever
await yourService.signUp({ email: inbox.address });

// Block until the email arrives, with a 30-second timeout
const email = await inbox.waitForEmail({
  timeout: 30000,
  filter: { subject: /welcome/i },
});

expect(email.subject).toMatch(/welcome/i);
expect(email.body).toContain('Confirm your account');

That's the whole pattern. The inbox address is a real @lobstermail.ai address your service actually sends to. waitForEmail() holds open and returns as soon as the email lands.

Filter options#

Without a filter, waitForEmail() returns the first email that arrives after the call. That's fine for simple cases. For anything with more than one email in flight, you'll want to narrow it down:

const email = await inbox.waitForEmail({
  timeout: 30000,
  filter: {
    subject: /verification code/i,     // regex match on subject
    from: 'no-reply@yourservice.com',   // exact sender match
  },
});

Subject filters accept strings (exact match) or regex (partial/case-insensitive). The from filter accepts a full address or a domain fragment, so '@yourservice.com' matches any sender from that domain.

If multiple emails arrive before your filter matches, waitForEmail() checks each one in order and returns the first match. Emails that don't match are ignored, not consumed — subsequent calls can still see them.

Extracting verification codes#

The most common thing you'll do once the email arrives is pull a code or link out of it:

const email = await inbox.waitForEmail({
  timeout: 30000,
  filter: { subject: /your verification code/i },
});

// Extract a 6-digit code from the body
const match = email.body.match(/\b(\d{6})\b/);
if (!match) throw new Error('No verification code found in email body');
const code = match[1];

await yourService.verifyCode(code);

email.body returns the plain text version. For HTML with embedded links, use email.htmlBody:

const linkMatch = email.htmlBody.match(/https?:\/\/[^\s"]+\/verify[^\s"<]*/);
if (!linkMatch) throw new Error('No verification link found');
const verificationUrl = linkMatch[0];

Tip

LobsterMail attaches injection risk scoring to every email as security metadata. You probably don't need it in most tests — but if you're writing assertions for your injection-filtering logic, the field is email.securityMetadata.injectionRisk.

Using it in Jest and Vitest#

waitForEmail() is async and throws on timeout, so it integrates cleanly with both frameworks. The main thing to configure is the test timeout. Jest's default is 5 seconds, which won't cut it for real email delivery.

// jest.config.ts
export default {
  testTimeout: 60000,
};

Or set it per test:

it('sends a welcome email on signup', async () => {
  const inbox = await lm.createSmartInbox({ name: 'signup-test' });
  await yourService.signUp({ email: inbox.address });

  const email = await inbox.waitForEmail({
    timeout: 45000,
    filter: { subject: /welcome/i },
  });

  expect(email.body).toContain('Get started');
}, 60000);

The SDK's timeout value is intentionally shorter than the Jest timeout, so the SDK throws a descriptive error before Jest's generic timeout fires. You get the inbox address, the filter you used, and how long it waited. That context matters when you're debugging a flaky test on your fourth retry.

Vitest uses the same timeout option on it() and test().

Playwright integration#

For E2E tests where you're actually driving a browser through a signup flow, the inbox address goes into the form field:

import { test, expect } from '@playwright/test';
import { LobsterMail } from '@lobsterkit/lobstermail';

test('email verification flow', async ({ page }) => {
  const lm = await LobsterMail.create();
  const inbox = await lm.createSmartInbox({ name: 'playwright-test' });

  await page.goto('https://yourapp.com/signup');
  await page.fill('[name="email"]', inbox.address);
  await page.fill('[name="password"]', 'TestPass123!');
  await page.click('[type="submit"]');

  const email = await inbox.waitForEmail({
    timeout: 30000,
    filter: { subject: /verify your email/i },
  });

  const link = email.htmlBody.match(/https?:\/\/[^\s"]+\/verify[^\s"<]*/)?.[0];
  expect(link).toBeTruthy();

  await page.goto(link!);
  await expect(page.locator('h1')).toContainText('Email verified');
});

Each test gets a fresh inbox. No cleanup needed. Disposable addresses are disposable.

Handling timeout errors#

When waitForEmail() times out, it throws an EmailTimeoutError with context you can act on:

try {
  const email = await inbox.waitForEmail({ timeout: 30000 });
} catch (err) {
  if (err instanceof EmailTimeoutError) {
    console.error(
      `No email in ${err.timeoutMs}ms for ${err.inboxAddress} (filter: ${JSON.stringify(err.filter)})`
    );
  }
  throw err;
}

In CI I usually let the error propagate. The message in the test output points you straight at what to check in your service's email logs.

When to use receive() instead#

waitForEmail() is for flows where timing is uncertain. If you're writing unit tests against emails you've already loaded as fixtures, or your test triggers email synchronously and you know it's already there, inbox.receive() is the right call. It returns whatever is currently in the inbox without waiting.

A few other cases where receive() makes more sense: asserting that no email was sent (an empty array is a clean pass), or batch-asserting a set of emails that all arrive together.

The sandbox testing guide goes deeper on fixture patterns and running your whole email test suite without touching production infrastructure. If you're building tests for an agent that autonomously signs up for services, the agent self-signup post covers the full provisioning flow.

Frequently asked questions

What does waitForEmail() do exactly?

It blocks execution until an email matching your filter arrives in the specified inbox, then returns the email object. If no matching email arrives within the timeout window, it throws an EmailTimeoutError with details about what it was waiting for.

How long should I set the timeout?

30 seconds is a reasonable default for most real-world email flows. For tests in CI where delivery might be slower, 45-60 seconds is safer. Set your test framework's own timeout a few seconds higher than the SDK timeout so you get the SDK's descriptive error rather than a generic framework timeout.

What happens when waitForEmail() times out?

It throws an EmailTimeoutError that includes the inbox address, the filter you specified, and the elapsed time. This makes it much easier to debug than a generic timeout — you can see exactly what the test was waiting for and check your service's outbound email logs accordingly.

Can I filter by email body content?

Not directly in the filter object — the current filters are subject and from. For body-based matching, call waitForEmail() with a subject filter and then assert on email.body or email.htmlBody after it returns.

Does waitForEmail() work with any email service sending to @lobstermail.ai?

Yes. Your service just needs to send email to the inbox address. From your service's perspective it's a normal email address. There's no special integration or webhook to configure.

How is waitForEmail() different from receive()?

receive() returns whatever emails are currently in the inbox and exits immediately. waitForEmail() blocks and waits for new email to arrive. Use receive() when the email should already be there; use waitForEmail() when you don't know how long delivery will take.

Can I wait for multiple emails at once?

Not with a single waitForEmail() call. For multiple emails, call it multiple times with different filters, or use receive() in a polling loop after your flow completes. The sandbox testing guide has examples of multi-email assertion patterns.

Does this work on the free tier?

Yes. The free tier supports 1,000 emails per month, which is more than enough for most test suites. Each test inbox receives a small number of emails, so even a large suite running daily will stay well within limits.

Is there a cost to creating lots of test inboxes?

Inboxes themselves don't have a per-inbox cost. You pay for emails sent and received, not for the number of addresses you provision. Creating a fresh inbox per test is the recommended pattern and doesn't add meaningful cost.

Can I use waitForEmail() in CI/CD pipelines?

Yes, and it's a common use case. Make sure your pipeline's job timeout is longer than your test timeout, and that the LOBSTERMAIL_TOKEN environment variable is set so the SDK doesn't try to create a new account on every run.

What test frameworks does waitForEmail() support?

Any framework that supports async/await works — Jest, Vitest, Mocha, Playwright, Cypress (with async support). The SDK is framework-agnostic. The main thing to configure in your framework is the test timeout.

Can I use a custom domain address for testing?

Yes, if you have a custom domain configured with LobsterMail, you can provision test inboxes on that domain the same way. See the Docs for custom domain setup.

Is LobsterMail's waitForEmail() safe to use in parallel tests?

Yes. Each inbox is isolated — a waitForEmail() call on one inbox only sees emails delivered to that inbox. Running parallel tests with separate inboxes works cleanly with no cross-contamination.


Give your agent its own inbox. Get started with LobsterMail — it's free.

Related posts