Sign-up and login flows that send a one-time code or a magic link are hard to test end to end, because the test has to read an email. This guide shows how to give each test run its own address on a domain you control, then fetch the code or link from the Free Domain Mail API inside Playwright and Cypress tests.
Why real email in E2E tests
Mocking the email step keeps tests fast, but it skips the part users actually depend on. A mock will not notice a broken template, a code that never makes it into the message, a link that points to the wrong host, or an email that is never sent because of a configuration change.
Receiving a real message closes that gap. The test fills in the form, your app sends the email through its normal provider, and the test reads the code from the message that actually arrived.
Setup
Connect a domain. Add a domain you own to Free Domain Mail as custom temp mail, publish the TXT ownership record, and point its MX record to mx.freedomainmail.com. The domain becomes a catch-all, so every address at it receives mail and there is nothing to create per test. Use a domain that does not already receive mail you depend on. The setup guide walks through the records, and the catch-all email guide explains how catch-all works.
Create an API key and keep it in an environment variable, such as FDM_API_KEY, in your CI secrets. Do not commit it. Every request sends it as Authorization: Bearer followed by the key. The API only reads domains connected to the account that owns the key.
Use a new address for each test, built from a timestamp or random value, such as signup-${Date.now()}@yourdomain.com. The full API reference is in the API docs, and the temp mail API page gives an overview.
FDM_API_KEY=your-api-keyPlaywright example
GET /api/v1/code waits for a message at the address and returns the extracted code as code, along with message_id, from, subject and received_at. The wait parameter holds the request open for up to 60 seconds and returns as soon as a matching message arrives. If nothing arrives in time, the API responds with 404, so check res.ok before reading the body.
Playwright's default test timeout is shorter than a 60-second wait, so raise it for tests that read email.
import { test, expect } from "@playwright/test";
const API = "https://freedomainmail.com/api/v1";
async function waitForCode(address: string): Promise<string> {
const res = await fetch(`${API}/code?address=${encodeURIComponent(address)}&wait=60`, {
headers: { Authorization: `Bearer ${process.env.FDM_API_KEY}` },
});
if (!res.ok) throw new Error(`No code for ${address}: HTTP ${res.status}`);
const { code } = await res.json();
return code;
}
test("sign up with an email code", async ({ page }) => {
test.setTimeout(120_000);
const address = `signup-${Date.now()}@yourdomain.com`;
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();
});Cypress example
In Cypress, cy.request makes the same call. Set its timeout above the 60-second wait so Cypress does not give up first. By default cy.request fails the test on a non-2xx response, so a 404 from a missing email shows up as a clear failure. Provide the key as the Cypress environment value FDM_API_KEY and read it with Cypress.env.
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();
});Magic links
For login links instead of codes, call GET /api/v1/links with the same address and wait parameters. It returns primary, the main verification link, along with links (the verification links found in the message), message_id and received_at. Open primary in the browser to finish the flow.
const res = await fetch(
`https://freedomainmail.com/api/v1/links?address=${encodeURIComponent(address)}&wait=60`,
{ headers: { Authorization: `Bearer ${process.env.FDM_API_KEY}` } },
);
if (!res.ok) throw new Error(`No link for ${address}: HTTP ${res.status}`);
const { primary } = await res.json();
await page.goto(primary);Exact codes with pattern
Without pattern, the API detects the code automatically. If your emails contain other numbers that could be mistaken for the code, pass pattern with the text around it and exactly one {code} placeholder, for example pattern=Your code is {code}. Use {digits} instead of {code} when the code is digits only. The pattern needs some literal text besides the placeholder, and pattern is accepted by /api/v1/code only.
You can also narrow which message is read with from (a sender address or domain) and subject (text the subject contains).
const params = new URLSearchParams({
address,
wait: "60",
pattern: "Your code is {code}",
from: "no-reply@your-app.example",
});
const res = await fetch(`https://freedomainmail.com/api/v1/code?${params}`, {
headers: { Authorization: `Bearer ${process.env.FDM_API_KEY}` },
});Avoid flaky tests
Use a unique address per run. The API returns the newest matching message, so reusing an address can hand a test the code from an earlier run. If you must reuse an address, pass since with the time the test started, as an ISO 8601 timestamp or Unix time, so older messages are ignored.
Use wait instead of sleep. A fixed sleep is either too short on a slow day or wastes time on a fast one. With wait, the request returns as soon as the email arrives.
Respect the rate limit. Each API key allows 60 requests per minute, and requests over the limit get HTTP 429. One long-polling request per email is enough; do not poll in a tight loop. Each API key can hold only a few waiting requests at the same time; extra concurrent waits get HTTP 429, so limit how many email tests run in parallel.
Common mistakes to avoid
- Hard-coding the API key in test files or committing it to the repository instead of reading it from FDM_API_KEY.
- Reusing one fixed address for every run without since, so a test reads a code left over from a previous run.
- Leaving the test runner's timeout below the wait time, so the test fails before the email has had a chance to arrive.
Frequently asked questions
Can I use public temp mail addresses with the API?
No. The API only reads domains connected to the account that owns the API key. Connect a domain you own so every test address is private to you.
What happens if the email never arrives?
The request waits for up to the number of seconds in wait, at most 60, and then returns 404. Treat that as a test failure and check whether your app sent the email.
Can Free Domain Mail send the OTP email for my app?
No. Free Domain Mail is receive-only and never sends or forwards mail. Your app keeps sending through its own email provider; the API only reads what arrives.
How many requests can my tests make?
Each API key allows 60 requests per minute. A single request with wait set covers one email, so there is no need to poll repeatedly.
Does this work in CI?
Yes, as long as the CI runner can reach the API over HTTPS and the key is available as an environment variable or secret.