TempMaily.co
Developers

Cypress Email Testing: Verify Signups with cy.task

TempMaily Team12 min read

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:

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

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.

Frequently asked questions

How do I test email verification in Cypress?

Cypress drives the browser, so it fills and submits your signup form, but it cannot read the inbox your app emails a code to. Bridge the gap with a disposable-mail API: create a real inbox, use its address in the form, then poll the API for the verification message, extract the code, type it back into the browser, and assert. In Cypress the HTTP work belongs in cy.task (Node side) or cy.request, not a bare fetch inside the test.

Why can't Cypress read emails directly?

Cypress automates a browser and has no access to a mail server, IMAP, or an inbox. The verification email lives entirely outside the page Cypress is looking at. To read it you call a mail API — from cy.task in the Node process or with cy.request — that returns the message your app just sent. Cypress owns the browser half of the flow; the mail API owns the email half.

What is cy.task and why do I need it for email?

cy.task runs a function you register in cypress.config.ts inside Cypress's Node process, not the browser. That matters for email because a bounded polling loop with retry logic reads cleanly as plain Node, and your API key never has to enter browser-side code. You call cy.task('waitForMessage', ...) from the spec and get back the delivered message once it lands.

How do I extract an OTP code in Cypress?

Fetch the full message from the API, then run a scoped regular expression over its bodyText (falling back to bodyHtml). For a six-digit OTP, anchor the pattern to nearby copy such as 'your code is' rather than matching any six digits, otherwise a year or a support-line number in the footer can match first. Do the extraction inside the cy.task so the spec just receives the code string.

How do I avoid flaky email tests in Cypress?

Give every test its own inbox so parallel machines never cross wires, bound the poll with a deadline that comfortably exceeds real mail latency, raise taskTimeout above that deadline so Cypress does not kill the task mid-poll, scope the code regex tightly, and delete the inbox in afterEach treating 404 as success. Because Cypress retries re-run the whole test, creating the inbox in beforeEach gives each attempt a fresh mailbox.

Can I run this in CI?

Yes. Store your API key as a CI secret and expose it to the job as XTM_KEY so the Node tasks read it from process.env. The cypress-io/github-action runs the suite headless. Use a domain your app accepts so its own disposable-address filter does not reject the test address before the flow starts.

Get a free disposable inbox

A live throwaway address, no signup, real-time delivery. Upgrade to Premium for custom domains, forwarding, and no expiry.