Volver al blog
·8 min de lectura

End-to-End Testing with Disposable Email and Playwright

Learn how to test email verification flows end-to-end using Playwright and a disposable email API — no mocking, no flaky tests, real SMTP delivery.

testing playwright automation email cicd

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) {
      // Fetch full message body
      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`);
}

This polls every 2 seconds for up to 30 seconds — reasonable for transactional email in a test environment.

Extract the OTP or Verification Link

The message object contains body_text and body_html. Use regex to extract what you need:

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

Back in Playwright, navigate to the extracted link and assert the result:

  const email = await waitForEmail(address);
  const verificationLink = extractVerificationLink(email);

  await page.goto(verificationLink);
  await expect(page.locator(".verified-badge")).toBeVisible();
});

The test has now exercised the full path: form submission, email delivery, link extraction, and verification confirmation — without any mocking.


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);
  }
});

The finally block ensures cleanup even if the test fails mid-run.


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.

Written by

FP
Francisco Pérez Ferrer

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.

LinkedIn

Ready to give your AI agents a real inbox?

Create your first temporary mailbox in 30 seconds. Free plan available.

Create your free mailbox