The Problem with Testing Email Auth Flows
Most Playwright suites stop at the form submission. The test fills the email field, clicks Submit, asserts a success banner, and calls it a day. But the auth flow doesn't end there — it ends when the user clicks the link in their inbox. That second half is invisible to your test.
The common workarounds all have the same failure mode in disguise:
-
Mocking the email sender — you assert that
sendEmail()was called, not that an email was received, rendered correctly, and contains a working link. - Shared test inboxes — two parallel CI runs both register and poll the same inbox. The first email gets consumed by whichever test polls first. Flaky tests guaranteed.
- Hardcoded OTP bypass routes — test backdoors that sooner or later end up in a production build.
- Mailhog / Mailpit — one more container to deploy, a different API surface from production, and zero validation of your real SMTP path.
The issue isn't test tooling. It's the assumption that email delivery is a side effect you can safely skip or stub. In practice, your verification link can be malformed, your SMTP credentials can expire, your template can break — and a mocked test will pass through all of it.
What We Will Build
By the end of this article you will have a fully working TypeScript Playwright test that:
- Creates a temporary inbox via the uncorreotemporal.com API
- Submits a signup form using that inbox address
- Polls for the incoming email with a configurable timeout
- Extracts the OTP code or verification link from the message body
- Completes the auth flow inside Playwright
- Cleans up the inbox in teardown
The test uses real SMTP delivery — your application sends to the temporary inbox exactly as it would send to a real user. No interceptors, no stubs.
Architecture Overview
[Your App] --signup form--> [FastAPI / Node backend]
|
sends real email via SMTP
|
v
[uncorreotemporal.com SMTP receiver]
|
stores message
|
+------------------------------------------+
| REST API: GET /api/v1/mailboxes/ |
| {address}/messages |
+------------------------------------------+
|
Playwright test
polls -> extracts -> continues
The temporary inbox acts as a controllable endpoint on the SMTP receive side. You created it with one HTTP call; you read from it with another; you tear it down when the test is done. The application under test never knows the difference.
Setup
Install Playwright
npm install --save-dev @playwright/test
npx playwright install chromium
Environment Variables
# .env.test (never commit this)
UCT_API_KEY=uct_your_key_here
DEMO_BASE_URL=http://localhost:3000 # your app under test
Get your API key at uncorreotemporal.com. The free tier is sufficient for local development; for parallel CI pipelines use a paid key with higher inbox quotas.
Playwright Config
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
workers: process.env.CI ? 4 : undefined,
use: {
headless: true,
baseURL: process.env.DEMO_BASE_URL ?? "http://localhost:3000",
},
});
Step-by-Step Implementation
Step 1 — Create an Inbox via API
// tests/helpers/email.ts
const BASE = "https://uncorreotemporal.com/api/v1";
interface Inbox {
address: string;
expires_at: string;
session_token: string;
}
export async function createInbox(ttlMinutes = 15): Promise<Inbox> {
const res = await fetch(`${BASE}/mailboxes`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.UCT_API_KEY!,
},
body: JSON.stringify({ ttl_minutes: ttlMinutes }),
});
if (!res.ok) {
throw new Error(`Failed to create inbox: ${res.status} ${await res.text()}`);
}
return res.json();
}
The response:
{
"address": "crisp-falcon-77@uncorreotemporal.com",
"expires_at": "2026-03-24T15:00:00+00:00",
"session_token": "dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg"
}
Step 2 — Use the Inbox in the Signup Form
test("signup -> email verification -> dashboard", async ({ page }) => {
const inbox = await createInbox();
try {
await page.goto("/register");
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', "Secure1234!");
await page.click('[type="submit"]');
await page.waitForURL("**/confirm**");
await expect(page.locator("h1")).toContainText("Check your email");
const email = await waitForEmail(inbox.address, inbox.session_token);
// ... extract and complete
} finally {
await deleteInbox(inbox.address, inbox.session_token);
}
});
Step 3 — Wait for the Email (Polling with Retry)
export async function waitForEmail(
address: string,
sessionToken: string,
options: { timeout?: number; interval?: number; subjectFilter?: string } = {}
): Promise<FullMessage> {
const { timeout = 30_000, interval = 2_000, subjectFilter } = options;
const encoded = encodeURIComponent(address);
const headers = { "X-API-Key": process.env.UCT_API_KEY! };
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
let messages: MessageSummary[];
try {
const res = await fetch(`${BASE}/mailboxes/${encoded}/messages`, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
messages = await res.json();
} catch (err) {
console.warn(`[waitForEmail] poll error: ${err}. Retrying...`);
await sleep(interval);
continue;
}
const match = subjectFilter
? messages.find((m) => m.subject.toLowerCase().includes(subjectFilter.toLowerCase()))
: messages[0];
if (match) {
const res = await fetch(`${BASE}/mailboxes/${encoded}/messages/${match.id}`, { headers });
if (!res.ok) throw new Error(`Failed to fetch message body: ${res.status}`);
return res.json();
}
await sleep(interval);
}
throw new Error(`[waitForEmail] No email arrived at ${address} within ${timeout}ms`);
}
Key design: network errors are retried, not propagated. "No message" means keep polling — not failure. Use subjectFilter when your app sends multiple emails per registration.
Step 4 — Parse the Email (OTP and Link Extraction)
export function extractOTP(message: FullMessage): string {
const sources = [message.body_text, message.body_html].filter(Boolean) as string[];
for (const content of sources) {
const match = content.match(/\b(\d{6})\b/);
if (match) return match[1];
}
throw new Error("OTP not found in email body");
}
export function extractVerificationLink(message: FullMessage, urlPrefix: string): string {
const pattern = new RegExp(`${escapeRegex(urlPrefix)}[^\\s"'<>]+`);
const sources = [message.body_text, message.body_html].filter(Boolean) as string[];
for (const content of sources) {
const match = content.match(pattern);
if (match) return match[0];
}
throw new Error(`Verification link not found (prefix: ${urlPrefix})`);
}
Prefer body_text for numeric OTP extraction — it's cleaner than parsing HTML.
Step 5 — Complete the Auth Flow
OTP flow:
const email = await waitForEmail(inbox.address, inbox.session_token, {
subjectFilter: "Your verification code",
});
const otp = extractOTP(email);
await page.fill('[name="otp"]', otp);
await page.click('[type="submit"]');
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
Verification link flow:
const link = extractVerificationLink(email, `${process.env.DEMO_BASE_URL}/verify`);
await page.goto(link);
await expect(page.locator("h1")).toHaveText("Account verified");
Full Working Example
// tests/auth/register.spec.ts
import { test, expect } from "@playwright/test";
const BASE = "https://uncorreotemporal.com/api/v1";
const API_HEADERS = {
"Content-Type": "application/json",
"X-API-Key": process.env.UCT_API_KEY!,
};
async function createInbox(ttlMinutes = 15) {
const res = await fetch(`${BASE}/mailboxes`, {
method: "POST",
headers: API_HEADERS,
body: JSON.stringify({ ttl_minutes: ttlMinutes }),
});
if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
return res.json() as Promise<{ address: string; session_token: string }>;
}
async function waitForEmail(address: string, timeout = 30_000, subjectFilter?: string) {
const encoded = encodeURIComponent(address);
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/mailboxes/${encoded}/messages`, { headers: API_HEADERS });
if (!res.ok) { await sleep(2_000); continue; }
const messages: Array<{ id: string; subject: string }> = await res.json();
const hit = subjectFilter
? messages.find((m) => m.subject.toLowerCase().includes(subjectFilter.toLowerCase()))
: messages[0];
if (hit) {
const full = await fetch(`${BASE}/mailboxes/${encoded}/messages/${hit.id}`, { headers: API_HEADERS });
return full.json() as Promise<{ body_text: string | null; body_html: string | null }>;
}
await sleep(2_000);
}
throw new Error(`No email at ${address} after ${timeout}ms`);
}
async function deleteInbox(address: string) {
const encoded = encodeURIComponent(address);
await fetch(`${BASE}/mailboxes/${encoded}`, { method: "DELETE", headers: API_HEADERS });
}
function extractOTP(body_text: string | null, body_html: string | null): string {
for (const content of [body_text, body_html]) {
if (!content) continue;
const m = content.match(/\b(\d{6})\b/);
if (m) return m[1];
}
throw new Error("OTP not found in email");
}
function sleep(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
test("full signup -> OTP -> dashboard", async ({ page }) => {
const inbox = await createInbox();
try {
await page.goto("/register");
await page.fill('[name="email"]', inbox.address);
await page.click('[type="submit"]');
await page.waitForURL("**/confirm**");
const email = await waitForEmail(inbox.address, 30_000, "OTP");
const otp = extractOTP(email.body_text, email.body_html);
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="otp"]', otp);
await page.click('[type="submit"]');
await expect(page.locator("h1")).toHaveText("Welcome to the dashboard");
} finally {
await deleteInbox(inbox.address);
}
});
CI/CD Integration
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main, staging]
pull_request:
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
UCT_API_KEY: ${{ secrets.UCT_API_KEY }}
DEMO_BASE_URL: ${{ vars.STAGING_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Each CI run creates isolated inboxes. Parallel matrix jobs need zero coordination — each leg creates its own inbox.
Best Practices
Use subjectFilter when your app sends multiple emails per registration (welcome + confirmation). messages[0] on a two-message inbox is a race condition.
Distinguish network errors from missing emails. A 503 from the API should retry silently. An empty inbox after 30 seconds is a real failure.
Set ttl_minutes=5 in CI. Shorter TTLs reduce orphaned inbox accumulation if a test crashes before finally runs.
One inbox per test(), not per describe(). Two tests sharing an inbox re-introduce shared-state problems.
Add jitter for high-concurrency pipelines:
await sleep(Math.random() * 200);
const inbox = await createInbox();
Log the inbox address on failure:
test.info().annotations.push({ type: "inbox", description: inbox.address });
Why Real Email Testing Matters
| Bug | Mock catches | Real inbox catches |
|---|---|---|
sendEmail() not called |
Yes | Yes |
| Email template render failure | No | Yes |
| Broken verification link format | No | Yes |
| Wrong base URL for the environment | No | Yes |
| SMTP credential expiry | No | Yes |
| OTP in email doesn't match backend | No | Yes |
The most painful production bugs are in the second column. Testing against real SMTP delivery does not require standing up your own mail server. Programmable Temporary Email Infrastructure — what uncorreotemporal.com provides — is designed exactly for this.
Try It
The complete working examples — including the demo Express app the Playwright tests use as the application under test — are in the temporary-email-api-examples repository on GitHub.
Get your API key and start testing real email flows at https://uncorreotemporal.com
Top comments (0)