DEV Community

Francisco Perez
Francisco Perez

Posted on • Originally published at uncorreotemporal.com

Playwright + Temporary Email API: Full E2E Auth Flow Testing

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:

  1. Creates a temporary inbox via the uncorreotemporal.com API
  2. Submits a signup form using that inbox address
  3. Polls for the incoming email with a configurable timeout
  4. Extracts the OTP code or verification link from the message body
  5. Completes the auth flow inside Playwright
  6. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Environment Variables

# .env.test (never commit this)
UCT_API_KEY=uct_your_key_here
DEMO_BASE_URL=http://localhost:3000   # your app under test
Enter fullscreen mode Exit fullscreen mode

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",
  },
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

The response:

{
  "address": "crisp-falcon-77@uncorreotemporal.com",
  "expires_at": "2026-03-24T15:00:00+00:00",
  "session_token": "dGhpcyBpcyBhIHNhbXBsZSB0b2tlbg"
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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})`);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Log the inbox address on failure:

test.info().annotations.push({ type: "inbox", description: inbox.address });
Enter fullscreen mode Exit fullscreen mode

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)