Cypress email testing
Cypress + Mailsac
Cypress email testing with Mailsac
Test signup codes, password reset links and magic links end to end. Your app sends a real email to a Mailsac address, your Cypress test reads it through the Mailsac API, then types the code or opens the link.
The Cypress examples on this page were run on October 19, 2026 with Cypress 16.1 and @mailsac/cypress 0.1.0 against the live Mailsac API.
In short
Install: npm install --save-dev @mailsac/cypress
Wait: cy.mailsacWaitForMessage() polls Mailsac until the email your test just triggered arrives, or times out.
Read: extractCode() returns the one-time code; extractLink() returns the reset or sign-in link.
Key: MAILSAC_API_KEY stays in Cypress’s Node process, never in the browser or your repo.
Inbox: a new address per test on your private domain, such as yourteam.msdc.co. No inbox setup needed.
What you’ll build
Cypress drives the browser, but it can’t open an inbox. Mailsac gives every test a real email address and a REST API to read what arrived. Each email test follows the same three steps:
- Trigger. The test signs up or asks for a reset with a brand-new address, and notes the time just before it clicks.
- Wait. The test polls
GET /api/addresses/{email}/messagesuntil a message with the expected subject arrives after that time, or a deadline passes. - Check. The test reads the code from the message text, or the link from the
linksMailsac extracted, then keeps going withcy.getorcy.visit.
You’ll end up with three working specs: a signup verification code (OTP), a password reset link and a magic link sign-in. They use the official @mailsac/cypress plugin. If you can’t use the plugin, there is a plain cy.request version too.
On this page
Set up Cypress and Mailsac
You need Cypress 16, Node.js 22, 24 or 26, and an app that sends real email (on your machine or in a test environment). Mailsac receives test email; it doesn’t send it.
- Get an API key. Create a free account, then generate a key under API Keys & Users in the dashboard. The key is shown once, so store it like a password. The free plan includes an API key, one private address and 1,500 operations a month.
- Install Cypress and the plugin.
Terminal
npm install --save-dev cypress @mailsac/cypress
- Put the key in an environment variable. Name it
MAILSAC_API_KEY, with noCYPRESS_prefix. The plugin reads it in Cypress’s Node process, so it never reaches the page under test, screenshots or your repository. Never hard-code it in a spec or incypress.config.js.
Terminal
# Local runs (macOS/Linux shell). In CI, use your secret store instead.
export MAILSAC_API_KEY="your-api-key"
npx cypress run
- Register the plugin’s Node task in
cypress.config.js. Merge this into your existing config and setbaseUrlto your app.
cypress.config.js
const { defineConfig } = require('cypress');
const { createMailsacTasks } = require('@mailsac/cypress/node');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000', // your app
setupNodeEvents(on, config) {
// Reads MAILSAC_API_KEY from the Node process. The key never reaches the browser.
on('task', createMailsacTasks());
return config;
},
},
});
- Add the command to your support file.
cypress/support/e2e.js
import '@mailsac/cypress/commands';
The plugin adds cy.mailsacWaitForMessage() plus two helpers, extractCode() and extractLink(). Its source is on GitHub (MIT license).
Use a new inbox for every test
You don’t create inboxes in Mailsac. Any address at mailsac.com, or at a custom domain you’ve added, receives mail as soon as something is sent to it. So give every test its own address. Then tests, retries and parallel machines never read each other’s email.
cypress/support/inbox.js
// Not a secret. Use your private Mailsac domain, for example a zero-DNS yourteam.msdc.co subdomain.
export const MAILSAC_DOMAIN = 'yourteam.msdc.co';
// A brand-new address for every test (and every retry), so tests never read each other's mail.
export function newInbox(prefix = 'test') {
return `${prefix}-${Date.now()}-${Cypress._.random(1e9)}@${MAILSAC_DOMAIN}`;
}
Set MAILSAC_DOMAIN to your private domain. On paid plans you can create a zero-setup subdomain such as yourteam.msdc.co that receives mail right away with no DNS changes, or bring your own domain. To try the examples on the free plan, use mailsac.com with made-up accounts only: public inboxes are readable by anyone.
Test a signup verification code (OTP)
This test signs up with a new address, waits for the verification email, types the six-digit code and checks that the account is verified. Change the paths, selectors, button labels and subject to match your app.
cypress/e2e/signup-code.cy.js
import { extractCode } from '@mailsac/cypress';
import { newInbox } from '../support/inbox';
describe('signup', () => {
it('verifies a new account with the emailed code', () => {
const email = newInbox('signup');
let receivedAfter;
cy.visit('/signup');
cy.get('input[name=email]').type(email);
cy.get('input[name=password]').type('Test-password-123', { log: false });
// Take the timestamp just before the click that sends the email.
cy.then(() => { receivedAfter = new Date().toISOString(); });
cy.contains('button', 'Create account').click();
cy.then(() => cy.mailsacWaitForMessage({
email,
receivedAfter,
subject: 'Your verification code',
timeoutMs: 60_000,
})).then((message) => {
const code = extractCode(message); // exactly one 6-digit code, or the test fails
cy.get('input[name=code]').type(code, { log: false });
});
cy.contains('button', 'Verify email').click();
cy.contains('h1', 'Email verified').should('be.visible');
});
});
- Take
receivedAfterjust before the click. Only mail received at or after that moment counts, so an older email in the same inbox can’t fool the test. Taken after the click, it could miss an email that arrives fast. - Wrap the wait in
cy.then(). Cypress queues commands before it runs them. Without the wrapper,receivedAfterwould still beundefinedwhen the command is queued. extractCode()wants exactly one match. By default it looks for one standalone six-digit number in the plain-text body and fails if it finds none or two different ones. For other formats pass a pattern, for exampleextractCode(message, { pattern: 'Code: ([A-Z0-9]{8})', group: 1 }).{ log: false }keeps the code out of the Cypress command log.
Test a password reset email
This test creates an account, asks for a reset, opens the emailed link, sets a new password and signs in with it.
cypress/e2e/password-reset.cy.js
import { extractLink } from '@mailsac/cypress';
import { newInbox } from '../support/inbox';
describe('password reset', () => {
it('resets the password from the emailed link and signs in', () => {
const email = newInbox('reset');
const newPassword = 'New-password-456';
let receivedAfter;
// Create the account. Use your own seed script or API here if you have one.
cy.request({ method: 'POST', url: '/signup', form: true, body: { email, password: 'Old-password-123' } });
cy.visit('/forgot-password');
cy.get('input[name=email]').type(email);
cy.then(() => { receivedAfter = new Date().toISOString(); });
cy.contains('button', 'Send reset link').click();
cy.then(() => cy.mailsacWaitForMessage({
email,
receivedAfter, // skips the signup email that arrived earlier
subject: 'Reset your password',
timeoutMs: 60_000,
})).then((message) => {
const resetUrl = extractLink(message, {
origin: new URL(Cypress.config('baseUrl')).origin,
pathname: '/reset-password', // ignores footer and help links
});
cy.visit(resetUrl, { log: false });
});
cy.get('input[name=password]').type(newPassword, { log: false });
cy.contains('button', 'Set new password').click();
cy.contains('h1', 'Password updated').should('be.visible');
cy.visit('/login');
cy.get('input[name=email]').first().type(email);
cy.get('input[name=password]').type(newPassword, { log: false });
cy.contains('button', 'Sign in').click();
cy.contains('h1', `Signed in as ${email}`).should('be.visible');
});
});
- The signup email is ignored. By the time the reset email arrives, the inbox also holds the signup email. The subject and
receivedAfterfilters skip it. extractLink()checks origin and path. It returns the one link whose origin and path match exactly, so footer links, help pages and links to other sites are ignored. It fails if there are none, or two different matching links.- Reset links are live credentials. Use a private domain for anything that would work on a real account, and keep
{ log: false }on the visit.
Test a magic link sign-in
Passwordless sign-in works the same way: request the link, wait for the email, open the link. This test also checks that the link can’t be used twice.
cypress/e2e/magic-link.cy.js
import { extractLink } from '@mailsac/cypress';
import { newInbox } from '../support/inbox';
describe('magic link', () => {
it('signs in with the emailed link, which works only once', () => {
const email = newInbox('magic');
let receivedAfter;
cy.request({ method: 'POST', url: '/signup', form: true, body: { email, password: 'Test-password-123' } });
cy.visit('/login');
cy.get('#magic-link input[name=email]').type(email);
cy.then(() => { receivedAfter = new Date().toISOString(); });
cy.contains('button', 'Email me a sign-in link').click();
cy.then(() => cy.mailsacWaitForMessage({
email,
receivedAfter,
subject: 'Your sign-in link',
timeoutMs: 60_000,
})).then((message) => {
const link = extractLink(message, {
origin: new URL(Cypress.config('baseUrl')).origin,
pathname: '/magic',
});
cy.visit(link, { log: false });
cy.contains('h1', `Signed in as ${email}`).should('be.visible');
// The same link must not work twice.
cy.request({ url: link, failOnStatusCode: false, log: false }).its('status').should('eq', 400);
});
});
});
Run all three specs with npx cypress run, or one at a time with npx cypress run --spec cypress/e2e/magic-link.cy.js.
Waiting for the email: polling and timeouts
Email isn’t instant. Don’t use a fixed cy.wait(10000): it’s too long when mail is fast and too short when it’s slow. Poll with a deadline instead. cy.mailsacWaitForMessage() does this for you:
timeoutMsis the total wait, including retries. The default is 30,000 ms and the maximum is 120,000 ms. The examples use 60 seconds. When it runs out, the test fails with a clear message instead of hanging.pollIntervalMsis the time between checks: 1,000 ms by default, at least 250 ms.- Filters:
subject(exact, case-sensitive),subjectIncludes(a substring) orfrom(the sender address). You need at least one; all you supply must match. - Retries: HTTP 429 and 5xx responses are retried within the deadline, honoring
Retry-After. A 401 or 403 fails at once. - What it returns:
{ _id, subject, received, to, from, links, text }for the newest matching message among the inbox’s latest 100.
Each check uses Mailsac operations. Every API call is one operation, and so is each email received by a private address or custom domain. A test that finds its email on the first check uses about four. A test that times out after 60 seconds of 1-second polling uses about 60, so keep deadlines sensible. See pricing for each plan’s monthly operations.
To avoid polling altogether, a private address or custom domain can push each new email to a webhook or WebSocket. Polling is simpler for most Cypress suites.
Without the plugin: plain cy.request
The plugin needs Cypress 16. On older versions, or if you’d rather not add a dependency, call the Mailsac REST API with cy.request. Every request sends your key in the Mailsac-Key header.
cypress/support/mailsac-request.js
// Read test email with cy.request only, no plugin.
// The key comes from the CYPRESS_MAILSAC_API_KEY environment variable (cy.env needs Cypress 15.10 or later).
const API = 'https://mailsac.com/api';
function mailsac(key, path, method = 'GET') {
return cy.request({ method, url: API + path, headers: { 'Mailsac-Key': key }, log: false });
}
// Poll the inbox until a message with this subject arrives after receivedAfter, then fetch its text.
export function waitForEmail(email, { subject, receivedAfter, timeoutMs = 60000, intervalMs = 2000 }) {
const inbox = encodeURIComponent(email);
const deadline = Date.now() + timeoutMs;
return cy.env(['MAILSAC_API_KEY'], { log: false }).then(({ MAILSAC_API_KEY: key }) => {
const poll = () => mailsac(key, `/addresses/${inbox}/messages?limit=20`).then(({ body }) => {
const message = body.find((m) =>
(m.subject || '').includes(subject) && Date.parse(m.received) >= Date.parse(receivedAfter));
if (message) {
return mailsac(key, `/text/${inbox}/${encodeURIComponent(message._id)}`)
.then(({ body: text }) => ({ ...message, text })); // _id, subject, links, text, ...
}
if (Date.now() > deadline) throw new Error(`No "${subject}" email for ${email} after ${timeoutMs} ms`);
return cy.wait(intervalMs, { log: false }).then(poll);
});
return poll();
});
}
export function deleteEmail(email, messageId) {
return cy.env(['MAILSAC_API_KEY'], { log: false }).then(({ MAILSAC_API_KEY: key }) =>
mailsac(key, `/addresses/${encodeURIComponent(email)}/messages/${encodeURIComponent(messageId)}`, 'DELETE'));
}
cypress/e2e/signup-code-request.cy.js
import { waitForEmail, deleteEmail } from '../support/mailsac-request';
import { newInbox } from '../support/inbox';
it('verifies a new account with cy.request only', () => {
const email = newInbox('signup');
let receivedAfter;
cy.visit('/signup');
cy.get('input[name=email]').type(email);
cy.get('input[name=password]').type('Test-password-123', { log: false });
cy.then(() => { receivedAfter = new Date().toISOString(); });
cy.contains('button', 'Create account').click();
cy.then(() => waitForEmail(email, { subject: 'Your verification code', receivedAfter }))
.then((message) => {
const code = message.text.match(/\b\d{6}\b/)[0];
cy.get('input[name=code]').type(code, { log: false });
deleteEmail(email, message._id); // optional cleanup
});
cy.contains('button', 'Verify email').click();
cy.contains('h1', 'Email verified').should('be.visible');
});
For a link instead of a code, use the links array that Mailsac adds to every message:
Reading a reset link with the cy.request helper
cy.then(() => waitForEmail(email, { subject: 'Reset your password', receivedAfter }))
.then((message) => {
const resetUrl = message.links.find((url) => url.startsWith(`${Cypress.config('baseUrl')}/reset-password`));
cy.visit(resetUrl, { log: false });
});
- Set the key as
CYPRESS_MAILSAC_API_KEYin your shell or CI secrets;cy.env()reads it. Cypress 15.9 and older don’t havecy.env(); useCypress.env('MAILSAC_API_KEY')there. Cypress 16 removedCypress.env(); calling it throws an error. - Trade-off: with this approach the key is available to your spec code. The plugin keeps it in Node only.
log: falsekeeps the header out of the command log. - Errors stop the test.
cy.requestfails on any non-2xx response, including 429. The plugin retries those.
Text, HTML and links
GET /api/text/{email}/{messageId}returns the plain-text body. If the email only has HTML, Mailsac generates the text. This is the easiest place to find a code.- Every message’s metadata, including the inbox list, has a
linksarray: the HTTP(S) links Mailsac found in the text and HTML bodies, with HTML entities such as&already decoded. GET /api/dirty/{email}/{messageId}returns the original HTML, if you need to assert on markup.GET /api/body/{email}/{messageId}returns sanitized HTML with links removed, so don’t use it to find links.
Full details are in the API reference.
Run Cypress email tests in GitHub Actions
Add the key as a repository secret: in your repository’s Settings, open Secrets and variables → Actions, choose New repository secret and name it MAILSAC_API_KEY. Then save this workflow:
.github/workflows/email-tests.yml
name: Email tests
on:
push:
branches: [main]
pull_request:
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: cypress-io/github-action@v7
with:
start: npm start # your app, configured to send real email
wait-on: 'http://localhost:3000'
env:
MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
- The official Cypress action installs dependencies, starts your app with
npm start, waits for it and runs the specs. The secret is passed as an environment variable, so the plugin finds it in Node. For thecy.requestversion, name the variableCYPRESS_MAILSAC_API_KEYinstead. - The app started in the job must send real email, through its usual provider or through Mailsac’s Email Capture SMTP service, which captures mail instead of delivering it. Captured mail is public unless you turn on private capture or capture to your private domain.
- GitHub doesn’t pass secrets to workflows triggered from forks or by Dependabot, so those runs can’t read email.
- Screenshots and videos can contain codes and reset links. Keep CI artifacts private, or turn them off for email specs.
Parallel runs and private domains
Email tests break in parallel for three reasons. Each has a simple fix.
Two tests read the same inbox
If tests share an address, one test can pick up the other’s email. Call newInbox() in every test. A timestamp alone can’t tell apart two emails sent to one inbox at the same moment. If some tests must share a fixed, pre-seeded account, don’t run them at the same time, and give each a distinct subject to match.
Mail is delayed on the shared mailsac.com domain
Public @mailsac.com addresses have lower inbound throttling limits than private domains. A busy suite, or many CI jobs at once, can see mail slow down there and tests time out. Custom domains have higher limits. Move busy suites to a private domain: a zero-DNS yourteam.msdc.co subdomain, or your own domain verified with a TXT record and MX records pointing to Mailsac. Custom domains are included from the Indie plan up.
Anyone can read public inboxes
Anyone can open a public @mailsac.com inbox on the Mailsac website without an account, and public inboxes are temporary. Use them only for made-up data. Reset links, codes and anything with real personal data belong on a private address or a custom domain, where only your account and team can read the mail. On a custom domain every address receives mail with no setup, so newInbox() works unchanged.
Parallel tests share your account’s monthly operations. Plan limits for operations, private addresses and custom domains are on the pricing page.
Cleaning up messages
With a new inbox per test, you rarely need to delete anything to keep tests correct. Each plan stores a set number of messages across your private addresses and domains, and the oldest are recycled when it fills. Delete a message when it holds something sensitive, or when you want to keep storage free.
The plugin only reads mail. To delete, add a second task to setupNodeEvents in cypress.config.js, after the Mailsac task:
cypress.config.js, inside setupNodeEvents
// Optional: delete a message after the test has used it.
on('task', {
async 'mailsac:deleteMessage'({ email, messageId }) {
const url = `https://mailsac.com/api/addresses/${encodeURIComponent(email)}/messages/${encodeURIComponent(messageId)}`;
const res = await fetch(url, { method: 'DELETE', headers: { 'Mailsac-Key': process.env.MAILSAC_API_KEY } });
if (!res.ok) throw new Error(`Mailsac delete failed: HTTP ${res.status}`);
return null;
},
});
Then call it from a test once you’ve used the message:
In a spec, inside .then((message) => { … })
cy.task('mailsac:deleteMessage', { email, messageId: message._id }, { log: false });
Each delete is one API call. To clear more at once, DELETE /api/addresses/{email}/messages empties an inbox you own, and POST /api/domains/{domain}/delete-all-domain-mail deletes all mail on a custom domain you own. Starred messages are kept when you empty an inbox. Deletes can’t be undone.
Troubleshooting
First, check what actually arrived. This lists the newest messages in an inbox (needs curl and jq):
Terminal
curl -s -H "Mailsac-Key: $MAILSAC_API_KEY" \
"https://mailsac.com/api/addresses/signup-123@yourteam.msdc.co/messages?limit=5" \
| jq '.[] | {subject, received, from: .from[0].address, links}'
“Set MAILSAC_API_KEY in the Node process”
Cypress was started without the variable. Export it in the same shell, or check the secret name in CI. Remember that it has no CYPRESS_ prefix for the plugin.
“Timed out after 60000 ms waiting for a matching email”
- Compare the address your app sent to with the one in the test.
subjectmust match exactly, including case. UsesubjectIncludesif your subject contains a name or date.- Take
receivedAfterbefore the click, and keep the test machine’s clock in sync: the time is compared with Mailsac’s received time. - Check your email provider’s logs. Some sandbox or test modes accept mail without delivering it.
- On public
@mailsac.comaddresses, throttling can delay a busy suite. Use a private domain.
“API access denied (HTTP 401)” or “(HTTP 403)”
401 means Mailsac didn’t accept the key: look for extra spaces or an old key. 403 usually means the address is private and belongs to a different Mailsac account; use a key from the account that owns the address or domain.
“expected one distinct matching link; found 0” (or 2)
Found 0: your email provider’s click tracking may have rewritten the link (turn it off for test mail), the link’s host doesn’t match baseUrl, or the token is part of the path, such as /reset/abc123, so pathname never matches. Found 2: the email contains two different links to that path. Fix the template or narrow the match.
“expected one distinct matching code; found 0” (or 2)
Found 0: the code is formatted differently, for example 482 913. Found 2: another six-digit number, such as an order number, is in the email. Pass a pattern that matches your wording.
HTTP 429
The plugin retries 429 responses until its deadline. If they keep coming, the account may have used its monthly operations; Mailsac emails a warning first. Operations reset on the first of each month (UTC).
Tests pass alone but fail in parallel
Two tests share an address or a fixed account. Use newInbox() in every test, and see parallel runs.
“Cypress.env() was removed in Cypress version 16.0.0”
Older snippets read the key with Cypress.env(), which now throws. Use the plugin, which keeps the key in Node, or cy.env() as in the cy.request helper.
FAQ
How do I test email verification in Cypress?
Sign up with a unique Mailsac address, wait for the verification email with cy.mailsacWaitForMessage() from the @mailsac/cypress plugin, then type the code with extractCode() or open the link with extractLink(). See the signup code example.
How can Cypress read an OTP code from an email?
Cypress can’t read email by itself. Send the email to a Mailsac address and read it through the Mailsac API from Cypress’s Node process. extractCode(message) returns the one six-digit code in the plain-text body, or matches your own pattern, and fails if it finds none or several.
How do I test a password reset email with Cypress?
Request the reset for a test address, wait for the email, take the link with extractLink(message, { origin, pathname: '/reset-password' }), then cy.visit() it, set a new password and sign in. See the password reset example.
How do I make Cypress wait until an email arrives?
Poll the inbox with a deadline rather than a fixed cy.wait(). cy.mailsacWaitForMessage({ email, receivedAfter, subject, timeoutMs: 60_000 }) checks every second and fails clearly if nothing matching arrives in time.
Where should I put my Mailsac API key in Cypress?
In an environment variable named MAILSAC_API_KEY, set in your shell or CI secret store. The plugin reads it in Node, so it never reaches the browser. Don’t commit it or put it in cypress.config.js.
Why do my Cypress email tests time out when they run in parallel?
Usually because tests share an inbox, or because a busy suite is throttled on the public mailsac.com domain. Use a new address per test on a private custom domain, such as a yourteam.msdc.co subdomain.
Do I need my own domain to test email in Cypress?
No. You can start with public @mailsac.com addresses on the free plan, using made-up data. For real reset links, codes or personal data, and for busy CI suites, use a private custom domain. A msdc.co subdomain needs no DNS setup.
Add email checks to your Cypress suite
Create a free account, generate an API key and run the signup test above. Plans with a private test domain start at $18 a month.
Using Playwright? Read Playwright email testing. New to Mailsac? See how the email testing API works.