Every signup, password reset and "confirm your email" flow ends the same way in a test: the app sends an email, and your test needs the link or code inside it. The usual result is await page.waitForTimeout(10000) and a prayer.
This post shows a pattern that waits exactly as long as needed and never reads a stale message. It uses a throwaway SMTP inbox that needs no signup. (Disclosure: I built it, it is called Email Blackhole, it is a hosted service and it is not open source. The pattern works with any inbox that has an API; I will say at the end when you should use something else.)
The idea: mark, trigger, wait
- Mark where the inbox is now.
- Trigger the email (fill in the form, click the button).
- Wait for a message newer than the mark, filtered by recipient.
The mark is what makes it race-free. Old mail from a previous run can never satisfy this run, and a message that arrives between steps 2 and 3, before the wait starts, still counts.
1. Get an inbox
Open emailblackhole.io in a browser. You get an inbox immediately, with SMTP settings and an id and secret. Copy the id and the secret into two environment variables:
export EBH_URL=https://emailblackhole.io
export EBH_ID=<the id>
export EBH_SECRET=<the secret>
The inbox's SMTP username is the id and its password is the secret. The server is smtp.emailblackhole.io, port 2525, STARTTLS. Everything sent there is caught and shown to you; nothing is delivered to the real recipient, and the service cannot send mail out at all.
2. Point your app at it
Wherever your app configures email in the test environment, use those settings. With nodemailer, for example:
import nodemailer from 'nodemailer'
export const mailer = nodemailer.createTransport({
host: 'smtp.emailblackhole.io',
port: 2525,
secure: false, // STARTTLS is upgraded after connecting
requireTLS: true,
auth: { user: process.env.EBH_ID, pass: process.env.EBH_SECRET },
})
Use a separate environment or configuration for tests, so that no real user can ever get a test email.
3. Two small helpers
Put these in tests/email.ts. They use the built-in fetch, so there is nothing to install.
const BASE = process.env.EBH_URL!
const ID = process.env.EBH_ID!
const headers = { Authorization: `Bearer ${process.env.EBH_SECRET!}` }
type Mail = { seq: number; subject: string; links: string[]; codes: string[]; text: string }
/** Where the inbox is right now: the highest message number. */
export async function mark(): Promise<number> {
const res = await fetch(`${BASE}/api/sessions/${ID}/messages`, { headers })
if (!res.ok) throw new Error(`could not read the inbox: ${res.status}`)
const { messages } = (await res.json()) as { messages: Mail[] }
return Math.max(0, ...messages.map((m) => m.seq))
}
/** Blocks until a message newer than `after` arrives for `to`. */
export async function waitForMail(after: number, to: string, seconds = 60): Promise<Mail> {
const q = new URLSearchParams({ after: String(after), to, timeout: String(seconds) })
const res = await fetch(`${BASE}/api/sessions/${ID}/wait?${q}`, { headers })
if (res.status === 204) throw new Error(`no email for ${to} within ${seconds}s`)
if (!res.ok) throw new Error(`wait failed: ${res.status}`)
return ((await res.json()) as { messages: Mail[] }).messages[0]
}
wait is a long poll: the request stays open until a matching message arrives or the timeout (1 to 120 seconds) passes. It answers 200 with the messages or 204 when nothing came. Every message already carries the links found in its body and a codes list of likely one-time codes, so you do not need to parse HTML.
4. Test: signup verification link
import { test, expect } from '@playwright/test'
import { mark, waitForMail } from './email'
test('signup sends a verification email and the link works', async ({ page }) => {
const email = `new.user+${Date.now()}@example.com` // unique per run: parallel tests do not collide
const m = await mark()
await page.goto('/signup')
await page.fill('[name=email]', email)
await page.fill('[name=password]', 'a-long-test-password')
await page.click('text=Sign up')
const mail = await waitForMail(m, email)
const link = mail.links.find((l) => l.includes('/verify'))
expect(link, 'the email should contain a verification link').toBeTruthy()
await page.goto(link!)
await expect(page.getByText('Email verified')).toBeVisible()
})
The unique recipient is the second half of the isolation: even if ten tests run in parallel and share one inbox, each waits only for its own address.
5. Test: one-time code
test('password reset emails a 6 digit code', async ({ page }) => {
const email = `user+${Date.now()}@example.com`
const m = await mark()
await page.goto('/forgot-password')
await page.fill('[name=email]', email)
await page.click('text=Send code')
const { codes } = await waitForMail(m, email)
expect(codes[0]).toMatch(/^\d{6}$/)
await page.fill('[name=code]', codes[0])
await page.click('text=Continue')
await expect(page.getByText('Choose a new password')).toBeVisible()
})
Codes are found by a heuristic: 4 to 8 digits on the same line as a word like "code", "OTP", "PIN" or "passcode"; order numbers, years and phone numbers are ignored. If your email formats codes differently, read the text field and match it yourself.
What goes wrong, and how to avoid it
-
Always pass
after. Without it, last run's email can pass this run's test. - Filter by recipient. One inbox serves many tests; the address is the filter.
- Give real providers time. If your app sends through SendGrid or SES instead of straight to the sandbox, delivery is slower; use a 60 to 120 second timeout.
- Assert the content, not only that something arrived. A link that points at the wrong host is a bug a sleep-based test never finds.
The limits, honestly
- A free inbox lives in memory and expires 60 minutes after you last used it (each API call counts as use). Messages are capped at 2 MB, and a free inbox accepts 30 messages an hour.
- Creating an inbox needs a bot check, so a script cannot create one itself yet. You create it in the browser and copy the id and secret. That is fine for local runs. For a CI pipeline that runs on every commit, the free inbox will have expired by the next run: a permanent inbox with an API key is $1.99 a month. The only differences between free and paid are limits and persistence; no feature is locked.
- It is a test inbox, not a vault. Use synthetic data, never real customers' addresses.
When to use something else
If you can run a container in your test environment, Mailpit is excellent, open source and runs next to your app, and it is the better choice when everything must stay on your own network. A hosted inbox earns its place when you cannot or do not want to run one: shared staging environments, CI runners with restrictions, or when you want to catch mail from a real provider and see its SPF, DKIM and DMARC results (every inbox also has a public address you can send real mail to).
The full API is in the API reference, and there is a CI page with recipes for GitHub Actions and pytest. I would like to hear what is missing for your setup.
Top comments (0)