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_KEYin your CI secrets. - Your domain is
shared_domaininGET /v1/me. The Catch-all lives in your Playground project and shows up inGET /v1/inboxeswith"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:
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
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.
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.
tomatches 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.
waitmatches mail received after the call starts. Note the time before triggering the email and pass it asafter, 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.
Read next
- Mailbay vs MailosaurMailosaur is a testing tool: email and SMS, previews, spam checks and SDKs for every test framework. Mailbay covers the email part of that and also sends real mail.
- An email inbox for your AI agentAn agent that signs up for things needs an address that receives mail, and a way to get the code out of it without polling. Here is the whole flow in three steps.
- Messages and threadsRead mail, wait for it with a filter, and follow threads.
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.