Testing & CI

Playwright email testing: read a code from a real inbox

A Playwright test can fill in a sign-up form in two seconds and then has no idea what happened next, because the next step is an email. Here is the fixture that reads it — a real message, from a real mailbox, with no API key — and the three habits that keep the test from flaking.

  • Intermediate
  • 20 min read
A grey browser window with a blue cursor arrow, a blue envelope sliding into a slot in its side, and a grey stopwatch in front

Where a Playwright test usually stops

Most sign-up tests end at the sentence “check your inbox”. The form was filled, the button was clicked, the page said the right thing — and everything that happens after that sentence is assumed. Whether the mail went out, whether the code in it is the code the server expects, whether the confirmation link opens a page that works: all of it is left to production.

It is left there because the next step is asynchronous and lives outside the browser, and Playwright has nothing to click on. The three usual ways round that each prove something different:

Mocking the mailer
Proves your code called send(). Proves nothing about the template, the link, or the provider that rejected the message.
A local SMTP sink (Mailpit, MailHog, smtp4dev)
Proves a well-formed message left the application. One more service in CI, and nothing that only happens on the public internet — a real MX lookup, a real provider, a real recipient — happens here.
A real disposable mailbox
Proves the message left the application, crossed the internet, was accepted by a real mail server and carries a code that works. The only cost is that the test has to wait properly — which is the whole of this guide.

The API behind it is three endpoints with no key, documented in the reference. If you want the general discipline before the Playwright specifics, testing a verification flow end to end covers it for any runner; this is the Playwright version, with the fixture that makes it pleasant.

A fixture that gives every test its own inbox

Playwright’s test.extend is the right place for this: an inbox becomes something a test asks for by name, like page, and the address is invented fresh each time. Nothing has to be created on the server — a mailbox exists the moment mail reaches it — so the fixture is a class with a random address in it and three small methods.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

Two things in that file are deliberate. The address is random per test, not per file or per run, so parallel workers can never read each other’s mail. And waitFor returns the full message rather than the summary — in practice you always want the body next, and one call fewer in every test adds up.

Waiting for the message without a sleep

Mail is not synchronous. It normally lands in two or three seconds and occasionally takes twenty, and the way the test waits decides whether the suite can be trusted. The rules are short:

  • A deadline, not a retry count. for (let i = 0; i < 30; i++) is thirty attempts at whatever speed the loop happens to run — shorter as the API gets faster, longer as your sender gets slower. A wall-clock deadline means the same thing on every machine.
  • One read per second. That is the documented rhythm and it is never throttled. Faster is refused with 429 and a Retry-After header, and polling faster would not make the mail arrive sooner.
  • No waitForTimeout. A fixed sleep is either too short on a slow day or too long on every other day. The loop stops the moment the message exists.
  • Filter; do not take the newest message blindly. Pass subjectContains or fromContains. When a flow sends two messages — a welcome and a code — the newest is not always the one you want.

The status codes the loop will meet, and what it should do with each:

CodeMeansWhat the loop does
200The mailbox was read. count may be 0 — an empty mailbox is never a 404.Look for a match; if there is none, sleep one second and go again.
400The address is malformed.Throw. Retrying a typo does not fix it.
404The domain is not hosted here.Throw, and check the MX record if it is your own domain.
429More than one read a second for that address, or more than 1200 requests a minute from this runner.Sleep for Retry-After seconds and continue. Never fail the test on a 429.

Getting the code, or the link, out of the message

The message comes back with both parts, and which one to parse depends on what your application sends:

text
The plain-text part. Parse this when it exists — no markup, and a six-digit code is a six-digit code.
html
The HTML part, or null when the sender sent text only. Confirmation links often live only here, inside an <a href>, with & written as &amp;.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

The pattern is anchored on the wording of your own template on purpose. [0-9]{6} alone happily matches a year, a price or an order number that happened to appear first; code is ([0-9]{6}) matches your code and nothing else — and the day somebody rewords the email, the test fails and tells you, rather than passing on the wrong number.

Links are matched on a path fragment you know — /confirm/, /reset/ — rather than on “the first URL”, because a transactional email usually carries five: the logo, the unsubscribe, the help centre, the app-store badge, and the one you want.

Three flows, end to end

With the fixture and the extractors in place, each test reads like the feature it exercises. The waiting, the polling and the parsing are somewhere else, which is the whole reason for putting them there.

Sign-up with a confirmation code

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

A magic link that signs the user in

Nothing to type: the test visits the link the message carries, and asserts on where it lands.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

A password reset, then a login with the new password

The reset test needs a user that already exists, which is a job for your application’s own test seam — an internal endpoint, a database fixture, a CLI — not for the browser. Then the flow is the same shape as the others: request, wait, follow, assert.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Making it survive CI

Everything above works on a laptop. These are the things that only break once it runs twenty times a day on someone else’s machine.

SymptomCauseFix
Passes locally, fails in CIThe runner cannot reach the public internet, or egress is filtered.Allow grabmail.io over HTTPS. Nothing else — no SMTP port, no inbound.
Fails first time, passes on retryYour sender queues mail and the deadline is shorter than the queue.Raise the deadline before touching anything else. Sixty seconds is a fair ceiling for a transactional mail.
429 in burstsSeveral tests polling one address, or the whole runner past 1200 requests a minute.One address per test — the fixture does that. The client ceiling is twenty mailboxes polled once a second.
Green build, broken featureA reused address served an old message.A random address per test. This is the one that matters.
Flaky only with several workersTwo tests sharing a mailbox, or an assertion on which message is newest.A fresh address per test and a subjectContains filter; never the newest message blindly.
Works for a week, then neverA fixture that cached a message id; everything here is deleted after 5 days.Tests must trigger their own mail on every run. Nothing survives 5 days.

There is no secret to store. The public domains take no key, no account and no header — if your pipeline needs a credential to run these tests, something has been misunderstood. The one setting worth writing down is the timeout, because it is the one thing Playwright’s defaults get wrong for a test that waits on mail:

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

A GitHub Actions workflow that runs this suite, with the deadline and the egress question settled, is written out in the CI guide.

If your application refuses disposable domains

Some sign-up forms check the address against the public lists of disposable domains and refuse grabmail.io on sight. That is a feature of your application, not a fault of the test — and the fix is not to weaken the check for the test environment. Point a domain you own at this service instead: one MX record, no account, and every address on it becomes a mailbox the same fixture can read by changing one constant.

Turning a domain into a catch-all inbox is the setup; unlimited test accounts on one domain is what it looks like in a test suite.

Before you call it done

  • A different address for every test, from the fixture — never a constant.
  • A wall-clock deadline, and a failure that names the address it waited on.
  • 429 handled by sleeping out Retry-After, not by failing.
  • The code or link matched against your own wording, not a bare pattern.
  • The test timeout comfortably above the mail deadline.
  • A filter on subject or sender, so the right message wins when two arrive.
  • No assertion on how fast the mail came — only that it came.

That is the whole discipline. Everything else about testing mail in Playwright is the same as testing anything else asynchronous. The same helper as Cypress commands is in the Cypress guide; the extraction rules on their own, for any runner, are in OTP codes in automated tests.

Questions

Do I need an API key to read the mailbox from Playwright?

No. The public domains take no key, no account and no header. Only the paid pool of domains that stays off the disposable-mail blocklists needs an Authorization: Bearer header, and that is a separate product.

Can the tests run in parallel workers?

Yes, and that is the point of a random address per test: two workers can never read each other’s mail. The per-client ceiling is 1200 requests a minute, which is twenty mailboxes polled once a second — plenty for a suite, and the fixture never polls faster than once a second anyway.

Should I use Playwright’s request fixture instead of fetch?

Either works. fetch is used here because the helper then runs unchanged in a plain Node script, a global setup, or another runner. Playwright’s request adds tracing of the calls, which is worth having if you want the polling to show up in the trace viewer.

What if the email arrives before the test starts polling?

Nothing changes. The first poll returns it. A mailbox holds what arrives for 5 days whether or not anyone is reading, so a message that lands during the click is simply there on the next request.

Is the mailbox private while the test uses it?

No. Anyone who knows the address can read it, on a public domain and on your own. For a random address that exists for eleven seconds and holds one throwaway code that is irrelevant; for a staging environment that sends real customer mail it is disqualifying — do not point one here.

How do I clean up afterwards?

Optionally, with a DELETE on the message, which is idempotent. Everything expires after 5 days regardless, so a run that skips cleanup costs nothing — deleting only makes the next failure easier to read.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.