Playwright handles browser automation well. It clicks buttons, fills forms, waits for network responses, and asserts on DOM state. But the moment your test needs to verify that an email was actually sent — and that its content, links, and headers are correct — you're outside the browser. Playwright has no built-in way to check a mailbox.
That gap matters. Signup flows send verification emails. Password resets send magic links. Agent workflows send transactional messages and wait for replies. If your end-to-end tests can't verify the email leg of these flows, you're testing half the feature and hoping the other half works.
This guide covers how to set up reliable Playwright email testing: the architecture, the tooling, and the code patterns that make email assertions as deterministic as DOM assertions.
Why Playwright email testing is harder than UI testing
Playwright tests run against a browser. The browser is local, fast, and deterministic — you click a button, the DOM updates, you assert. Email delivery is none of those things.
When your application sends an email, the message passes through your backend, an SMTP relay or email API, one or more receiving mail servers, and eventually lands in an inbox. That pipeline introduces latency (seconds to minutes), external dependencies (SMTP infrastructure, DNS, receiving MTA), and a delivery target your test runner cannot directly observe.
Three specific problems make Playwright email testing difficult:
No direct observation. Playwright controls a browser. It cannot open a mailbox, query an IMAP server, or read a webhook payload. You need a separate mechanism to observe email delivery from your test code.
Variable latency. Email delivery is asynchronous. A send call returns before the message reaches the recipient. Your test needs to poll or wait for delivery confirmation, with appropriate timeouts and retry logic.
Stateful side effects. Emails carry one-time tokens (verification codes, magic links) that expire. Your test must extract the token from the delivered email and use it within the validity window — often in the same test run that triggered the send.
Standard Playwright fixtures and assertions don't address any of these. You need a testing architecture that bridges the gap between browser actions and email delivery.
Two approaches: mailbox capture vs API-based verification
There are two practical strategies for Playwright email testing. Each has tradeoffs.
Mailbox capture (SMTP interception)
Tools like MailHog, MailPit, or smtp4dev run a local SMTP server that captures every message your application sends. Your test queries the captured messages via a REST API.
Advantages: fully local, zero external dependencies, fast. Disadvantages: you're testing against a fake SMTP server, not real email infrastructure. Delivery quirks, authentication (SPF/DKIM), and bounce handling are invisible. Headers your production email provider adds — Message-ID format, threading headers, custom metadata — won't be present.
For unit-level email content checks, this works. For end-to-end tests that verify the full delivery pipeline, it's insufficient.
API-based verification (real email infrastructure)
Use a real email API that exposes sent messages and delivery events over a REST endpoint. Your test triggers a send via the UI, then queries the API to verify delivery, read content, and extract tokens.
This approach tests the actual production path: your application → email API → delivery events. The tradeoff is that it introduces network latency and requires API credentials in your test environment.
For Playwright email testing that validates real delivery behavior, API-based verification is the right choice. The rest of this guide uses this approach with a real email API.
Setting up an email testing environment
A Playwright email testing setup needs three things: a test workspace with its own sending identity, API access to query delivery events, and a helper module your tests import.
Create a test agent
Use a dedicated agent for your test suite. This keeps test traffic separate from production sends and gives you a clean event stream to query.
import { createClient } from "@mailsai/sdk"
const mails = createClient() // reads MAILS_API_KEY from env
// One-time setup: create a test agent
// The agent gets its own address: test@<workspace>.mails.ai
await mails.agents.create("test")
The agent's email address — test@<workspace>.mails.ai — is what your test environment uses as the sender. Configure your application's test environment to send through this agent.
Build a polling helper
Email delivery is asynchronous. Your tests need to poll the events API until the expected event appears. Wrap this in a helper with configurable timeout and interval:
// test-helpers/email.ts
import { createClient } from "@mailsai/sdk"
const mails = createClient()
interface PollOptions {
eventType: string
agentId: string
timeoutMs?: number
intervalMs?: number
since?: string
}
export async function waitForEmail(opts: PollOptions) {
const {
eventType,
agentId,
timeoutMs = 30_000,
intervalMs = 1_000,
since,
} = opts
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const events = await mails.events.list({
event_type: eventType,
agent_id: agentId,
})
// Find the first event after our timestamp marker
const match = events.data?.find(
(e) => !since || new Date(e.created_at) > new Date(since)
)
if (match) return match
await new Promise((r) => setTimeout(r, intervalMs))
}
throw new Error(
`No ${eventType} event for agent ${agentId} within ${timeoutMs}ms`
)
}
This helper polls events.list({ event_type, agent_id }) until it finds a matching event. The since parameter prevents matching stale events from previous test runs.
Writing Playwright tests that verify email delivery
Here's the core pattern: trigger an action in the browser, then use the polling helper to verify the email arrived.
Example: signup verification email
import { test, expect } from "@playwright/test"
import { waitForEmail } from "./test-helpers/email"
test("signup sends a verification email", async ({ page }) => {
const timestamp = new Date().toISOString()
// 1. Trigger signup in the browser
await page.goto("/signup")
await page.fill('[name="email"]', "newuser@example.com")
await page.fill('[name="password"]', "test-password-123")
await page.click('button[type="submit"]')
await expect(page.locator(".confirmation-message")).toBeVisible()
// 2. Verify the email was delivered
const event = await waitForEmail({
eventType: "message.received",
agentId: "test",
since: timestamp,
})
// 3. Assert on the email content
expect(event.data.subject).toContain("Verify your email")
expect(event.data.to).toContain("newuser@example.com")
expect(event.data.body_text).toContain("verification")
})
The test runs in three phases: browser action, email verification, content assertion. Each phase is clear and independently debuggable.
Note the since timestamp captured before the browser action. Without it, your test might match an email from a previous run. This pattern is essential for idempotent test execution — every run starts clean.
Testing email content and links
Verification emails and password resets carry one-time tokens — either as a link or a code. A complete Playwright email test extracts the token from the delivered email and uses it in the browser.
Magic link extraction
test("magic link login works end-to-end", async ({ page }) => {
const timestamp = new Date().toISOString()
// Request a magic link
await page.goto("/login")
await page.fill('[name="email"]', "user@example.com")
await page.click("text=Send magic link")
await expect(page.locator("text=Check your email")).toBeVisible()
// Wait for the email
const event = await waitForEmail({
eventType: "message.received",
agentId: "test",
since: timestamp,
})
// Extract the magic link from the email body
const body = event.data.body_text ?? event.data.body_html ?? ""
const linkMatch = body.match(/https?:\/\/[^\s"]+\/auth\/verify\?token=[^\s"]+/)
expect(linkMatch).not.toBeNull()
const magicLink = linkMatch![0]
// Use the magic link in the browser
await page.goto(magicLink)
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible()
})
Verification code extraction
For numeric codes instead of links:
// Extract a 6-digit code from the email body
const codeMatch = body.match(/\b(\d{6})\b/)
expect(codeMatch).not.toBeNull()
const verificationCode = codeMatch![1]
// Enter the code in the browser
await page.fill('[name="code"]', verificationCode)
await page.click("text=Verify")
await expect(page.locator("text=Email verified")).toBeVisible()
The key point: Playwright email testing lets you verify the full user journey — not just that the UI shows "check your email," but that the email arrived, contained the right token, and that token actually works when used.
Testing email replies and threading
If your application processes inbound email — a support agent, an approval workflow, a scheduling bot — your Playwright tests need to verify that replies are handled correctly. This requires sending a test email, waiting for the agent's response, and verifying the thread is intact.
The Mails.ai events API distinguishes between reply.received (a reply to one of your agent's sends) and message.received (first-contact mail that is not a reply). Use the right event type in your test assertions.
test("agent replies to a support request", async ({ page }) => {
const timestamp = new Date().toISOString()
// Trigger a support message through the application UI
await page.goto("/support")
await page.fill('[name="subject"]', "Order #1234 status")
await page.fill('[name="message"]', "Where is my order?")
await page.click("text=Send")
// Wait for the agent's outbound send event
const sendEvent = await waitForEmail({
eventType: "message.received",
agentId: "test",
since: timestamp,
timeoutMs: 45_000,
})
// Verify threading — the reply should reference the original thread
expect(sendEvent.data.thread_id).toBeDefined()
expect(sendEvent.data.subject).toContain("Order #1234")
})
For testing the full reply loop — send, receive reply, verify the reply's content — see the structured reply events guide for the event schema and threading model. The data.thread_id field on each event links messages in the same conversation, so your test can assert that a reply belongs to the right thread.
For more on how inbound email parsing works and how webhook delivery surfaces events to your application, see the respective feature guides.
Handling flaky email tests
Email tests are inherently more prone to flakiness than pure UI tests. Network latency, SMTP queue delays, and DNS resolution all add variability. Here are the patterns that keep Playwright email testing reliable.
Generous but bounded timeouts
Set your polling timeout based on observed delivery latency, not optimistic assumptions. For a real email API, 15–30 seconds covers normal delivery. For safety, cap at 60 seconds — if an email hasn't arrived in a minute, it's not a timing issue.
// Bad: too tight — fails intermittently on slow delivery
const event = await waitForEmail({ ...opts, timeoutMs: 3_000 })
// Good: generous but bounded
const event = await waitForEmail({ ...opts, timeoutMs: 30_000 })
Timestamp-based event filtering
Always capture a timestamp before the action that triggers the email. Filter events to those created after that timestamp. Without this, your test matches stale events from previous runs — intermittently, which makes the failure maddening to debug.
Test isolation
Each test should use a unique recipient address or a unique subject line that distinguishes its email from every other test's. If multiple tests run in parallel and all send to the same address, event queries return ambiguous results.
test("password reset", async ({ page }) => {
const testId = `reset-${Date.now()}`
const testEmail = `test+${testId}@example.com`
// Use testEmail as the recipient — unique per run
})
Retry the assertion, not the action
If an email isn't found, retry the poll — don't re-trigger the browser action. Re-triggering the send creates duplicate emails, duplicate tokens, and race conditions. The polling helper handles retries internally; the test action runs exactly once.
Playwright email testing checklist
Before adding email assertions to your Playwright suite, verify each item:
- Dedicated test agent: A separate sending identity for test traffic, so production events aren't polluted
- API credentials in CI: Your
MAILS_API_KEYis available in the test environment (use CI secrets, not hardcoded values) - Polling helper with timeout: A reusable function that polls for events with configurable timeout and interval
- Timestamp markers: Every test captures a pre-action timestamp and filters events accordingly
- Unique recipients or subjects: Each test's email is distinguishable from every other test's
- Cleanup between runs: If your test agent accumulates state, reset it in a global setup fixture
- Realistic timeouts: 15–30 seconds for normal delivery; 60 seconds as a hard cap
- No hardcoded tokens: Extract tokens from the delivered email body, never from the application's database directly — that's testing the email path, not a database query
For a complete reference on wiring event-driven webhooks into your application's test infrastructure, see the inbound processing guide. The Mails.ai API documentation has the full events.list schema, including filtering and pagination.
Frequently Asked Questions
How do I set up Playwright email testing without a real email server?
Use a local SMTP capture tool like MailPit or MailHog in development. These intercept all outbound SMTP and expose messages via a REST API your tests can query. For CI and staging, switch to a real email API — local SMTP capture doesn't test delivery authentication, threading headers, or provider-specific behavior. A dedicated test agent on a real email API gives you production-representative results without affecting live sending.
Can I test inbound email handling with Playwright?
Yes. Trigger an inbound email by sending a message to your test agent's address, then verify your application processed it correctly through the browser UI. Send the test email via the API, wait for the message.received event to confirm delivery, then navigate to the page in your application where the processed result should appear and assert on the UI state. This tests the full inbound email parsing pipeline end-to-end.
How do I prevent Playwright email tests from being flaky?
Three practices eliminate most flakiness: (1) capture a timestamp before the action that triggers the email and filter events to those created after it, (2) use unique recipient addresses or subject lines per test so parallel runs don't cross-contaminate, and (3) set polling timeouts based on observed delivery latency (15–30 seconds typically), not optimistic guesses. Retry the event poll, never the browser action — re-triggering sends creates duplicates and race conditions.
What's the best way to extract a verification code or magic link from an email in a Playwright test?
Query the delivered event's body text and use a regex to extract the token. For magic links: /https?:\/\/[^\s"]+\/verify\?token=[^\s"]+/. For 6-digit codes: /\b(\d{6})\b/. Always prefer the plain text body over HTML — it's cleaner and doesn't require HTML parsing. If your email only sends HTML, use a lightweight HTML-to-text conversion before extracting.
How do I test that an email thread is maintained across multiple messages?
Send an initial message, capture the event, note the data.thread_id. Then trigger a reply through your application, wait for the reply.received event, and verify its data.thread_id matches the original. The threading relies on Message-ID, In-Reply-To, and References headers — the email API handles these automatically when you reply to a message. Your test just needs to verify the thread IDs match. See structured reply events for the full threading model.
Should I use Playwright email testing in CI, or only locally?
Both. Run email tests locally during development against a dedicated test agent. In CI, use the same test agent with API credentials stored as secrets. The test code is identical — only the environment variable for the API key changes. Keep email tests in a separate Playwright project or tag them so you can run them independently from pure UI tests, since they're inherently slower (network round-trips) and you may want different parallelism settings.
Related guides: CC and BCC in AI Agent Email Workflows, Email Circuit Breaker Pattern for AI Agent Pipelines, Detecting Auto-Replies to Prevent Agent Email Loops and AI for Email: Integrating Agents Into Transactional Flows.
Explore the product: Inbound email API, Sender reputation, All features and Pricing.
