# E2E email testing with Playwright

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/e2e-email-testing/

If you've ever tried to test a "click the link in your email" flow in Playwright, you know the shape of the suffering: shared Gmail catch-all, OAuth on the test runner, forwarding rules to a Slack channel, label rules to scope per-PR runs, race conditions when two CI workers read the same inbox at the same moment. The shared inbox is the single biggest source of flakiness in email-dependent E2E tests.

The fix: every test gets its own address on a wildcard inbox you control. No OAuth on the runner. No race conditions. No "did this email arrive yet?" timeouts that fire 200ms before the email actually arrives.

## The setup

One-time, using the [Nylas CLI](https://cli.nylas.com/):

```bash
nylas inbound create e2e
```

You get back an inbox ID and a wildcard pattern shaped like `e2e-*@yourapp.nylas.email`. See the [`nylas inbound create`](https://cli.nylas.com/docs/commands/inbound-create) and [`nylas inbound list`](https://cli.nylas.com/docs/commands/inbound-list) reference for full options. From here on, each test fixture mints its own unique address under that wildcard.

## Per-test fixture

```ts


type Fixtures = {
  testEmail: string;
  pollInbox: (timeoutMs?: number) => Promise<any>;
};

export const test = base.extend<Fixtures>({
  testEmail: async ({}, use) => {
    const addr = `e2e-${randomUUID()}@yourapp.nylas.email`;
    await use(addr);
  },

  pollInbox: async ({ testEmail }, use) => {
    const poll = async (timeoutMs = 30_000) => {
      const deadline = Date.now() + timeoutMs;
      while (Date.now() < deadline) {
        const out = execSync(
          `nylas inbound messages ${process.env.INBOX_ID} --json --limit 50`,
        ).toString();
        const msgs = JSON.parse(out);
        const match = msgs.find((m: any) =>
          m.to.some((t: any) => t.email === testEmail),
        );
        if (match) return match;
        await new Promise((r) => setTimeout(r, 1500));
      }
      throw new Error(`Email never arrived for ${testEmail}`);
    };
    await use(poll);
  },
});

export { expect };
```

Two fixtures: `testEmail` mints a UUID-suffixed address per test, `pollInbox` waits for a matching message and returns it.

## Use it

A signup-with-verification flow:

```ts
test("signup completes after email verification", async ({ page, testEmail, pollInbox }) => {
  await page.goto("/signup");
  await page.getByLabel("Email").fill(testEmail);
  await page.getByLabel("Password").fill("hunter2-correct");
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page.getByText("Check your inbox")).toBeVisible();

  const msg = await pollInbox();
  const linkMatch = msg.body.match(/https:\/\/[^\s"<]+\/verify\?[^\s"<]+/);
  expect(linkMatch).not.toBeNull();

  await page.goto(linkMatch![0]);
  await expect(page.getByText("Email verified")).toBeVisible();
});
```

Password reset is the same shape — fill the form, poll the inbox, extract the link, navigate, complete the flow.

## Parallel-safe by construction

Playwright runs tests in parallel across workers. Each test gets a different `testEmail` UUID, so the polling logic only sees its own messages. No filtering by subject, no "wait until the right message bubbles to the top" — you just look for `to: testEmail` and you've got the right mail.

For Playwright's `fullyParallel: true` config, the `INBOX_ID` is shared but the addresses are unique. The wildcard endpoint accepts mail to all of them.

## Common patterns

### OTP capture

For 2FA / OTP flows, parse the body for a 6-digit number:

```ts
const msg = await pollInbox();
const code = msg.body.match(/\b\d{6}\b/)?.[0];
expect(code).toBeDefined();
await page.getByLabel("Verification code").fill(code!);
```

For more robust extraction (the body sometimes has multiple 6-digit numbers — phone numbers, transaction IDs, etc.), use the [Extract OTP codes from email](/docs/cookbook/cli/extract-otp-codes/) helper.

### HTML link extraction

When the verification URL is in `<a href>` rather than plain text, parse the HTML:

```ts


const $ = cheerio.load(msg.html);
const link = $('a:contains("Verify your email")').attr("href");
```

### Cleanup

By default, mail in the inbox stays for the standard retention window. For a noisier signal, mark messages read after each test:

```ts
test.afterEach(async ({ pollInbox }) => {
  // optional — only if you want a clean inbox for debugging
  execSync(`nylas inbound messages-read ${process.env.INBOX_ID} --to ${testEmail}`);
});
```

## Things to know

- **Latency is sub-5-seconds typical.** The poll interval (1.5s) catches most messages within two iterations. Bump the timeout for unusually slow flows; 30s is generous for most.
- **The CLI is sync.** Playwright's `execSync` blocks the test until the CLI returns. For chatty test suites, consider replacing with `execAsync` and awaiting in parallel.
- **One inbox, many addresses.** You don't pay or provision per address — the wildcard pattern is just a convention. Burn UUIDs freely.
- **MX is on Nylas.** Mail flows through Nylas's MX. There's nothing to configure in your DNS. The downside: addresses always live under `*.nylas.email`.

## Next steps

- [Extract OTP codes from email](/docs/cookbook/cli/extract-otp-codes/)
- [Webhooks API reference](/docs/reference/notifications/)
- [Nylas CLI](https://cli.nylas.com/) — installation and full [command reference](https://cli.nylas.com/docs/commands)