Skip to content
GuideRun end to end with Playwright 1.64, 4 min read

Playwright email testing with a catch-all inbox

A Playwright fixture that gives every test its own email address and waits for the mail to arrive. No inboxes to create, no polling loop, and parallel workers never read each other's mail.

tests/signup.spec.ts
const { message, codes } = await email.next();
await page.getByLabel("Code").fill(codes[0]);
Terminal output of npx playwright test with two passing sign up tests that each received their own emailed code
Two Playwright tests running in parallel, each signing up with its own Catch-all address and reading its own code. Both passed in 2.7 seconds.

Set up in one paste

What you need:

  1. 1

    A Playground API key

    The Catch-all lives in the Playground project, so create the key there. A key from another project cannot see it, and the fixture says so.
  2. 2

    An app that sends email

    To the address a test types in. For this page we used a 26 line Node server that emails a six digit code; your real app takes its place.
  3. 3

    Playwright

    We used 1.64. The fixture uses only fetch, so there is nothing else to install.

Why a Catch-all works for tests is explained in catch-all inboxes for end to end tests. This page is the complete, runnable project. Testing is your job? See Mailbay for QA and test engineers.

The fixture

A Playwright fixture gives each test an email object: a fresh address, and a next() that blocks until mail to that address arrives. It notes the time before the test acts, so an email that arrives before the wait starts is still found. The wait endpoint is documented under wait for an email.

import { test as base } from "@playwright/test";

const API = process.env.MAILBAY_API_URL ?? "https://api.mailbay.dev/v1";
const headers = { Authorization: `Bearer ${process.env.MAILBAY_API_KEY}` };

async function get(path: string) {
  const res = await fetch(`${API}${path}`, { headers });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (fix: ${body.error.fix})`);
  return body;
}

type Email = { message: { subject: string; text: string }; codes: string[]; links: string[] };

/** `email.address` is fresh for every test; `email.next()` blocks until mail to it arrives. */
export const test = base.extend<{ email: { address: string; next: (timeout?: number) => Promise<Email> } }>({
  email: async ({}, use, testInfo) => {
    const me = await get("/me");
    const { data } = await get("/inboxes");
    const catchAll = data.find((i: { catch_all: boolean }) => i.catch_all);
    if (!catchAll) throw new Error("No Catch-all inbox: use a key from the Playground project");
    const address = `e2e-${testInfo.workerIndex}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}@${me.shared_domain}`;
    const after = new Date().toISOString();
    await use({
      address,
      next: (timeout = 30) =>
        get(`/inboxes/${catchAll.id}/wait?${new URLSearchParams({ to: address, after, timeout: String(timeout) })}`),
    });
  },
});
export { expect } from "@playwright/test";

What the tests check

  • email.next()

    Sign up with the emailed code

    The first test signs up, reads the subject and fills in codes[0], then expects the welcome heading.
  • Its own address and code

    The second test gets its own address and checks the code is six digits.
  • Parallel workers

    Each address carries the worker index, a timestamp and a random part, so workers never read each other's mail.
  • No cleanup

    Addresses are never reused, so there is nothing to delete between runs.
  • Errors with a fix

    A failed API call throws with the code, the message and the fix Mailbay returned.

Run it

We ran the project on October 14, 2026 against a local Mailbay stack, with the app sending over SMTP to Mailbay's receiving server. Two workers, two tests, each with its own address and code: the capture at the top of this page. Against the hosted service, leave MAILBAY_API_URL unset; it defaults to https://api.mailbay.dev/v1.

In CI

The only secret is the key. Store it as a CI secret and expose it to the test step:

- run: npx playwright test
  env:
    MAILBAY_API_KEY: ${{ secrets.MAILBAY_PLAYGROUND_KEY }}

Addresses are never reused, so there is nothing to clean up between runs. Received mail does not count toward a plan's sending allowance; Mailbay's receiving server does limit one workspace to 300 recipients per 10 minutes, which matters only for very large suites. These tests prove your app sends the right email; whether it reaches inboxes is a separate check, which the email deliverability test runs on one real message.

When a wait times out

408 wait_timeout
Nothing arrived for that address in time. Check the address your app actually sent to, and that the app sends to external domains in the test environment.
“No Catch-all inbox”
The key belongs to a project other than the Playground.
The code is not in codes
Mailbay extracts 4 to 8 digit codes. For other formats, read message.text or use links.

Moving from Mailosaur? The Mailosaur alternative page maps its calls to these. Testing from Python instead? See the Python integration, and all integrations on the integrations page.

Questions

Do I need to create an inbox for every test?

No. Any address under your workspace's private mailbay.email domain reaches the Catch-all, so each test makes up an address and waits for mail to exactly that address.

How do parallel workers avoid reading each other's email?

Each test gets its own address, with the worker index, a timestamp and a random part in it, and waits with to set to that exact address. A message to another test's address never matches.

Does this work with Cypress?

Yes. The fixture is plain fetch calls to the API. In Cypress, put the same two calls in a cy.task so they run in Node, and call the task from the test.

How long can a test wait for an email?

One wait call blocks for up to 120 seconds. If it times out, Mailbay answers 408 with wait_timeout, and calling wait again is safe.

Join the beta

Mailbay is in private beta and free while the beta runs: inboxes, sending, your own domains and MCP. We invite people in small batches and read every note on the form.