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.

Hourglass and inbox tray waiting for a delayed verification envelope on a cream postal stage

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 +tag addresses 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 stepTimeout/retryAssertionTeardown
Create unique recipientn/aAddress allocatedRelease alias / purge inbox
Submit signup formPlaywright defaultNavigation or toast
Poll mailbox60s / 2sSix-digit code matchStop poller
Enter code10sURL /appLogout
Negative: wrong code5sError message shown
Negative: timeoutexpect throwError includes “not found”Dump last inbox snapshot

Concrete worked example

Suite: staging smoke, nightly, not every PR.

  1. Provision recipient qa+pw-${RUN_ID}@your-test-domain.
  2. Playwright signs up.
  3. Poller hits your mail store API.
  4. Code 448291 matched; entered; success.
  5. 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

ApproachBest forRisk
Test OTP seamPR-speed testsMust not ship to production
IMAP/API pollStaging smokeCredentials in CI
MIME fixturesRenderer testsWon’t catch SMTP breaks
Mailby manual inboxExploratory QANot a silent CI contract
Developer APIProgrammatic receive you configureRequires 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 classSymptomFix
Stale code readOld 6-digit code accepted wronglyUnique recipient per test; match newest timestamp
Shared inbox collisionParallel workers steal messagesShard by worker index tag
Regex too greedyMatching year or order idAnchor on code is (\d{6}) copy
HTML entity breaks&#8203; inside codePrefer text/plain part
Provider outageAll polls timeoutSkip 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.