Test transactional email
Use case: signup sends a confirmation link, password reset sends a token, an order sends a receipt. The user journey leaves the browser — and your test must follow it into the inbox and back.
Why this is hard
Section titled “Why this is hard”Playwright ends at the browser. The email leaves your app via SMTP, and there’s nothing to locator() on. Sending real email from CI is worse: slow, rate-limited, spam-filtered, and it leaks test traffic to real providers.
Recipe
Section titled “Recipe”Run a capturing SMTP server and read what your app sent through its REST API. Mailpit is the modern standard (successor to MailHog):
services: mailpit: image: axllent/mailpit ports: - '1025:1025' # SMTP — point your app here - '8025:8025' # Web UI + REST APIPoint the app under test at smtp://localhost:1025, then:
import { test, expect } from '@playwright/test';
const MAILPIT = 'http://localhost:8025/api/v1';
async function waitForEmail(request, to: string, subject: RegExp) { return expect .poll(async () => { const res = await request.get(`${MAILPIT}/search?query=to:${to}`); const { messages } = await res.json(); return messages?.find((m) => subject.test(m.Subject)) ?? null; }, { timeout: 15_000 }) .not.toBeNull() .then(async () => { const res = await request.get(`${MAILPIT}/search?query=to:${to}`); const { messages } = await res.json(); return messages.find((m) => subject.test(m.Subject)); });}
test('signup confirmation link works', async ({ page, request }) => { const email = `user-${Date.now()}@example.test`;
await page.goto('/signup'); await page.getByLabel('Email').fill(email); await page.getByRole('button', { name: 'Sign up' }).click();
// Find the message… const msg = await waitForEmail(request, email, /confirm your account/i);
// …extract the link from the HTML body… const detail = await (await request.get(`${MAILPIT}/message/${msg.ID}`)).json(); const link = detail.HTML.match(/href="([^"]*\/confirm[^"]*)"/)?.[1]; expect(link).toBeTruthy();
// …and complete the journey in the browser. await page.goto(link!); await expect(page.getByText('Account confirmed')).toBeVisible();});What’s worth asserting
Section titled “What’s worth asserting”- Delivery + addressing — right recipient, right subject; unique addresses per test (
user-${Date.now()}@…) keep runs independent. - The link round-trip — the token in the email actually works, once, and expires.
- Content — assert on data (names, amounts) with contains-matchers, not full-body snapshots; templates churn.
- Negative cases — unsubscribe honored, no email on failed signup.
GET /api/v1/messagesafter a short wait proves silence.
Rendering checks
Section titled “Rendering checks”Mailpit can screenshot the HTML body — pipe it into your visual-testing flow for template regressions. For real-client rendering (Outlook…), that’s a specialized service (Litmus etc.), not CI territory.
Caveats
Section titled “Caveats”- Reset Mailpit between tests (
DELETE /api/v1/messages) or scope every query by recipient. - If the app is in a container, the SMTP host is
mailpit:1025, notlocalhost. - Staging environments that send through a real provider (SES, SendGrid) can often be pointed at Mailpit’s SMTP instead — same assertion code, before and after deploy.
Related
Section titled “Related”- Test TOTP two-factor login — email-code login is this recipe plus a regex.
- Test PDF output — receipts often arrive as attachments (Mailpit serves those over the API too).