Playwright can drive your signup form, but it cannot open the inbox that your app emails a code to. To test email verification end-to-end, you pair Playwright with a disposable-mail API: mint a real address per test, use it in the form, poll the API for the message, extract the code, type it back into the browser, and assert. This post is the working setup — a reusable helper module and a full @playwright/test spec — with the trade-offs spelled out.
Quick answer
Playwright email testing works like this: before the test runs, call a disposable-inbox API to create a real, unique address. Fill your signup form with that address and submit. The app sends its verification email; your test polls the mail API until the message lands, extracts the one-time code (OTP) with a scoped regex, types it into the verification field, and asserts the account is confirmed. Tear the inbox down afterward.
Poll from inside the test rather than wiring up webhooks — the test is already blocking, so a bounded expect.poll loop is the natural fit. Give each test its own inbox so parallel workers never collide. The rest of this guide is the code and the hardening that keeps it green in CI.
Why you can't test email with Playwright alone
Playwright is a browser automation tool. It clicks, types, navigates, and asserts on the DOM — and that is the whole of its world. The verification email your app sends lives entirely outside the browser: it goes to a mail server, gets delivered to a mailbox, and never touches the page Playwright is looking at. So the moment your signup flow says "check your email for a code," an unaided Playwright test hits a wall it has no way over.
Teams usually try one of three workarounds, and each one bites:
- A real Gmail account with plus-addressing and IMAP. You register
[email protected], then connect to Gmail over IMAP to fetch the message. In practice this means OAuth ceremony, app passwords, provider rate limits, and a shared account whose state leaks between runs. It is slow, and it turns your test suite into a mail-client integration you now have to maintain. - One shared test inbox for the whole suite. Point every test at
[email protected]and grep for the latest message. This collapses the instant you run in parallel: worker A reads worker B's code, and the suite goes flaky in the most maddening way — green locally, red in CI, never the same test twice. - Mocking the mail service. Stub out the email send and assert the code from your own database or a fake transport. This is fast and deterministic, but it tests nothing real. The mail template, the address validation, the deliverability, the actual code that reaches a user — all of it is now outside the test. You have a green check that says the part you replaced works.
The fix is to keep the flow real and give the test a mailbox it can actually read from code. A disposable-mail API does exactly that: each test creates its own throwaway inbox, uses the address in the browser, and reads the delivered message back over HTTP. Playwright owns the browser half; the API owns the email half; nothing is mocked and nothing is shared.
The worked example
Here is a setup you can drop into a @playwright/test project. It has two parts: a small helper module that talks to the TempMaily API, and a spec that uses it to test a signup-and-verify flow end to end.
The helper is intentionally self-contained — plain fetch, no SDK — so you can read every request. Every endpoint, header, and field name below matches the API reference; reconcile against that page if anything changes.
// tests/helpers/mailbox.ts
const API = "https://tempmaily.co/api/v1";
const KEY = process.env.XTM_KEY; // Premium API key, xtm_-prefixed
if (!KEY) throw new Error("XTM_KEY is not set");
const headers = {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
};
export interface Inbox {
id: string;
address: string;
expiresAt: string;
}
// Create a fresh disposable inbox. Each call returns a unique address, so
// every test (and every parallel worker) gets its own mailbox.
export async function createInbox(): Promise<Inbox> {
const res = await fetch(`${API}/inboxes`, {
method: "POST",
headers,
body: JSON.stringify({ expiry: "1d" }),
});
if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
const { id, address, expiresAt } = await res.json();
return { id, address, expiresAt };
}
// Poll the inbox until a message matching `match` arrives, then return its
// full body. The rate limit is 300 requests / 60s per key, so a 2s interval
// leaves plenty of headroom even with many workers polling at once.
export async function waitForMessage(
inboxId: string,
match: (subject: string) => boolean,
{ timeoutMs = 30_000, intervalMs = 2_000 } = {},
): Promise<{ bodyText: string; bodyHtml: string; subject: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const list = await fetch(`${API}/inboxes/${inboxId}/messages`, { headers });
if (!list.ok) throw new Error(`list messages failed: ${list.status}`);
const { messages = [] } = await list.json();
const hit = messages.find((m: { subject: string }) => match(m.subject));
if (hit) {
// Fetch the full message — the list endpoint returns summaries only.
const full = await fetch(`${API}/messages/${hit.id}`, { headers });
if (!full.ok) throw new Error(`get message failed: ${full.status}`);
return full.json(); // { subject, bodyText, bodyHtml, ... }
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`No matching message in inbox ${inboxId} within ${timeoutMs}ms`);
}
// Pull a six-digit OTP out of the message. Prefer the text part; fall back to
// HTML. Anchor to nearby copy so a footer number never matches first.
export function extractCode(message: {
bodyText: string;
bodyHtml: string;
}): string {
const body = message.bodyText || message.bodyHtml || "";
const anchored = body.match(/code(?:\s+is)?[:\s]+(\d{6})/i);
if (anchored) return anchored[1];
const bare = body.match(/\b(\d{6})\b/);
if (bare) return bare[1];
throw new Error("No 6-digit code found in message body");
}
// Delete the inbox and its mail once the test is done.
export async function deleteInbox(inboxId: string): Promise<void> {
const res = await fetch(`${API}/inboxes/${inboxId}`, {
method: "DELETE",
headers,
});
// 204 on success; 404 means it was already gone — both are fine in teardown.
if (!res.ok && res.status !== 404) {
throw new Error(`deleteInbox failed: ${res.status}`);
}
}
Now the spec. It creates an inbox in beforeEach, runs the real signup, waits for the code, verifies, and asserts — then cleans up in afterEach. The one subtlety worth calling out is the poll: rather than build a second loop inside the test, we let expect.poll retry the "did the code arrive and did verification succeed" step, and delegate the actual mail wait to waitForMessage.
// tests/signup-verification.spec.ts
import { test, expect } from "@playwright/test";
import { createInbox, waitForMessage, extractCode, deleteInbox } from "./helpers/mailbox";
import type { Inbox } from "./helpers/mailbox";
test.describe("signup email verification", () => {
let inbox: Inbox;
test.beforeEach(async () => {
inbox = await createInbox();
});
test.afterEach(async () => {
if (inbox) await deleteInbox(inbox.id);
});
test("user can sign up and confirm their email", async ({ page }) => {
// 1. Drive the real signup form with the disposable address.
await page.goto("/signup");
await page.getByLabel("Email").fill(inbox.address);
await page.getByLabel("Password").fill("Sufficiently-Long-Pw-9!");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText(/check your email/i)).toBeVisible();
// 2. Wait for the verification email and pull out the OTP.
const message = await waitForMessage(
inbox.id,
(subject) => /verify|confirm|code/i.test(subject),
{ timeoutMs: 30_000, intervalMs: 2_000 },
);
const code = extractCode(message);
expect(code).toMatch(/^\d{6}$/);
// 3. Complete verification in the browser and assert the result.
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText(/email verified/i)).toBeVisible();
});
});
That is the whole flow: a real inbox, a real email, a real code typed into a real browser, asserted end to end. No mock, no shared mailbox, no human copying a code out of Gmail.
Hardening against flaky email tests
The example above passes on a good day. The difference between that and a suite that stays green for a year is in the edges.
Set the timeout against real mail latency, not a guess. Verification mail usually lands in a second or two, but a cold sender, a queued job, or a busy CI runner can push it to ten or more. A 30-second timeoutMs gives comfortable headroom; a 5-second one turns a slightly slow delivery into a red build. Measure your own p99 delivery time and set the bound above it, not at it.
Isolate an inbox per worker, not per suite. Because createInbox runs in beforeEach, every test — and therefore every parallel worker Playwright spins up — gets a unique address. That is the whole reason parallel email tests stop colliding. Never move inbox creation up to a shared beforeAll that hands one address to many tests; you will reintroduce exactly the race condition disposable inboxes exist to kill.
Tear inboxes down. The afterEach calls DELETE /inboxes/{id}, which removes the inbox and its mail and frees your account's storage quota. Treat 404 as success — if a test failed before the inbox was created, the delete is a no-op, and teardown should never throw over that.
Keep the key in a CI secret. The API key is a Bearer credential; it belongs in an environment variable your CI injects, never in the repo. In GitHub Actions, store it as a repository secret and expose it to the job:
# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
XTM_KEY: ${{ secrets.XTM_KEY }}
Know when to switch to webhooks. Polling is right for a test runner because the test is already waiting. But if you are building something that reacts to inbound mail outside a test loop — a staging service, a background worker, a demo bot — polling is the wrong shape and a webhook is the right one. Register a public HTTPS endpoint once and TempMaily POSTs each message to it as it arrives, verified with an X-Xtm-Signature header. The API reference covers webhook registration and signature verification; inside a Playwright spec, stay with polling.
Common mistakes
- Matching any six digits.
\b\d{6}\bmatches a copyright year, an order number, or a support line before it matches the code. Anchor to the surrounding copy, asextractCodedoes withcode(?:\s+is)?[:\s]+. - Reading the wrong field. The full-message response exposes
bodyTextandbodyHtml— nottext/html. Reach forbodyTextfirst; some senders only populate the HTML part, so fall back tobodyHtml. - Asserting on a message summary. The list endpoint returns summaries with a
snippet, not the full body. You must fetchGET /messages/{id}to getbodyText/bodyHtmlbefore you can extract a code.waitForMessagedoes this second fetch for you. - Unbounded polling. A
while (true)with no deadline turns one slow delivery into a hung pipeline. Always fail loudly after a timeout. - Sharing one inbox across tests. It works until the first parallel run, then it is the flakiest thing in your suite.
- Ignoring the rate limit. The key is capped at 300 requests / 60s. A 2-second poll interval is well within budget, but a tight loop across dozens of workers can trip
429 rate_limited— space your polls out.
An honest caveat
Two things worth being straight about. First, free inboxes expire — the default lifetime is a day, which is plenty for a test that runs and asserts in seconds, but it means you should never hardcode an address and expect it to survive between runs. Create a fresh one each time; that is the pattern here anyway. Second, some staging environments block known disposable domains at signup, which is the whole point of that defense — and it means your own app may reject a free TempMaily address before the flow even starts. If that is happening to you, it is working as designed; we wrote up the mechanics in why websites block temp mail. For tests, the fix is to use a Premium dedicated or custom domain that your app treats as legitimate, so the verification path behaves exactly as it would for a real user.
Next steps
Pairing Playwright with a disposable-mail API turns email verification from the flakiest step in your suite into one more assertion. Create an isolated inbox per test, drive the real form, poll for the message, extract the OTP with a scoped regex, verify in the browser, and tear the inbox down. If you want the framework-agnostic version of this pattern — the general poll-and-extract flow and the webhook deep-dive — the companion guide on automating email verification with the temp mail API covers it.
Programmatic access is part of TempMaily Premium, and the API reference is the source of truth for every endpoint, field, and limit used above. Start from the homepage if you are new to disposable inboxes, then wire the helper into your @playwright/test project and delete the "we skip email in CI" line from your test plan.