Tutorial · Playwright

Testing email flows in Playwright: OTPs and magic links with a real inbox

Email is where E2E tests go to flake. The usual options — mocking the mailer (you're no longer testing the real flow), self-hosting MailHog (catches only your own SMTP), or a commercial inbox API (accounts, SDKs, per-inbox pricing) — all add friction. This tutorial uses mxu.io: a real inbox at anything@mxu.io your test creates on the fly, a blocking wait call, and OTP extraction that's already done when the message arrives.

Setup

Create a free account at mxu.io, generate an API key in the dashboard, and export it in CI as MXU_KEY. That's the whole setup — there is no SDK to install.

// tests/helpers/mxu.ts
const MXU = "https://api.musicsupplies.com/functions/v1/mxu-api";
const KEY = process.env.MXU_KEY!;
const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

export async function createInbox(name: string) {
  const r = await fetch(`${MXU}/inboxes`, { method: "POST", headers: H,
    body: JSON.stringify({ name }) });
  if (!r.ok) throw new Error(`inbox create failed: ${r.status}`);
  return `${name}@mxu.io`;
}

export async function waitForEmail(name: string, timeout = 50) {
  const r = await fetch(`${MXU}/wait?inbox=${name}&timeout=${timeout}`, { headers: H });
  const { messages, timed_out } = await r.json();
  if (timed_out) throw new Error(`no email arrived in ${timeout}s`);
  return messages[0]; // includes .extracted = { otp, links, verify_link }
}

Test 1: signup OTP

import { test, expect } from "@playwright/test";
import { createInbox, waitForEmail } from "./helpers/mxu";

test("signup delivers a working OTP", async ({ page }) => {
  const name = `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
  const address = await createInbox(name);

  await page.goto("/signup");
  await page.fill('[name="email"]', address);
  await page.click('button[type="submit"]');

  const mail = await waitForEmail(name);
  expect(mail.subject).toContain("verification");
  expect(mail.extracted.otp).toMatch(/^\d{6}$/);

  await page.fill('[name="code"]', mail.extracted.otp);
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL(/dashboard/);
});

Test 2: magic link / password reset

extracted.verify_link is the first URL that looks like a confirmation, magic, or reset link — so the test doesn't need to parse HTML:

test("password reset link works", async ({ page }) => {
  const name = `t-reset-${Date.now().toString(36)}`;
  const address = await createInbox(name);
  // ...create a user with this address, then:
  await page.goto("/forgot-password");
  await page.fill('[name="email"]', address);
  await page.click('button[type="submit"]');

  const mail = await waitForEmail(name);
  await page.goto(mail.extracted.verify_link);   // straight to the reset form
  await page.fill('[name="password"]', "n3w-Passw0rd!");
  await page.click('button[type="submit"]');
  await expect(page.locator(".toast")).toContainText("updated");
});

Why this doesn't flake

Rate-limit notes for CI

The free plan allows 1 inbox and 200 inbound messages/month — fine for evaluating. For a real suite, builder ($9/mo flat) gives 10 concurrent inbox names and 5,000 inbound messages, which comfortably covers hundreds of runs a day if you reuse a small pool of per-worker inbox names and filter with the since parameter.