Testing & CI

Cypress email testing: sign-up, OTP and reset emails

Cypress runs your test in the browser, and the browser cannot read a mailbox. The usual fix is a task on the Node side that polls one — here it is, against a real disposable inbox with no API key, plus the two custom commands that make a sign-up spec read like the feature it tests.

  • Intermediate
  • 19 min read
A grey mechanical arm lowering a blue envelope onto a bench of three grey cubes marked with blue ticks

Why Cypress needs a task for this

A Cypress spec runs inside the browser, in the same window as the page under test. That is what makes cy.get and cy.contains so direct, and it is also why the spec cannot simply loop over an HTTP API for a minute: the command queue is not a place for a while loop with a sleep in it, and a chain of retried cy.request calls is hard to read and harder to stop.

The three usual ways to test the email half of a flow each prove something different, and only one of them proves the thing you shipped:

Stubbing the mailer
Proves send() was called with the right arguments. Says nothing about the template, the link, or the provider that rejected the message.
A local SMTP sink (Mailpit, MailHog, smtp4dev)
Proves a well-formed message left the application. One more container in CI, and nothing that only happens on the public internet — a real MX lookup, a real provider, a real recipient — happens here.
A real disposable mailbox
Proves the message left the application, crossed the internet, was accepted by a real mail server and carries a code that works. The cost is that the test has to wait properly, and in Cypress the right place to wait is a task.

The API the task calls is three endpoints with no key — the reference is short. The runner-agnostic version of this discipline is in testing a verification flow end to end; the Playwright version, with a fixture instead of a task, is in the Playwright guide.

The task: a polling loop on the Node side

Everything that has to wait lives here, in setupNodeEvents. It is plain Node: fetch, a deadline, one read a second, and a 429 branch that sleeps rather than fails. The spec never sees any of it.

cypress.config.ts
import { defineConfig } from 'cypress';

const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    taskTimeout: 90_000,                  // above the mail deadline below, always
    setupNodeEvents(on) {
      on('task', {
        /** Poll a mailbox until a matching message arrives, or the deadline passes. */
        async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
          const deadline = Date.now() + timeoutMs;

          while (Date.now() < deadline) {
            const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);

            if (res.status === 429) {                  // slow down, do not fail
              await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
              continue;
            }
            if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${address}`);

            const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
            const hit = messages.find(m =>
              (!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
              (!fromContains    || m.from.toLowerCase().includes(fromContains.toLowerCase())));

            if (hit) {
              const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
              if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
              return full.json();                      // the whole message, both parts
            }
            await sleep(1000);                         // one read a second, never throttled
          }
          return null;                                 // "not yet" is an answer, not an error
        },
      });
    },
  },
});

Two decisions in that file are worth stating. The task returns the full message, not the summary, because the next thing every spec wants is the body and a second task call for it is noise. And it returns null at the deadline instead of throwing: “no message yet” is a legitimate answer for a task to give, and the command below is where it becomes a failure with a useful message.

Two custom commands and two extractors

The commands are thin on purpose. freshAddress invents a mailbox; waitForMail calls the task with a timeout comfortably above the deadline and asserts on the answer. The extractors are plain functions, because they are plain string work and a Cypress command would only make them harder to unit-test.

cypress/support/commands.ts
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };

declare global {
  namespace Cypress {
    interface Chainable {
      /** A mailbox nothing else in this run, or any previous run, is using. */
      freshAddress(prefix?: string): Chainable<string>;
      /** Block until a matching message arrives. Fails the test at the deadline. */
      waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
    }
  }
}

Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
  cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));

Cypress.Commands.add('waitForMail', (address, opts = {}) =>
  cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
    .then(m => {
      expect(m, `a message for ${address}`).not.to.be.null;
      return cy.wrap(m as Message, { log: false });
    }));

/** The whole body, both parts, with the entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

Note the command’s own timeout: it is the task’s deadline plus ten seconds, so the task always gets to give its answer. Without it, Cypress’s default task timeout of sixty seconds races the sixty-second mail deadline and wins by a few milliseconds, and the failure blames the task.

Three specs, end to end

With the task and the commands in place, each spec reads like the feature it exercises. The waiting and the parsing are somewhere else, which is the whole point of putting them there.

Sign-up with a confirmation code

cypress/e2e/signup.cy.ts
import { codeFrom } from '../support/commands';

describe('sign-up', () => {
  it('confirms the address with the emailed code', () => {
    cy.freshAddress().then(address => {
      cy.visit('/signup');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('correct-horse-battery-staple');
      cy.contains('button', 'Create account').click();
      cy.contains('Check your inbox').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
        cy.get('input[name="code"]').type(codeFrom(message));
        cy.contains('button', 'Confirm').click();
        cy.contains('h1', 'Welcome').should('be.visible');
      });
    });
  });
});

A login that asks for an emailed one-time code

The user has to exist first, and that is a job for your application’s own test seam — an internal endpoint, a database fixture, a CLI — reached with cy.request, not through the browser.

cypress/e2e/otp-login.cy.ts
import { codeFrom } from '../support/commands';

describe('login with an emailed one-time code', () => {
  it('asks for the code and accepts it', () => {
    cy.freshAddress().then(address => {
      // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
      cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('hunter2hunter2');
      cy.contains('button', 'Log in').click();
      cy.contains('Enter the code we emailed you').should('be.visible');

      cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
        cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
        cy.contains('button', 'Continue').click();
        cy.url().should('include', '/dashboard');
      });
    });
  });
});

A password reset, then a login with the new password

The reset link is followed with a plain cy.visit when it points at the same origin as baseUrl. If your application sends users to another origin for the reset page — an auth subdomain, say — wrap the steps on that page in cy.origin(); the link extraction is unchanged.

cypress/e2e/password-reset.cy.ts
import { linkFrom } from '../support/commands';

describe('password reset', () => {
  it('changes the password through the emailed link', () => {
    cy.freshAddress().then(address => {
      cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });

      cy.visit('/forgot-password');
      cy.get('input[name="email"]').type(address);
      cy.contains('button', 'Send reset link').click();

      cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
        cy.visit(linkFrom(message, '/reset/'));      // same origin as baseUrl: a plain visit
        cy.get('input[name="password"]').type('new-password-2');
        cy.contains('button', 'Change password').click();
      });

      cy.visit('/login');
      cy.get('input[name="email"]').type(address);
      cy.get('input[name="password"]').type('new-password-2');
      cy.contains('button', 'Log in').click();
      cy.url().should('include', '/dashboard');
    });
  });
});

Making it survive CI

Everything above works on a laptop. These are the things that only break once it runs twenty times a day on someone else’s machine.

SymptomCauseFix
Passes locally, fails in CIThe runner cannot reach the public internet, or egress is filtered.Allow grabmail.io over HTTPS from the Node side. Nothing else — no SMTP port, no inbound.
“cy.task timed out” with no word about mailtaskTimeout (60 s by default) is below the mail deadline.Set taskTimeout above the deadline in the config, and pass the per-call timeout the command already computes.
Fails first time, passes on retryYour sender queues mail and the deadline is shorter than the queue.Raise the deadline before touching anything else. Sixty seconds is a fair ceiling for a transactional mail.
429 in burstsSeveral specs polling one address, or the runner past 1200 requests a minute.One address per spec. The client ceiling is twenty mailboxes polled once a second.
Green build, broken featureA reused address served an old message.freshAddress in every test body. This is the one that matters.
Works for a week, then neverA fixture cached a message id; everything here is deleted after 5 days.Specs must trigger their own mail on every run. Nothing survives 5 days.

There is no secret to store: the public domains take no key, no account and no header. If your pipeline needs a credential to run these specs, something has been misunderstood. A GitHub Actions workflow that runs a suite like this, with the egress question settled, is in the CI guide.

If your application refuses disposable domains

Some sign-up forms check the address against the public lists of disposable domains and refuse grabmail.io on sight. That is a feature of your application, not a fault of the spec — and the fix is not to weaken the check for the test environment. Point a domain you own at this service instead: one MX record, no account, and every address on it becomes a mailbox the same task can read by changing one constant.

Turning a domain into a catch-all inbox is the setup; unlimited test accounts on one domain is what it looks like in a test suite.

Before you call it done

  • A different address for every spec, invented in the test body — never a constant.
  • The wait in a task with a wall-clock deadline; null at the deadline, never undefined.
  • taskTimeout and the command’s timeout both above the mail deadline.
  • 429 handled by sleeping out Retry-After, not by failing.
  • The code or link matched against your own wording, not a bare pattern.
  • A filter on subject or sender, so the right message wins when two arrive.
  • No assertion on how fast the mail came — only that it came.

That is the whole discipline. The extraction rules on their own, for any runner, are in OTP codes in automated tests.

Questions

Could I use cy.request in a loop instead of a task?

You can: cy.request runs from the Node side too, so it is not subject to CORS, and a recursive function that re-requests until a match or a deadline works. It is just harder to read and harder to stop than a task with a while loop in it, and the task keeps the spec free of retry logic.

Do I need an API key or a Cypress environment variable?

No. The public domains take no key, no account and no header, so there is nothing to put in cypress.env.json or in the CI secrets. Only the paid pool of domains kept off the disposable-mail blocklists uses a bearer token, and that is a separate product.

Does this work with Cypress’s test retries and parallelisation?

Yes, precisely because the address is invented inside the test body: every retry and every parallel machine gets its own mailbox. The per-client ceiling of 1200 requests a minute is twenty mailboxes polled once a second, which a Cypress run never approaches.

What if the email arrives before the task starts polling?

Nothing changes. The first poll returns it. A mailbox holds what arrives for 5 days whether or not anyone is reading, so a message that lands during the click is simply there on the next request.

Is the mailbox private while the spec uses it?

No. Anyone who knows the address can read it, on a public domain and on your own. For a random address that exists for eleven seconds and holds one throwaway code that is irrelevant; for a staging environment that sends real customer mail it is disqualifying — do not point one here.

How do I clean up afterwards?

Optionally, with a DELETE on the message from the task, which is idempotent. Everything expires after 5 days regardless, so a run that skips cleanup costs nothing — deleting only makes the next failure easier to read.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.