Browser test automation
Waiting for a delayed verification mail in Playwright email tests
In Playwright, poll a mailbox you control with bounded retries and clear assertions—never sleep forever—and treat temporary inboxes as manual aids, not a silent CI API.

Waiting for a delayed verification mail in Playwright email tests
Wait for delayed verification mail in Playwright by polling an inbox interface you own—with a deadline, jittered retries, and assertions on subject/code—not by waitForTimeout guesses. Prefer test doubles (intercepted APIs, captured MIME fixtures) in unit-speed suites. Use a live receive path only in a small staging smoke suite. Mailby Quick Inbox helps manual QA; do not assume undocumented CI scraping is a supported product contract—use /developers when you need explicit API access you configure yourself.
Playwright context and boundaries
Playwright shines at browser truth: signup UI, paste OTP, land on /app. Email arrival is external time. Patterns that work:
- App seam: signup returns a test-only OTP when
EMAIL_MODE=test - Mailbox poll: worker reads IMAP/API until match or timeout
- Fixture inject: pre-seed the app’s “last sent code” store
Patterns that flake:
- Fixed 30s sleeps
- Unbounded
while (true)polls - Shared inboxes without unique recipient tags
- Hitting production ESPs from PR builds
Boundaries: Only automate systems you authorize. Mailby does not send mail. This article keeps roadmap fantasies out: we demonstrate waiting patterns, not a claim that Mailby is wired into Playwright out of the box.
Demonstrate waiting for delayed verification mail
Pattern: bounded poll helper
async function waitForCode(opts: {
fetchInbox: () => Promise<{ subject: string; body: string }[]>;
match: (m: { subject: string; body: string }) => string | null;
timeoutMs?: number;
intervalMs?: number;
}): Promise<string> {
const timeoutMs = opts.timeoutMs ?? 60_000;
const intervalMs = opts.intervalMs ?? 2_000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const messages = await opts.fetchInbox();
for (const m of messages) {
const code = opts.match(m);
if (code) return code;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("Verification mail not found before timeout");
}
In a Playwright test:
test("signup verifies email", async ({ page }) => {
const recipient = uniqueTestAddress(); // you control routing
await page.goto("/signup");
await page.getByLabel("Email").fill(recipient);
await page.getByRole("button", { name: "Sign up" }).click();
const code = await waitForCode({
fetchInbox: () => readMailbox(recipient),
match: (m) => {
if (!/verify/i.test(m.subject)) return null;
const hit = m.body.match(/\b(\d{6})\b/);
return hit?.[1] ?? null;
},
});
await page.getByLabel("Code").fill(code);
await page.getByRole("button", { name: "Confirm" }).click();
await expect(page).toHaveURL(/\/app/);
});
Working path: Code arrives in 5–20s; assertion passes; user lands in app.
Failure / limitation: ESP delay exceeds 60s under load → test times out. Raising timeout without fixing queue depth masks product SLOs. Prefer measuring p95 send latency separately from UI tests.
Playwright’s own waiting model is documented in Playwright test timeouts (rel="nofollow noopener"); email polling should nest under those budgets deliberately.
Mechanism and edge cases
- Clock skew: OTP generation uses server time; compare message timestamps in UTC.
- Cleanup: delete messages or use unique
+tagaddresses per test to avoid reading stale codes. - Parallelism: workers must not share one inbox without isolation keys.
- HTML vs text: match codes in text part first; HTML entities can break naive regex.
- Delayed mail: distinguish “not sent” (app bug) from “sent but slow” (ESP). Log provider message IDs when available.
Step table
| Test step | Timeout/retry | Assertion | Teardown |
|---|---|---|---|
| Create unique recipient | n/a | Address allocated | Release alias / purge inbox |
| Submit signup form | Playwright default | Navigation or toast | — |
| Poll mailbox | 60s / 2s | Six-digit code match | Stop poller |
| Enter code | 10s | URL /app | Logout |
| Negative: wrong code | 5s | Error message shown | — |
| Negative: timeout | expect throw | Error includes “not found” | Dump last inbox snapshot |
Concrete worked example
Suite: staging smoke, nightly, not every PR.
- Provision recipient
qa+pw-${RUN_ID}@your-test-domain. - Playwright signs up.
- Poller hits your mail store API.
- Code
448291matched; entered; success. - Teardown deletes user and purges messages with that tag.
Manual assist: During exploratory QA, open Quick Inbox, paste address into staging, and watch delay visually. Convert findings into automated polls against your store—not brittle DOM scraping of a third-party temp-mail page unless you accept that maintenance cost.
Counterexample: await page.waitForTimeout(45000) “until mail arrives,” then hard-code reading from a shared QA Gmail via UI automation. Flakes weekly when the inbox has unread noise.
Alternatives and durable mailboxes
| Approach | Best for | Risk |
|---|---|---|
| Test OTP seam | PR-speed tests | Must not ship to production |
| IMAP/API poll | Staging smoke | Credentials in CI |
| MIME fixtures | Renderer tests | Won’t catch SMTP breaks |
| Mailby manual inbox | Exploratory QA | Not a silent CI contract |
| Developer API | Programmatic receive you configure | Requires setup |
Use durable QA mailboxes for long-lived shared environments. Temporary inboxes fit one-off human checks.
Related product pages: features, security, data retention.
Short answers
What causes waiting problems for delayed verification mail in Playwright?
Underestimated ESP latency, shared inboxes, and sleep-based synchronization.
What should I do first?
Introduce a bounded poll helper and unique recipients. Remove fixed multi-second sleeps.
When is a permanent address safer?
Shared staging identities with human recovery needs.
What evidence changes the recommendation?
- p95 delivery < 5s → shorter poll budget
- Frequent 60s timeouts → fix sending pipeline or add test OTP seam
Sources, test date, and limitations
Test date: 2026-09-24. Playwright APIs evolve; bounded polling remains the durable pattern. External: Playwright docs (rel="nofollow noopener"), RFC 5322 (rel="nofollow noopener") for message structure when parsing fixtures.
Limitations: No claim that Mailby injects a Playwright plugin. Educational CTA only for manual QA via Quick Inbox.
Choosing timeout budgets from SLOs
If product analytics say p95 “signup to mail accepted” is 12 seconds, a 15-second poll budget will flake. Set UI smoke budgets from measured p99 with margin—for example p99=20s → test timeout 45–60s—and alert on the pipeline metric separately. Do not endless-extend Playwright timeouts to hide regressions.
Split suites:
- PR: test OTP seam, no real ESP
- Nightly staging: real ESP + bounded poll
- Manual: Quick Inbox exploratory
Publish the split in README so someone does not “helpfully” enable live mail on every PR and burn API quotas.
Flake taxonomy for email waits
| Flake class | Symptom | Fix |
|---|---|---|
| Stale code read | Old 6-digit code accepted wrongly | Unique recipient per test; match newest timestamp |
| Shared inbox collision | Parallel workers steal messages | Shard by worker index tag |
| Regex too greedy | Matching year or order id | Anchor on code is (\d{6}) copy |
| HTML entity breaks | ​ inside code | Prefer text/plain part |
| Provider outage | All polls timeout | Skip live suite on status red; keep seam tests |
Observability hooks
When waitForCode throws, dump: recipient, poll attempts, newest subjects (redact bodies in public CI logs), and the app’s request id if signup API returns one. That single dump turns “Playwright failed” into “ESP delayed 70s” vs “app never called send.”
Pair with provider webhooks in staging when available. Webhook-driven tests can assert “accepted” before polling content, shortening the wait loop.
Auth and secrets
Store IMAP/API credentials in CI secrets, not repo dotenv commits. Scope credentials to a dedicated QA tenant. Revoke after contractor access ends. Prefer app passwords with read-only intent where providers allow.
Designing uniqueTestAddress()
Good patterns:
qa+pw-${Date.now()}-${workerIndex}@your-domain- Catch-all domain to a QA mail API that indexes by local-part
- Per-test mailbox via developer APIs you control (/developers when configured for your own tests)
Bad patterns:
- Single shared
qa@gmail.com - Random disposable public inboxes with CAPTCHA walls
- Scraping third-party temp-mail DOMs without contracts
Playwright tracing and mail
Attach the resolved code length (not the code itself) to the test trace annotation. That proves a code was obtained without leaking secrets into trace zips shared broadly. Redact inbox dumps the same way.
Additional practical notes
Network conditions in CI differ from laptops. A poll interval of 500ms may hammer a mail API and get rate-limited, producing false timeouts. Back off to 1–3 seconds and jitter workers.
When tests run in parallel shards, publish a dashboard of email-wait p95 per shard. Hot shards often share a misconfigured secret pointing at the wrong inbox.
Avoid asserting exact subject strings if marketing edits copy frequently. Assert stable tokens: product name + “code” + six digits. Coordinate with the email template owners so QA selectors are treated as contracts.
If your app supports resend, test that resend invalidates prior codes. Playwright can request twice and ensure only the latest code authenticates. That negative case catches serious security bugs.
Document how to run the live-mail suite locally with .env.staging so new hires do not copy production secrets. Include a dry-run mode that uses the OTP seam exclusively.
Sample failure narrative (what good debugging looks like)
Nightly run fails: Verification mail not found before timeout. Trace shows signup 200 OK. Mail API returned zero messages for 60s. ESP dashboard shows accept at T+72s. Root cause: template change added a large attachment slowing the provider. Fix: remove attachment from verification template; raise nightly timeout temporarily; add metric alert on send duration. Playwright was correct to fail; the product SLO regressed.
Contrast with a bad narrative: engineer increases sleep to 120s and merges. Flakes drop for a week, then return under load. Always ask whether the wait revealed a product problem.
Coordination with security
OTP codes in CI artifacts are secrets. Configure Playwright to scrub traces. Forbid console.log(code). Prefer hashing codes in debug output. Rotate test accounts if traces leaked to a public channel.
Local developer experience
Provide npm run test:e2e:otp-seam and npm run test:e2e:live-mail scripts. Default PR hooks to the seam. Document required env vars for live mail in a table: base URL, mailbox API token, timeout overrides. Engineers should not need to reverse-engineer Playwright config to know which suite hits the network.
When live mail is unavailable (token missing), fail clearly with “LIVE_MAIL_TOKEN unset” rather than timing out for sixty seconds. Fast failures keep trust in the suite.
Reader checklist
Before you act on this guide, confirm: (1) you are authorized to test or decide for this account, (2) you understand Mailby is receive-only and does not send or forward mail, (3) you have opened the linked policy or product pages when making retention or security claims, and (4) you picked durable mail whenever recovery, receipts, or multi-day continuity matter. Temporary inboxes excel at short receive tasks and fail loudly when pressed into identity roles they were never meant to fill. Re-read the decision table above if you are unsure; tables compress the judgment call better than memory under time pressure. When evidence disagrees with a default recommendation—vendor blocks, legal holds, employer policy—let that evidence win. Update your personal defaults after each surprising failure so the next decision is faster and safer.
Conclusion
Treat delayed verification mail as a distributed wait with a deadline and a teardown story. Keep PR suites hermetic; reserve live mail polls for staging. Use Quick Inbox for manual confirmation and /developers when you explicitly need API-shaped receive tooling—without promising magic CI glue.
Try it on Mailby
Open a receive-only disposable inbox when a short-lived address fits the job — session-bound, with timed purge.
