Blog
Test email OTP in Playwright — no polling, no shared Gmail
2026-07-30
Every OTP test starts the same way: the app under test emails a code, and
your test needs to read it. The classic answers all hurt. A shared Gmail
account works until Google decides your CI datacenter looks suspicious. An
IMAP library adds a flaky dependency and a polling loop. And waitForTimeout(5000)
is just a coin flip with extra steps.
Here's the whole thing with a disposable inbox you name on the spot.
The recipe
Your account has a handle — a namespace like witty-otter-7 (generated
free; custom names like bluepine are a paid option). Every scenario under it
is an inbox with no creation step: witty-otter-7.otp_code_on_login@mailmatter.dev
starts receiving the moment anything sends to it, and the part after the
dot names the test case so parallel flows stay separate.
import { test, expect } from "@playwright/test";
const INBOX = "witty-otter-7.otp_code_on_login"; // <handle>.<scenario>
const API = "https://mailmatter.dev/api";
test("user can log in with an emailed OTP", async ({ page, request }) => {
const since = Date.now();
// 1. Trigger the send in the app under test
await page.goto("https://staging.bluepine.app/login");
await page.fill("#email", `${INBOX}@mailmatter.dev`);
await page.click("text=Send code");
// 2. Block until the mail lands — long-poll, not a poll loop
const res = await request.get(
`${API}/mailbox/${INBOX}/wait?since=${since}&timeout=30`,
{ headers: { Authorization: `Bearer ${process.env.MAILMATTER_KEY}` } },
);
const { message } = await res.json();
expect(message, "OTP email never arrived").not.toBeNull();
// 3. Extract and use the code
const otp = message.text?.match(/\b(\d{6})\b/)?.[1];
expect(otp, `no 6-digit code in: ${message.subject}`).toBeTruthy();
await page.fill("#otp", otp!);
await page.click("text=Verify");
await expect(page).toHaveURL(/dashboard/);
});
Three details do the heavy lifting:
since=Date.now()— the wait only resolves for mail newer than the moment you triggered the send, so a code from the previous run can never satisfy this run's assertion./waitlong-polls server-side (up to 55s per request; loop it for longer). Your test blocks exactly as long as delivery takes — no retry loop, no timing window, no flakiness budget.- The scenario name is the test case. When the run fails, the address in the trace tells you which flow broke.
When you're debugging instead of asserting, the same mail is one click away in the browser — here's the OTP the test above just read:

MAILMATTER_KEY is an org API key — free accounts get API access, so this
costs nothing. (Anonymous inboxes work too — the mint response's secret
authorizes the same endpoints — but a key you set once in CI secrets is the
comfortable path.)
Parallel runs without crosstalk
Sharded CI hitting the same scenario? Tag the outgoing mail with a header
in your app's test config (X-Test-Run: ${RUN_ID}) and filter the listing
by it:
GET /api/mailbox/bluepine.otp_code_on_login?header=X-Test-Run:42
That's a durable link from a CI run to exactly the mail it produced — assert on it, or pull it up when archaeology is needed.
Magic links instead of codes?
Same recipe, one change: the parsed message exposes links[], already
extracted from the HTML body. page.goto(message.links[0]) and you're
through the flow — MailMatter never visits links on its own (nothing is
fetched, no tracking pixel fires), so the token is still unconsumed when
your test uses it.
If your suite would rather be pushed than poll at all, webhooks on the Pro plan POST the moment a message lands. And if an AI agent is driving the browser instead of a test runner, the same inbox speaks MCP.
Open an inbox and run the recipe — no signup, no card.