Create a disposable inbox in one API call. Your app sends the OTP. We hand it to your test — parsed, typed, and on time.
import { test, expect } from '@playwright/test'; import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); test('signup sends a one-time code', async ({ page }) => { const inbox = await mail.createInbox(); // qa-7f3a@tests.acme.dev await page.goto('/signup'); await page.fill('#email', inbox.address); await page.click('text=Send code'); const otp = await inbox.waitForOtp(); // long-polls. no sleep() await page.fill('#otp', otp); await expect(page.locator('h1')).toHaveText('Welcome'); });
waitForMessage() holds the connection open until the email lands — usually under a second after your app sends it. No sleep(5000), no polling every 500ms, no “flaky on CI, fine locally.”
-await page.waitForTimeout(5000);-for (let i = 0; i < 10; i++) {-msgs = await pollInbox(); if (msgs.length) break;-await sleep(1000);-}+ const msg = await inbox.waitForMessage({ timeout: 30_000 });
{
"subject": "Your Acme code",
"to": "qa-7f3a@tests.acme.dev",
"extracted": {
"otp": "482913",
"links": [
{ "kind": "verification",
"url": "https://acme.dev/verify?t=…" },
{ "kind": "unsubscribe",
"url": "https://acme.dev/unsub?u=…" }
]
}
}
Every message arrives pre-parsed: the one-time code, the magic link, the verification link — classified and sitting in extracted. Your assertion is one line, and it doesn't break when marketing redesigns the email.
Shared test-mail domains end up on blocklists — then your signup form rejects your own tests. Point one MX record at us and every fixture lives on tests.yourdomain.com: unblockable, because it's yours.
Run a test, watch the inbox fill in real time. Rendered HTML, plain text, headers, raw source — and the extracted OTP one click from your clipboard when you're debugging by hand.
Your whole team shares one plan — we count messages, not people. Go over? Soft overage at $2 per 1,000 messages. Nothing shuts off mid-CI-run.