mailsac

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:

  1. Trigger. The test signs up or asks for a reset with a brand-new address, and notes the time just before it clicks.
  2. Wait. The test polls GET /api/addresses/{email}/messages until a message with the expected subject arrives after that time, or a deadline passes.
  3. Check. The test reads the code from the message text, or the link from the links Mailsac extracted, then keeps going with cy.get or cy.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.

  1. Get an API key. , 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.
  2. Install Cypress and the plugin.

Terminal

npm install --save-dev cypress @mailsac/cypress
  1. Put the key in an environment variable. Name it MAILSAC_API_KEY, with no CYPRESS_ 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 in cypress.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
  1. Register the plugin’s Node task in cypress.config.js. Merge this into your existing config and set baseUrl to 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;
    },
  },
});
  1. 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 receivedAfter just 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, receivedAfter would still be undefined when 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 example extractCode(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 receivedAfter filters 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.

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:

  • timeoutMs is 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.
  • pollIntervalMs is the time between checks: 1,000 ms by default, at least 250 ms.
  • Filters: subject (exact, case-sensitive), subjectIncludes (a substring) or from (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_KEY in your shell or CI secrets; cy.env() reads it. Cypress 15.9 and older don’t have cy.env(); use Cypress.env('MAILSAC_API_KEY') there. Cypress 16 removed Cypress.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: false keeps the header out of the command log.
  • Errors stop the test. cy.request fails 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 links array: 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 the cy.request version, name the variable CYPRESS_MAILSAC_API_KEY instead.
  • 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.
  • subject must match exactly, including case. Use subjectIncludes if your subject contains a name or date.
  • Take receivedAfter before 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.com addresses, 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.