Guides / Cypress email verification

Testing email verification in Cypress

Tested 13 Sep 2026 · Cypress 14.5 · Node 23 · inboxsink 0.1.0

A Cypress spec runs inside the browser, so the test can't simply await a Node library while it waits for an email. The wait has to go through cy.task, which runs in the Node process next to Cypress. That turns out to be the right place anyway: the API key stays in Node and never reaches browser-side code.

Below is the setup we ran on 13 September 2026: a small signup app sending real email through Mailjet, one inbox per test, a code and a magic link read without any cy.wait.

What the run looked like

otp 56b9d0e9085d@mailhusk.com code=032534 after 2554 ms
    ✓ a new user can sign up with an emailed code (3757ms)
magic link http://localhost:4174/magic-link?token=223c33dc00bbf38b3769996843d489ba
    ✓ a user can sign in with a magic link (1985ms)

  2 passing (6s)

Output trimmed to the test lines. The code arrived 2.6 seconds after the click, sending included. Note the code itself: 032534. It starts with a zero, which is why it comes back as a string. Pass it to cy.type as it is; Number(code) would type 32534 and the test would fail for a reason that has nothing to do with your app.

Setup

  1. npm i -D cypress inboxsink
  2. Create a free account and an API key (1,000 calls a month, no card).
  3. Expose it as INBOXSINK_API_KEY in the environment that runs cypress run.

The tasks, in cypress.config.js

import { defineConfig } from 'cypress';
import { InboxSink } from 'inboxsink';

const sink = new InboxSink(); // reads INBOXSINK_API_KEY

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4174',
    setupNodeEvents(on) {
      on('task', {
        createInbox: () => sink.createInbox({ ttlSeconds: 900 }),
        waitForOtp: (id) => sink.waitForOtp(id, { timeoutMs: 60_000 }),
        waitForLink: (id) => sink.waitForLink(id, { timeoutMs: 60_000 }),
        deleteInbox: (id) => sink.deleteInbox(id).then(() => null),
      });
    },
  },
});

Two details. Each task returns a promise, and Cypress waits for it before running the next command. And deleteInbox ends with .then(() => null) because Cypress rejects a task that resolves to undefined.

cy.request straight to the HTTP API would also work, but the key would then live in browser-side test code. With tasks, the spec only ever sees an address, a code or a link.

Signup with a 6-digit code

it('a new user can sign up with an emailed code', () => {
  cy.task('createInbox').then((inbox) => {
    cy.visit('/signup');
    cy.get('input[name="email"]').type(inbox.address);
    cy.contains('button', 'Create account').click();

    cy.task('waitForOtp', inbox.id, { timeout: 90_000 }).then((code) => {
      cy.get('input[name="code"]').type(code);
      cy.contains('button', 'Verify').click();
      cy.contains('h1', 'Welcome aboard');
      cy.task('deleteInbox', inbox.id);
    });
  });
});

createInbox gives this test an address no other spec uses, so specs can run in parallel on several machines without reading each other's codes. The 15-minute ttlSeconds cleans up after a spec that crashed before deleteInbox.

Give the task more time than the wait

That { timeout: 90_000 } on the task is not decoration. Cypress stops waiting for a task after 60 seconds by default (taskTimeout). The task above waits up to 60 seconds for the email, plus a few seconds of margin in the client. We ran it with no email sent and the default setting. Cypress gave up first:

CypressError: `cy.task('waitForOtp')` timed out after waiting `60000ms`.

That message sends you looking at Cypress. With the timeout raised, the same situation ends with No message arrived before the timeout from the client, which points at the email. Raise it per call as above, or for the whole suite with taskTimeout: 90_000 in the e2e block.

it('a user can sign in with a magic link', () => {
  cy.task('createInbox').then((inbox) => {
    cy.visit('/login');
    cy.get('input[name="email"]').type(inbox.address);
    cy.contains('button', 'Email me a sign-in link').click();

    cy.task('waitForLink', inbox.id, { timeout: 90_000 }).then((link) => {
      cy.visit(link);
      cy.contains('h1', `Signed in as ${inbox.address}`);
      cy.task('deleteInbox', inbox.id);
    });
  });
});

The link is only extracted when its URL contains a word like confirm, verify, reset, magic or login. A short link such as /t/<token>, or one rewritten by click tracking, comes back empty and waitForLink throws. The fallback (read the message body and match the URL yourself) is shown in the Playwright guide; in Cypress it goes in a task in the same way.

In CI

- name: Cypress
  run: npx cypress run
  env:
    INBOXSINK_API_KEY: ${{ secrets.INBOXSINK_API_KEY }}

The two failures that only appear in CI — a mail worker that was never started, and a signup that refuses disposable domains — are the same as with Playwright and are covered in that guide. The rules for when a code is detected are there too, under When the code is not detected.

Free API key

1,000 calls a month, no card asked. Enough to wire it into your test suite and see if it holds.

Then, when your tests start getting refused: a domain of your own is published nowhere, so it lands on no blocklist — 29 € a month.

Other guides