Playwright email testing
Playwright drives the browser; the email step needs a mailbox your test can read. This integration gives you a small polling helper plus a complete spec that signs a user up, waits for the verification email, extracts the code and finishes the flow — the pattern transfers directly to OTP and password-reset tests.
page.fill email → page.click submit → waitForMessage(addr) → extractCode(preview) → page.fill code → assert success
The inbox helper
Save as wait-for-email.mjs next to your specs. It polls the free inbox API — no credentials for named inboxes — and returns the first matching message:
// wait-for-email.mjs — poll the Tempx inbox API until a matching mail lands
const TEMPX = "https://tempx.uk/api/inbox";
export async function waitForMessage(address, opts = {}) {
const timeoutMs = opts.timeoutMs ?? 45000;
const intervalMs = opts.intervalMs ?? 2000;
const match = opts.match ?? (() => true);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(TEMPX + "?address=" + encodeURIComponent(address));
if (res.status === 429) {
await new Promise((r) => setTimeout(r, 5000));
continue;
}
if (!res.ok) throw new Error("Tempx API returned " + res.status);
const { messages } = await res.json();
const hit = messages.find(match);
if (hit) return hit;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("No matching email for " + address + " within " + timeoutMs + "ms");
}
export function extractCode(text) {
const m = /\b(\d{4,8})\b/.exec(text || "");
return m ? m[1] : null;
}
A complete spec: signup with email verification
// signup.spec.mjs
import { test, expect } from "@playwright/test";
import { waitForMessage, extractCode } from "./wait-for-email.mjs";
// unique inbox per run — parallel workers never share mail
const addr = "pw-" + (process.env.TEST_RUN_ID || "local") + "-signup@tempx.uk";
test("signup sends a verification email with a working code", async ({ page }) => {
await page.goto("https://staging.example.com/signup");
await page.getByLabel("Email").fill(addr);
await page.getByLabel("Password").fill("correct-horse-battery");
await page.getByRole("button", { name: "Create account" }).click();
const msg = await waitForMessage(addr, {
timeoutMs: 45000,
match: (m) => /verify|confirm/i.test(m.subject),
});
expect(msg.from).toContain("noreply@example.com");
const code = extractCode(msg.preview);
expect(code).toHaveLength(6);
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page.getByText("Welcome")).toBeVisible();
});
Notes that save an afternoon
- Match on subject, read from the preview.
messages[]items carryid,from,fromName,subject,preview(first 140 chars of the text part),receivedAtandseen— see the API overview. - Rate limits are your friend. 240 reads/minute: a 2-second poll per test is fine even with 10 workers; beyond that, stagger starts or share one poller per worker process.
- Keep run IDs unique in CI.
TEST_RUN_ID: {{ github.run_id }}-style env keeps parallel workflow jobs hermetic. - Turn off the flaky sleep. If your suite still has
waitForTimeout(10000)in front of email steps, this replaces it with a real condition.
Run this spec against your staging
The only setup is picking an address prefix — the inbox exists the moment your app sends to it.
Open an inbox