The Problem Nobody Wants to Talk About
Most E2E test suites cover the happy path: fill a form, click a button, assert a result. But the moment you need to test a signup flow that ends with an email — a verification link, an OTP, a welcome message — things break down fast.
The flow is deceptively simple in production: user submits email → system sends message → user clicks link → access granted. In a test environment, that middle step is a black box. Your Playwright test has no way to receive email natively. And end-to-end testing email is one of those problems that teams defer until a critical bug slips past QA.
The common workarounds:
- Mocking the email sender — your tests pass, but you've never verified that the real SMTP path works.
- A shared test inbox — fragile, pollutes state across test runs, requires cleanup discipline nobody enforces.
- Hardcoded OTP bypass codes — shipping test backdoors into production code.
- Internal Mailhog/Mailpit setups — one more service to maintain, and they don't test real SMTP delivery.
Each of these introduces a different kind of lie into your test suite. You're testing the logic, not the system.
Why Traditional Approaches Fail
Mocking doesn't validate delivery
When you mock your email sender (replacing SMTP with a no-op or a spy), you verify that your application called the send function. You don't verify that the email was actually generated, that the template rendered correctly, or that the SMTP configuration is valid.
Real failures — DNS misconfiguration, SPF rejections, broken HTML templates — only surface when you actually send mail. Mocking hides all of this.
Shared inboxes make tests non-deterministic
Multiple tests running in parallel against the same inbox create race conditions. Test A registers, test B registers, both wait for email — and the first email that arrives gets claimed by whichever poll runs first. Flaky tests are the result.
Maintenance overhead compounds
Internal email servers (Mailhog, Mailpit, smtp4dev) need to be deployed, versioned, and kept in sync with your test infrastructure. They have their own APIs, different from production. Your test helpers diverge from real-world behavior. Over time, they become a source of friction rather than a solution.
The Better Approach: Disposable Email + Real Infrastructure
The right model is: one temporary inbox per test, provisioned via API, receiving real SMTP delivery, automatically expiring after the test completes.
This is identical to how production users interact with your system — they have an email address, your app sends to it, they receive it. The only difference is that the inbox is created programmatically, scoped to one test run, and deleted (or allowed to expire) afterward.
A temporary email API lets you:
- Create an isolated inbox with a single HTTP call
- Poll or stream for incoming messages
- Extract OTP codes and verification links from message bodies
- Clean up after each test without side effects
No shared state. No mocking. No bypass codes.
Architecture Overview
The infrastructure behind this involves several components working in sequence.
Inbox creation happens via a REST API call. The API generates a unique address in the format <adjective>-<noun>-<number>@<domain> (e.g., crisp-falcon-77@uncorreotemporal.com), stores it in PostgreSQL with an expiration timestamp, and returns the address to the caller.
Email ingestion uses an aiosmtpd-based SMTP server running alongside the API. When mail arrives for a known domain, the handler parses the raw RFC 2822 bytes, extracts headers and body (plaintext and HTML), stores the message in the database, and publishes a new_message event to a Redis pub/sub channel keyed by inbox address. Invalid recipients are silently accepted — returning 250 regardless prevents retry storms from external senders.
Retrieval supports two patterns: polling via REST (GET /api/v1/mailboxes/{address}/messages) and real-time push via WebSocket (WS /ws/inbox/{address}?api_key=<key>). The WebSocket stream emits {"event": "new_message", "message_id": "<uuid>"} when email arrives. The full message body — body_text, body_html, attachments metadata — is fetched separately.
Expiration runs as a background task inside the API process, executing every 60 seconds. Inboxes where expires_at <= now() are soft-deleted. Messages are retained for the configured retention period; the inbox simply stops accepting new mail.
Step-by-Step: Testing with Playwright
Let's walk through a complete Playwright email testing flow using the REST API.
Create a Temporary Inbox
Before your test starts, provision a dedicated inbox:
const API_KEY = process.env.UCT_API_KEY; // uct_<token>
const BASE_URL = "https://uncorreotemporal.com/api/v1";
async function createInbox(ttlMinutes = 15) {
const res = await fetch(`${BASE_URL}/mailboxes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
body: JSON.stringify({ ttl_minutes: ttlMinutes }),
});
const data = await res.json();
return data; // { address, expires_at }
}
The returned address is what you feed into your signup form. No hardcoded emails.
Trigger the Signup Flow with Playwright
test("signup email verification", async ({ page }) => {
const { address } = await createInbox();
await page.goto("https://yourapp.com/signup");
await page.fill('[name="email"]', address);
await page.fill('[name="password"]', "Test1234!");
await page.click('[type="submit"]');
await page.waitForSelector(".signup-success");
// Now wait for the verification email
});
At this point, your application has sent a verification email to the temporary inbox. Real SMTP delivery, real message in the queue.
Wait for the Email to Arrive
Use a polling helper with a timeout:
async function waitForEmail(address, { timeout = 30000, interval = 2000 } = {}) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(`${BASE_URL}/mailboxes/${address}/messages`, {
headers: { "X-API-Key": API_KEY },
});
const { messages } = await res.json();
if (messages.length > 0) {
const msgRes = await fetch(
`${BASE_URL}/mailboxes/${address}/messages/${messages[0].id}`,
{ headers: { "X-API-Key": API_KEY } }
);
return msgRes.json();
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`No email arrived within ${timeout}ms`);
}
Extract the OTP or Verification Link
function extractVerificationLink(message) {
const body = message.body_text || "";
const match = body.match(/https:\/\/yourapp\.com\/verify\?token=[a-zA-Z0-9_-]+/);
if (!match) throw new Error("Verification link not found in email body");
return match[0];
}
function extractOTP(message) {
const body = message.body_text || "";
const match = body.match(/\b(\d{6})\b/);
if (!match) throw new Error("OTP not found in email body");
return match[1];
}
Complete the Verification Flow
const email = await waitForEmail(address);
const verificationLink = extractVerificationLink(email);
await page.goto(verificationLink);
await expect(page.locator(".verified-badge")).toBeVisible();
});
Full End-to-End Script
import { test, expect } from "@playwright/test";
const API_KEY = process.env.UCT_API_KEY;
const BASE = "https://uncorreotemporal.com/api/v1";
async function apiFetch(path, opts = {}) {
return fetch(`${BASE}${path}`, {
...opts,
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json", ...opts.headers },
}).then((r) => r.json());
}
async function createInbox() {
return apiFetch("/mailboxes", { method: "POST", body: JSON.stringify({ ttl_minutes: 15 }) });
}
async function waitForEmail(address, timeout = 30000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const { messages } = await apiFetch(`/mailboxes/${address}/messages`);
if (messages?.length > 0) return apiFetch(`/mailboxes/${address}/messages/${messages[0].id}`);
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Email timeout");
}
async function deleteInbox(address) {
await fetch(`${BASE}/mailboxes/${address}`, { method: "DELETE", headers: { "X-API-Key": API_KEY } });
}
test("full signup + email verification", async ({ page }) => {
const { address } = await createInbox();
try {
await page.goto("https://yourapp.com/signup");
await page.fill('[name="email"]', address);
await page.fill('[name="password"]', "Secure1234!");
await page.click('[type="submit"]');
await page.waitForSelector(".check-your-email");
const email = await waitForEmail(address);
const [link] = email.body_text.match(/https:\/\/yourapp\.com\/verify\?token=\S+/) || [];
if (!link) throw new Error("No verification link");
await page.goto(link);
await expect(page.locator("h1")).toHaveText("Account verified");
} finally {
await deleteInbox(address);
}
});
CI/CD Integration
This pattern fits naturally into any CI pipeline. Set UCT_API_KEY as a secret, and your tests run identically in local, staging, and CI environments.
# .github/workflows/e2e.yml
- name: Run E2E tests
env:
UCT_API_KEY: ${{ secrets.UCT_API_KEY }}
run: npx playwright test
Key benefits for QA teams:
- No shared infrastructure to provision — the email backend is external, always available.
- Parallel-safe — each test creates its own inbox; no inbox is shared across test runs.
- Test reports include real email content — when a test fails, you can inspect the actual message that arrived (or didn't).
Best Practices
Set aggressive timeouts in CI. Transactional email delivery in a test environment should be fast (under 5s). Set your polling timeout to 20–30 seconds and treat any test that takes longer as a potential infrastructure issue, not a Playwright issue.
Retry on network errors, not on missing email. Distinguish between "the API call failed" (retry) and "no email arrived" (fail the test). Swallowing network errors in your wait loop masks real problems.
Create one inbox per test, not one per suite. Sharing an inbox across multiple tests in a suite reintroduces the shared-state problem. The inbox creation API call is fast — there's no meaningful performance tradeoff.
Clean up explicitly. Even though inboxes expire automatically, DELETE /api/v1/mailboxes/{address} in a finally block keeps your account tidy and reduces noise if you're monitoring active inbox counts.
Store the inbox address in test context. If you use Playwright fixtures, create the inbox in a fixture and attach the address to the test context. This makes it easy to log the address when a test fails, so you can inspect the inbox state during debugging.
Conclusion
Email flows are a first-class part of most user journeys — and they deserve first-class test coverage. Mocking the email layer trades realism for convenience, and eventually that tradeoff costs you. A real email lands in a real inbox, and your test suite should be able to verify that.
The approach described here — temporary inbox provisioned per test, real SMTP delivery, polling or WebSocket retrieval, explicit cleanup — is production-grade and CI-ready. It requires no additional infrastructure on your end and no changes to your application code.
If you want to try this approach, you can use a temporary email API like uncorreotemporal.com to get started.
Top comments (0)