Cypress can drive your signup form, but it cannot open the inbox your app emails a code to — and its command queue means you can't just await fetch the mail either. The fix is to pair Cypress with a disposable-mail API and put the HTTP calls where Cypress expects them: cy.task on the Node side for the polling loop, or cy.request for a one-off call. This post is the working setup — a cypress.config.ts with the tasks defined and a full spec that uses them.
If you want the framework-agnostic version of the argument — why you can't test email in the browser alone, and how polling stacks up against webhooks — the companion Playwright email testing guide covers it. Here we stay on the Cypress-specific mechanics.
Quick answer
Cypress email testing works like this: before the test runs, call a disposable-inbox API to create a real, unique address. Fill your signup form with that address and submit. The app sends its verification email; your test polls the mail API until the message lands, extracts the one-time code (OTP) with a scoped regex, types it into the verification field, and asserts the account is confirmed. Tear the inbox down afterward.
The Cypress twist is where the HTTP calls live. You have two idiomatic options: cy.request fires a single HTTP request from the Cypress process and is fine for one-shot calls, or cy.task runs a function in Node that you register in cypress.config.ts. For the poll, use cy.task — a retry loop reads cleanly as plain Node and your API key stays out of the browser. The rest of this guide is that setup.
The Cypress-specific catch
Cypress commands are not promises you await. When you write cy.get(...) and cy.request(...), you are enqueuing commands onto a chain that Cypress runs later, in order. So the instinct you'd bring from a plain Node script — const msg = await fetch(...) inside the test body — doesn't interleave with the command queue the way you expect. The fetch runs immediately, before Cypress has clicked anything, and your test races itself.
There are two supported ways to do the network work, and the difference is which process runs it:
cy.requestissues an HTTP request from the Cypress process and hands the response back into the command chain. It is not subject to browser CORS, so aBearercall to the mail API just works. This is the right tool for a single call — creating an inbox, or a one-shot message fetch. The cost: the request is defined in your spec, so the API key travels through browser-facing test code and anyCypress.envvalue is readable from the app under test.cy.taskruns a function you registered insetupNodeEventsinside Cypress's own Node process — no browser involved. You callcy.task('name', arg)and Cypress marshals the argument over, runs your Node function, and returns the (serializable) result into the chain. This is where a polling loop belongs: the retry-with-delay logic is ordinary Node, andprocess.env.XTM_KEYis read on the Node side and never crosses into the browser.
For the wait-for-the-email step, prefer cy.task. A poll is a loop that sleeps between attempts and returns when a message matching your predicate arrives. Expressed as cy.request calls you'd be chaining N requests with cy.wait between them and re-checking each time — awkward, and every one of those requests carries the key browser-side. As one Node task it is a plain while loop with a deadline, it returns the moment the message lands, and the key stays server-side. Use cy.request for the genuinely one-shot calls if you like; use cy.task for anything that loops.
The worked example
Two files: cypress.config.ts, where the tasks are defined against the TempMaily API, and the spec that uses them. Every endpoint, header, and field name below matches the API reference — reconcile against that page if anything ever drifts.
The tasks run in Node (18+), so they use the global fetch with no SDK — you can read every request. The key is read once from process.env.XTM_KEY on the Node side.
// cypress.config.ts
import { defineConfig } from "cypress";
const API = "https://tempmaily.co/api/v1";
function authHeaders() {
const key = process.env.XTM_KEY; // Premium API key, xtm_-prefixed
if (!key) throw new Error("XTM_KEY is not set");
return { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
}
// Pull a six-digit OTP out of a message. Prefer the text part; fall back to
// HTML. Anchor to nearby copy so a footer number never matches first.
function extractCode(bodyText: string, bodyHtml: string): string {
const body = bodyText || bodyHtml || "";
const anchored = body.match(/code(?:\s+is)?[:\s]+(\d{6})/i);
if (anchored) return anchored[1];
const bare = body.match(/\b(\d{6})\b/);
if (bare) return bare[1];
throw new Error("No 6-digit code found in message body");
}
export default defineConfig({
// cy.task inherits this timeout. Keep it comfortably above the poll deadline
// below so Cypress never kills waitForMessage mid-loop.
taskTimeout: 45_000,
e2e: {
baseUrl: "http://localhost:3000",
setupNodeEvents(on) {
on("task", {
// Create a fresh disposable inbox. Each call returns a unique address,
// so every test — and every parallel machine — gets its own mailbox.
async createInbox() {
const res = await fetch(`${API}/inboxes`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ expiry: "1d" }),
});
if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
const { id, address, expiresAt } = await res.json();
return { id, address, expiresAt };
},
// Poll the inbox until a message whose subject matches `pattern`
// arrives, then return the extracted code. The rate limit is
// 300 requests / 60s per key, so a 2s interval leaves headroom.
async waitForMessage({
inboxId,
pattern,
timeoutMs = 30_000,
intervalMs = 2_000,
}: {
inboxId: string;
pattern: string;
timeoutMs?: number;
intervalMs?: number;
}) {
const re = new RegExp(pattern, "i");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const list = await fetch(`${API}/inboxes/${inboxId}/messages`, {
headers: authHeaders(),
});
if (!list.ok) throw new Error(`list failed: ${list.status}`);
const { messages = [] } = await list.json();
const hit = messages.find((m: { subject: string }) =>
re.test(m.subject),
);
if (hit) {
// The list endpoint returns summaries only — fetch the full body.
const full = await fetch(`${API}/messages/${hit.id}`, {
headers: authHeaders(),
});
if (!full.ok) throw new Error(`get failed: ${full.status}`);
const { bodyText, bodyHtml, subject } = await full.json();
return { code: extractCode(bodyText, bodyHtml), subject };
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`No matching message in ${inboxId} within ${timeoutMs}ms`);
},
// Delete the inbox and its mail once the test is done. 204 on success;
// 404 means it was already gone — both are fine in teardown.
async deleteInbox(inboxId: string) {
const res = await fetch(`${API}/inboxes/${inboxId}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!res.ok && res.status !== 404) {
throw new Error(`deleteInbox failed: ${res.status}`);
}
return null; // a task must return something serializable, not undefined
},
});
},
},
});
Now the spec. It creates an inbox in beforeEach, runs the real signup, waits for the code via cy.task, verifies in the browser, and asserts — then cleans up in afterEach. Notice the inbox id is stashed on a closure variable so afterEach can reach it even if the test body throws.
// cypress/e2e/signup-verification.cy.ts
describe("signup email verification", () => {
let inbox: { id: string; address: string; expiresAt: string };
beforeEach(() => {
cy.task("createInbox").then((created) => {
inbox = created as typeof inbox;
});
});
afterEach(() => {
if (inbox?.id) cy.task("deleteInbox", inbox.id);
});
it("user can sign up and confirm their email", () => {
// 1. Drive the real signup form with the disposable address.
cy.visit("/signup");
cy.get('input[name="email"]').type(inbox.address);
cy.get('input[name="password"]').type("Sufficiently-Long-Pw-9!");
cy.contains("button", "Create account").click();
cy.contains(/check your email/i).should("be.visible");
// 2. Wait for the verification email and pull out the OTP (all in Node).
cy.task("waitForMessage", {
inboxId: inbox.id,
pattern: "verify|confirm|code",
timeoutMs: 30_000,
intervalMs: 2_000,
}).then((result) => {
const { code } = result as { code: string; subject: string };
expect(code).to.match(/^\d{6}$/);
// 3. Complete verification in the browser and assert the result.
cy.get('input[name="verificationCode"]').type(code);
cy.contains("button", "Verify").click();
cy.url().should("include", "/dashboard");
cy.contains(/email verified/i).should("be.visible");
});
});
});
That is the whole flow: a real inbox, a real email, a real code typed into a real browser, asserted end to end. No mock, no shared mailbox, no human copying a code out of Gmail.
Prefer cy.request for the one-shot calls? You can create the inbox from the spec instead — a cy.request with method: "POST", the /inboxes URL, and an Authorization: Bearer header — but that reads the key from Cypress.env("XTM_KEY") (set via a CYPRESS_XTM_KEY env var), which is exposed to the browser. Keep the poll in cy.task regardless.
Hardening against flaky email tests
The example passes on a good day. Staying green for a year is in the edges.
Set the poll deadline against real mail latency — and taskTimeout above it. These are two different clocks and they interact. timeoutMs inside waitForMessage bounds your own loop; taskTimeout (config, default 60000ms) is Cypress's patience for the whole cy.task. If your poll deadline is 30s but taskTimeout is left at a lower value, Cypress kills the task before the loop can finish and you get a confusing task-timeout error instead of a clean "no message" failure. Set taskTimeout comfortably above the longest poll — 45s task timeout for a 30s poll, here — so the loop always owns its own failure. Note that defaultCommandTimeout (the 4s that governs cy.get) does not apply to tasks; don't reach for it here.
Isolate an inbox per test, not per suite. createInbox runs in beforeEach, so every test gets a unique address. Never hoist it to a shared before that hands one address to many tests — you'd reintroduce exactly the race that disposable inboxes exist to kill, and it surfaces the instant you shard across machines.
Lean on retries, and understand what they re-run. Cypress test retries re-run the whole test, including beforeEach. That is a feature here: a retried attempt calls createInbox again and gets a brand-new inbox, so there's no message bleed from the failed attempt's mail into the retry. Enable retries: { runMode: 2 } and each attempt starts from a clean mailbox.
Tear inboxes down. The afterEach calls deleteInbox, which removes the inbox and its mail and frees your account's storage quota. The task treats 404 as success — if a test failed before the inbox existed, the delete is a no-op and teardown should never throw over it.
Respect the rate limit. The key is capped at 300 requests / 60s. A 2-second interval is well within budget, but a tight loop across many parallel machines can trip 429 rate_limited — keep the interval at seconds, not milliseconds. See the general temp mail API automation guide for the polling-vs-webhook trade-off in more depth.
Running it in CI
Keep the API key in a CI secret and expose it to the job as XTM_KEY so the Node tasks read it from process.env. The official action installs Cypress and runs the suite headless:
# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: "http://localhost:3000"
env:
XTM_KEY: ${{ secrets.XTM_KEY }}
That is the short version. The Playwright post has the fuller CI story — caching, sharding, artifacts — and it all transfers; only the runner action differs.
Common mistakes
await fetchin the test body. It runs immediately, out of sync with the command queue, and your test races the browser. Put the network work incy.taskorcy.request.- Leaving
taskTimeoutat the default. A poll longer thantaskTimeoutdies as a task timeout, not a clean "no message" error. RaisetaskTimeoutabove your poll deadline. - Matching any six digits.
\b\d{6}\bmatches a copyright year or an order number before the real code. Anchor to the surrounding copy, asextractCodedoes. - Reading the wrong field. The full-message response exposes
bodyTextandbodyHtml— nottext/html. PreferbodyText; some senders only fill the HTML part. - Extracting from the list endpoint.
GET /inboxes/{id}/messagesreturns summaries with asnippet, no body. You must fetchGET /messages/{id}forbodyText/bodyHtmlfirst —waitForMessagedoes that second call for you. - Returning
undefinedfrom a task. Cypress rejects it; returnnull(asdeleteInboxdoes) when there's no value. - Sharing one inbox across tests. Fine until the first parallel shard, then it's the flakiest thing you own.
Honest caveats
Two things to be straight about. First, free inboxes expire — the default is a day, plenty for a test that runs in seconds, but it means you should never hardcode an address and expect it to survive between runs. Create a fresh one each time; that's the pattern here anyway. Second, some staging environments block known disposable domains at signup — that's the defense working as designed, and it means your own app may reject a free address before the flow starts. If that's happening, we wrote up the mechanics in why websites block temp mail; the fix for tests is a Premium dedicated or custom domain your app treats as legitimate.
And on architecture: webhooks exist for event-driven flows, and TempMaily can POST each message to a signed endpoint as it arrives. But inside a Cypress spec the test is already blocking and waiting, so polling in a cy.task is the simpler shape — reach for webhooks when there's no test loop to hold open. The API reference covers webhook registration and signature verification when you get there.
Next steps
Pairing Cypress with a disposable-mail API turns email verification from the flakiest step in your suite into one more assertion. Register the tasks in cypress.config.ts, create an isolated inbox per test, drive the real form, poll in cy.task, extract the OTP with a scoped regex, verify in the browser, and tear the inbox down. Programmatic access is part of TempMaily Premium, and the API reference is the source of truth for every endpoint and limit used above. New to disposable inboxes? Start from the homepage, then wire the tasks into your Cypress project and delete the "we skip email in CI" line from your test plan.