Guides / Cypress email verification
Testing email verification in Cypress
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
npm i -D cypress inboxsink- Create a free account and an API key (1,000 calls a month, no card).
- Expose it as
INBOXSINK_API_KEYin the environment that runscypress 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.
Magic links
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.
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
- Testing email verification in Playwright
A Playwright test for a signup that emails a 6-digit code, run against a real mail server: one inbox per test, no waitForTimeout, magic links and CI included.
- Reading an emailed OTP in Selenium with Python
Read an emailed OTP in a Selenium test with Python and pytest: an inbox per test as a fixture, one HTTP call that waits for the email. Run against a real mail server.