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
- One inbox per test run. A fresh name each run means no cross-test contamination and no cleanup step — free-tier mail expires after 48 hours anyway.
- The wait is server-side.
/waitholds the connection until mail arrives (up to 55s), so there's no sleep-loop guesswork in your test. - Extraction is deterministic.
extracted.otpcomes from parsers, not an LLM, so the same email always yields the same value.
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.