Playwright + Temporary Email API: Full E2E Auth Flow Testing
How to implement a full E2E auth flow test in Playwright using a real Temporary Email API — inbox creation, email polling, OTP extraction, and CI/CD integration.

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
│
▼
[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, // generous for email delivery
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
The inbox is created before the browser even opens. It gives you a real email address that accepts SMTP delivery.
// 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 shape:
{
"address": "crisp-falcon-77@uncorreotemporal.com",
"expires_at": "2026-03-24T15:00:00+00:00",
"session_token": "dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg"
}
The session_token authenticates all subsequent calls to this inbox without requiring your API key in every request. Store it alongside the address.
Step 2 — Use the Inbox in the Signup Form
Nothing special here from Playwright's perspective — the address is just a string:
// tests/auth/signup.spec.ts
import { test, expect } from "@playwright/test";
import { createInbox, waitForEmail, deleteInbox } from "../helpers/email";
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"]');
// App redirects to "check your email" page
await page.waitForURL("**/confirm**");
await expect(page.locator("h1")).toContainText("Check your email");
// Now the SMTP delivery window opens — email is in transit
const email = await waitForEmail(inbox.address, inbox.session_token);
// ... extract and complete (shown in Steps 4–5)
} finally {
await deleteInbox(inbox.address, inbox.session_token);
}
});
The try/finally pattern matters: the inbox is deleted even if the test throws halfway through.
Step 3 — Wait for the Email (Polling with Retry)
Transactional email in a staging environment typically arrives in under 5 seconds. Build a polling helper that respects a timeout and distinguishes network errors from "not arrived yet":
// tests/helpers/email.ts (continued)
interface MessageSummary {
id: string;
subject: string;
from_address: string;
received_at: string;
}
interface FullMessage extends MessageSummary {
body_text: string | null;
body_html: string | null;
attachments: unknown[];
}
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) {
// Network error: retry, don't fail fast
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`);
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
Key design decisions here:
- Network errors are retried, not propagated — a transient DNS hiccup shouldn't fail your test.
- "No message" means keep polling, not failure — email delivery has legitimate latency.
subjectFilterlets you target the right email when your app sends both a welcome email and a separate confirmation.messages[0]on an inbox that received two messages is ambiguous.
Step 4 — Parse the Email (OTP and Link Extraction)
Both patterns come up in real apps. Handle them explicitly:
// tests/helpers/email.ts (continued)
/**
* Extract a 6-digit OTP from email body.
* Tries plain text first — it's cleaner to regex than HTML.
*/
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");
}
/**
* Extract a verification link matching the given URL prefix.
* Falls back from HTML to plain text if needed.
*/
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 in email body (prefix: ${urlPrefix})`);
}
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
One thing worth noting: when an OTP is embedded in an HTML button or styled table cell, body_text usually renders it cleanly as a bare number. Prefer body_text for numeric extraction, body_html only as a last resort.
Step 5 — Complete the Auth Flow in Playwright
OTP flow:
// Inside your test:
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 email = await waitForEmail(inbox.address, inbox.session_token, {
subjectFilter: "Confirm your account",
});
const link = extractVerificationLink(email, `${process.env.DEMO_BASE_URL}/verify`);
// Navigate Playwright directly to the link — no need to simulate clicking
await page.goto(link);
await expect(page.locator("h1")).toHaveText("Account verified");
Using page.goto(link) instead of typing the link or clicking a button in the email is intentional. The email client is out of scope; you want to assert that the link works, not that the email renders correctly as a clickable element.
Full Working Example
Here is the complete spec file matching the structure of the temporary-email-api-examples repository, adapted to the production API:
// tests/auth/register.spec.ts
import { test, expect } from "@playwright/test";
// ── API helpers ──────────────────────────────────────────────────────────────
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; expires_at: string }>;
}
async function waitForEmail(
address: string,
timeout = 30_000,
subjectFilter?: string
) {
const encoded = encodeURIComponent(address);
const headers = API_HEADERS;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/mailboxes/${encoded}/messages`, { 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 });
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 ─────────────────────────────────────────────────────────────────────
test("full signup → OTP → dashboard", async ({ page }) => {
const inbox = await createInbox();
try {
// Step 1: Fill the signup form with the temp inbox address
await page.goto("/register");
await page.fill('[name="email"]', inbox.address);
await page.click('[type="submit"]');
// Step 2: App redirects to the OTP confirmation screen
await page.waitForURL("**/confirm**");
// Step 3: Wait for real email delivery
const email = await waitForEmail(inbox.address, 30_000, "OTP");
// Step 4: Extract OTP
const otp = extractOTP(email.body_text, email.body_html);
// Step 5: Submit OTP — complete the auth flow
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);
}
});
Run it:
UCT_API_KEY=uct_your_key DEMO_BASE_URL=http://localhost:3000 npx playwright test
CI/CD Integration
GitHub Actions
# .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
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Why This Beats Mocking in CI
The UCT_API_KEY secret is the only external dependency you add to the pipeline. There is no SMTP container to spin up, no shared state to coordinate between matrix jobs, no staging-only bypass flag to remember to flip off.
Each CI run creates inboxes that are:
- Isolated — parallel jobs cannot read each other's emails
- Deterministic — the inbox address is generated fresh every run; no state bleeds between runs
- Self-cleaning — the
finallyblock deletes explicitly; TTL expiry cleans up if the job is killed
For a matrix strategy across multiple plans or regions, no changes are needed:
strategy:
matrix:
plan: [free, pro]
fail-fast: false
Each matrix leg creates its own inbox. They run in parallel with zero coordination.
Best Practices
Use subjectFilter when your app sends multiple emails per registration. Welcome emails and confirmation emails arrive in the same inbox. messages[0] on a two-message inbox is a race you will lose eventually.
Distinguish network errors from missing emails in your polling loop. A 503 from the API should retry silently. A clean empty-array response with no messages after 30 seconds is a real failure. The waitForEmail helper above handles this by only retrying on non-2xx responses.
Set ttl_minutes to 5 in CI, 15 locally. Shorter TTLs reduce inbox accumulation if a test crashes before finally runs. In local development, 15 minutes gives you time to inspect the inbox manually if a test breaks.
One inbox per test(), not per describe(). Running two tests in the same describe block against a shared inbox breaks isolation. If test A registers and triggers a welcome email and a confirmation email, test B's waitForEmail might grab the wrong one.
Use Playwright's --shard flag for parallelism, not just workers. With four shards across four runners, 40 tests create 40 inboxes in parallel. The temporary email API is rate-limit-aware — add 200ms jitter to createInbox() calls if you're running very large suites.
// Add jitter to inbox creation in high-concurrency environments
await sleep(Math.random() * 200);
const inbox = await createInbox();
Log the inbox address on test failure. When a test times out waiting for an email, the inbox address is the key debug artifact. Playwright's test.info().annotations is a clean place to attach it:
test.info().annotations.push({ type: "inbox", description: inbox.address });
Why Real Email Testing Matters
The gap between a mocked email test and a real email test is not about coverage percentage — it's about what class of bugs each catches.
| 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 |
| SPF / DKIM rejection causing silent drop | No | Yes |
| OTP in email doesn't match backend | No | Yes |
The most painful production bugs are in the second column. A broken template, a link pointing to localhost, an SMTP key rotated without updating CI secrets — these are the bugs that reach users because the mocked test reported green.
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: a real inbox, reachable by real SMTP, provisioned in one HTTP call, and expired automatically when the test is done.
Your E2E tests should exercise the same delivery path your users rely on. If they don't, you're flying blind on one of the most common points of failure in user registration.
Try It
If you want to run this yourself, the complete working examples — including the demo Express app that the Playwright tests use as the application under test — are in the temporary-email-api-examples repository.
Get your API key and start testing real email flows at uncorreotemporal.com.
Written by
Software Engineer · Sr. Python Developer · AWS Certified Solutions Architect
Software engineer with 20 years of experience building Python backends, cloud infrastructure, and AI agent tooling. Builder of UnCorreoTemporal.
LinkedInReady to give your AI agents a real inbox?
Create your first temporary mailbox in 30 seconds. Free plan available.
Create your free mailbox