Most sign-up flows send an email at some point. A six-digit code, a "confirm your address" link, a magic login link. And most end-to-end suites quietly skip that step: they stub the mailer, read the code from the database, or add a test-only backdoor that marks the user as verified.
That works until it doesn't. A mock won't notice that the template lost the code during a refactor, that the link points at localhost in staging, or that a config change means the email never goes out at all. Those are exactly the bugs users hit first.
This post shows a setup where the test reads the email your app actually sent. No mocks, no database peeking, and no sleep(10000).
The idea
- Point a domain you control at a catch-all inbox, so any address at that domain receives mail.
- Generate a new address for every test run, like
signup-1728000000000@yourdomain.com. - Let the app send its normal email through its normal provider.
- Ask an API for the code at that address, and have the request wait until the email arrives.
I'll use Free Domain Mail for steps 1 and 4 because that's the service I build (disclosure at the end). The patterns apply to any inbox API with long-polling.
Setup: a catch-all domain and an API key
Add a domain you own as a custom domain, publish the TXT ownership record (host _maildock-verify), and set its MX record to mx.freedomainmail.com with priority 10. After that, every address at the domain receives mail. There is nothing to create per test, which is the whole point.
Use a domain or subdomain that doesn't already receive mail you care about, since changing MX moves all of its mail. A spare domain or something like qa.yourcompany.com works well.
Then create an API key and store it as a CI secret:
FDM_API_KEY=your-api-key
Every request sends it as Authorization: Bearer <key>. The key only reads domains connected to its own account.
Playwright: wait for the code
GET /api/v1/code takes the address and a wait value in seconds (up to 60). The request stays open until a message arrives at that address, then returns the extracted code along with message_id, from, subject and received_at. If nothing arrives within wait, you get a 404, so always check res.ok before reading the body.
// tests/email.ts
const API = "https://freedomainmail.com/api/v1";
function authHeaders() {
const key = process.env.FDM_API_KEY;
if (!key) throw new Error("FDM_API_KEY is not set");
return { Authorization: `Bearer ${key}` };
}
export function newAddress(prefix = "signup") {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return `${prefix}-${id}@yourdomain.com`;
}
export async function waitForCode(address: string): Promise<string> {
const params = new URLSearchParams({ address, wait: "60" });
const res = await fetch(`${API}/code?${params}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`No code for ${address}: HTTP ${res.status}`);
const { code } = await res.json();
return code;
}
And the test:
// tests/signup.spec.ts
import { test, expect } from "@playwright/test";
import { newAddress, waitForCode } from "./email";
test("sign up with an email code", async ({ page }) => {
// The default test timeout (30s) is shorter than a 60s wait.
test.setTimeout(120_000);
const address = newAddress();
await page.goto("https://your-app.example/signup");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Send code" }).click();
const code = await waitForCode(address);
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page.getByText("Welcome")).toBeVisible();
});
Two details matter here. First, test.setTimeout(120_000) is longer than the 60-second wait. If your test timeout is shorter than the wait, Playwright kills the test before the API has had its chance to answer, and you get a confusing timeout instead of a clear "no code" error. Second, the address is unique per run, which I'll come back to in the flakiness section.
When the email has other numbers in it
By default the API detects the code on its own, which is fine for most templates. But if your email says something like "Order 48213. Your code is 912044. Expires in 10 minutes," you want to be explicit.
Pass pattern with the literal text around the code and exactly one placeholder: {code} for any code, or {digits} when it's digits only. The pattern needs some literal text besides the placeholder. Build the query with URLSearchParams so the braces and spaces get encoded:
export async function waitForExactCode(address: string): Promise<string> {
const params = new URLSearchParams({
address,
wait: "60",
pattern: "Your code is {digits}",
});
const res = await fetch(`${API}/code?${params}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`No code for ${address}: HTTP ${res.status}`);
const { code } = await res.json();
return code;
}
If your app sends more than one kind of email to the same address, you can also narrow by from or subject.
Magic links
For "click to log in" or "confirm your email" links, use GET /api/v1/links with the same address and wait. It returns primary, the main verification link, plus links with the other verification links it found.
export async function waitForLink(address: string): Promise<string> {
const params = new URLSearchParams({ address, wait: "60" });
const res = await fetch(`${API}/links?${params}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`No link for ${address}: HTTP ${res.status}`);
const { primary } = await res.json();
return primary;
}
test("log in with a magic link", async ({ page }) => {
test.setTimeout(120_000);
const address = newAddress("login");
await page.goto("https://your-app.example/login");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Email me a link" }).click();
await page.goto(await waitForLink(address));
await expect(page.getByText("Signed in")).toBeVisible();
});
If you need to assert on the whole email (subject line, a footer, the sender), /api/v1/messages lists and reads full messages.
Cypress variant
In Cypress, cy.request does the same job. Set its timeout above the 60-second wait so Cypress doesn't give up first. cy.request already fails the test on a non-2xx response, so a 404 for a missing email shows up as a clear failure without extra code.
// cypress/e2e/signup.cy.js
it("signs up with an email code", () => {
const address = `signup-${Date.now()}@yourdomain.com`;
cy.visit("/signup");
cy.get("input[name=email]").type(address);
cy.contains("button", "Send code").click();
cy.request({
url: "https://freedomainmail.com/api/v1/code",
qs: { address, wait: 60 },
headers: { Authorization: `Bearer ${Cypress.env("FDM_API_KEY")}` },
timeout: 70000,
})
.its("body.code")
.then((code) => {
cy.get("input[name=code]").type(code);
});
cy.contains("button", "Verify").click();
});
Pass the key in with CYPRESS_FDM_API_KEY=... in CI, or in cypress.env.json locally (and keep that file out of git).
Keeping it from getting flaky
Email tests have a reputation for flakiness. Most of it comes from a few habits that are easy to avoid.
Use a unique address per run. The API returns the newest matching message for an address. If every run uses test@yourdomain.com, a retry can pick up the code from the previous attempt, which is already expired. A timestamp plus a short random suffix is enough. If you really must reuse an address, pass since with the time the test started (ISO 8601 or Unix time) so older messages are ignored.
Wait, don't sleep. A fixed sleep is wrong in both directions: too short when your email provider is slow, wasted time when it's fast. A long-poll returns the moment the email lands.
Keep timeouts larger than the wait. Test timeout, cy.request timeout and any HTTP client timeout should all be above 60 seconds if you use wait=60. Otherwise your tooling fails before the API does.
Limit parallel email tests. Each key allows 60 requests per minute, and a single long-poll covers one email, so you don't need to poll in a loop. A key can also hold only a few waiting requests at the same time; extra concurrent waits get HTTP 429. If you run a big parallel suite, put the email tests in their own project with fewer workers, or tag them and run them serially:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{ name: "default", testIgnore: /email\/.*\.spec\.ts/ },
{ name: "email", testMatch: /email\/.*\.spec\.ts/, fullyParallel: false },
],
});
Then run that project with a small worker count, for example npx playwright test --project=email --workers=2.
Treat a 404 as a real failure. When the wait expires with nothing, the useful question is "did the app send the email?" That's a bug you want the test to catch, not retry around.
What this doesn't cover
The inbox is receive-only. It won't send or forward mail, so it's for verifying what your app sends, not for testing replies. And the API reads only custom domains you've connected, not the public temp mail addresses on the homepage.
API reference: freedomainmail.com/docs/api. There's also a longer walkthrough of how catch-all works: catch-all email guide.
If you test OTP flows a different way, I'd like to hear how in the comments.
Disclosure: I build Free Domain Mail – Temp Mail, the service used in this post.
Top comments (0)