Skip to content

Catch-all inboxes for end to end tests

Email is the step most end to end suites skip, because it is slow and flaky to check. A catch-all domain makes every test its own address, and a blocking wait removes the polling.

Updated 3 min read

On this page (6)

The idea

End to end tests for sign up, magic links and password resets need three things from email: an address that has never been used, a way to know when the message arrived, and the code or link out of it. Shared Gmail accounts with plus addressing get you the first, badly. Polling an IMAP inbox gets you flaky tests.

Mailbay gives every workspace a private domain such as k7m3qz.mailbay.email. Any address under it that is not a named inbox lands in the Catch-all, so each test can invent its own address with no API call. Then one request waits for mail to that exact address and returns the codes and links in it. Parallel tests never see each other's mail.

Setup

  • Create an API key in the dashboard and store it as MAILBAY_API_KEY in your CI secrets.
  • Your domain is shared_domain in GET /v1/me. The Catch-all lives in your Playground project and shows up in GET /v1/inboxes with "catch_all": true; use a key from that project.

A small helper covers both calls. It uses only fetch, so it runs in Node 18 and later:

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

let catchAll: { domain: string; id: string } | undefined;

async function setup() {
  if (catchAll) return catchAll;
  const me = await (await fetch(`${API}/v1/me`, { headers })).json();
  const { data } = await (await fetch(`${API}/v1/inboxes`, { headers })).json();
  catchAll = { domain: me.shared_domain, id: data.find((i: { catch_all: boolean }) => i.catch_all).id };
  return catchAll;
}

/** A fresh address nobody else will use, such as signup-1791979001036-k2x9qa@k7m3qz.mailbay.email */
export async function newAddress(label: string) {
  const { domain } = await setup();
  return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@${domain}`;
}

/** Blocks until mail to `to` arrives (up to 120 s); returns the message, codes and links. */
export async function waitForEmail(to: string, { after, timeout = 60 }: { after?: string; timeout?: number } = {}) {
  const { id } = await setup();
  const q = new URLSearchParams({ to, timeout: String(timeout) });
  if (after) q.set("after", after);
  const res = await fetch(`${API}/v1/inboxes/${id}/wait?${q}`, { headers });
  const body = await res.json();
  if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}. ${body.error.fix}`);
  return body as { message: { subject: string; text: string }; codes: string[]; links: string[] };
}

A Playwright test

tests/signup.spec.ts
import { expect, test } from "@playwright/test";
import { newAddress, waitForEmail } from "./mailbay";

test("sign up with an emailed code", async ({ page }) => {
  const email = await newAddress("signup");
  const after = new Date().toISOString();

  await page.goto("/signup");
  await page.getByLabel("Email").fill(email);
  await page.getByRole("button", { name: "Send code" }).click();

  const { codes } = await waitForEmail(email, { after });
  await page.getByLabel("Code").fill(codes[0]);
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});

test("password reset link", async ({ page }) => {
  const email = await newAddress("reset");
  // ... create the user and request a reset for `email` ...
  const { links } = await waitForEmail(email);
  await page.goto(links.find((l) => l.includes("/reset"))!);
  // ...
});

The same helper works in Cypress inside cy.task, in Vitest or Jest, or in any language that can make an HTTP request. From a shell script, the CLI does the same: mailbay wait ibx_… --to signup-42@k7m3qz.mailbay.email --timeout 60s.

We ran this helper against a real Mailbay stack: two addresses created in parallel, an email sent to each over SMTP, and both waits returned their own message with the code extracted. A wait with no mail fails after its timeout with wait_timeout and a fix.

Parallel runs

  • Unique addresses, no cleanup. A timestamp plus a random suffix makes collisions practically impossible across workers and CI jobs, and there is nothing to delete afterwards.
  • Exact matching. to matches one recipient address exactly, against the address the mail was delivered to, so a test never picks up another test's message.
  • No race with fast senders. wait matches mail received after the call starts. Note the time before triggering the email and pass it as after, as the first test does, and an email that arrives before you start waiting is still found.

Testing what your app sends

If the app under test sends through Mailbay too, point it at the test recipients: delivered@test.mailbay.dev, bounced@test.mailbay.dev and complained@test.mailbay.dev simulate each outcome, emit the matching webhook event and never touch your suppressions. Add a +tag such as bounced+checkout@test.mailbay.dev to tell cases apart.

Limits to know

  • A wait lasts at most 120 seconds. Call it again if your app is slower; repeating is safe.
  • Messages up to 25 MB are accepted, attachments included.
  • Mailbay tests email only. For SMS codes, client previews or spam reports, a dedicated testing tool is the better fit: see Mailbay vs Mailosaur.

Playwright, Cypress and plain API versions are side by side on Mailbay for QA and test engineers.

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.