• Home

How to Test Emails in Cypress

Cypress Email Testing with Mailinator

Reading time: 10 min | Topics: Cypress email testing, QA automation, Mailinator API, custom Cypress commands, OTP verification, private email domains

Cypress is exceptional at testing everything a user sees in a browser. Email is the one workflow it consistently can’t reach on its own. Registration confirmations, OTP codes, password resets, and magic links all land in an inbox that sits entirely outside Cypress’s control — and most teams work around this by either skipping email assertions entirely or manually spot-checking in staging.

This guide shows you how to close that gap. By wiring Mailinator’s REST API into Cypress via a custom command and cy.task(), you can assert on email delivery, content, and links as naturally as you assert on DOM elements — with full test isolation and no real inboxes involved.

Why Is Email Testing Hard to Do in Cypress?

Cypress runs entirely in the browser sandbox, which means it has no native mechanism for checking what arrived in an external inbox. Unlike a REST API response or a DOM element, an email is delivered asynchronously to a third-party server that Cypress has no visibility into.

The common workarounds each come with serious trade-offs:

WorkaroundProblem
Skip email assertions entirelyEmail regressions ship silently to production
Check a shared staging inbox manuallyNot automated; messages from other runs pollute results
Mock the email sending serviceProves the call was made, not that the email was actually delivered and correct
IMAP client in a cy.task()Heavy setup; requires a real mailbox with credentials

The right answer is a REST-accessible test inbox: something Cypress can query from a cy.task() call, get a clean JSON response, and assert against — without IMAP, browser tabs, or shared state between runs. That’s exactly what Mailinator’s API provides.

What Is the Best Way to Test Emails in Cypress?

The cleanest pattern for Cypress email testing is a three-layer approach:

  1. Cypress test — triggers your app’s email-sending flow (signup, login, password reset)
  2. cy.task() plugin — calls the Mailinator API from Node.js context (outside the browser sandbox) and returns the email data
  3. Mailinator private inbox — receives the test email and holds it until your task polls for it

This keeps your test code readable (no API boilerplate in spec files) while solving the sandbox problem. A cy.task() call runs in Node.js, which can make arbitrary HTTP requests — including to the Mailinator REST API.

How Do You Set Up Mailinator for Cypress Email Testing?

Step 1: Use a Private Domain

⚠️ Important: Public @mailinator.com inboxes are readable by anyone on the internet. Never use them in automated tests — emails containing tokens, OTPs, or reset links can be intercepted. Mailinator’s Verified Pro and Business plans include private domains (e.g. @yourcompany.testinator.com) that are only accessible with your API key. This is the only safe configuration for test automation.

Step 2: Store Credentials in cypress.env.json

{
  "MAILINATOR_API_KEY": "your_api_key_here",
  "MAILINATOR_DOMAIN": "yourcompany.testinator.com"
}

Add cypress.env.json to your .gitignore. In CI/CD, inject these as environment variables — Cypress will read them automatically when prefixed with CYPRESS_ (e.g. CYPRESS_MAILINATOR_API_KEY).

Step 3: Generate Unique Inbox Names Per Test

To prevent test pollution when specs run in parallel or in rapid succession, generate a unique inbox for each test run:

// In your spec file
const runId = Date.now();
const testInbox = `cypress-signup-${runId}`;
const testEmail = `${testInbox}@${Cypress.env('MAILINATOR_DOMAIN')}`;

How Do You Create a Custom Cypress Command for Email Polling?

Rather than repeating API polling logic in every spec, encapsulate it in a reusable cy.task() defined in cypress/plugins/index.js (Cypress 9) or cypress.config.js (Cypress 10+), then expose it through a custom command.

1. Add the task in cypress.config.js:

const axios = require('axios');

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      on('task', {
        async waitForEmail({ inbox, domain, apiKey, maxWaitMs = 30000 }) {
          const pollInterval = 2000;
          const deadline = Date.now() + maxWaitMs;

          while (Date.now() < deadline) {
            const res = await axios.get(
              `https://api.mailinator.com/api/v2/domains/${domain}/inboxes/${inbox}`,
              { headers: { Authorization: apiKey } }
            );
            const msgs = res.data.msgs;
            if (msgs && msgs.length > 0) return msgs[0];
            await new Promise(r => setTimeout(r, pollInterval));
          }
          throw new Error(`No email arrived in inbox "${inbox}" within ${maxWaitMs}ms`);
        },

        async getEmailBody({ messageId, apiKey }) {
          const res = await axios.get(
            `https://api.mailinator.com/api/v2/message/${messageId}`,
            { headers: { Authorization: apiKey } }
          );
          return res.data.data.parts[0].body;
        }
      });
    }
  }
});

2. Add a custom command in cypress/support/commands.js:

Cypress.Commands.add('getLatestEmail', (inbox) => {
  return cy.task('waitForEmail', {
    inbox,
    domain: Cypress.env('MAILINATOR_DOMAIN'),
    apiKey: Cypress.env('MAILINATOR_API_KEY'),
  });
});

Cypress.Commands.add('getEmailBody', (messageId) => {
  return cy.task('getEmailBody', {
    messageId,
    apiKey: Cypress.env('MAILINATOR_API_KEY'),
  });
});

Your spec files can now read as cleanly as any other Cypress test.

How Do You Test a Signup Confirmation Email with Cypress?

Here’s a complete end-to-end spec that signs up a new user, waits for the confirmation email, and asserts on its subject and body content:

describe('Signup email confirmation', () => {
  it('sends a confirmation email with the correct subject and activation link', () => {
    const runId = Date.now();
    const inbox = `cypress-signup-${runId}`;
    const email = `${inbox}@${Cypress.env('MAILINATOR_DOMAIN')}`;

    // 1. Trigger the signup flow
    cy.visit('/signup');
    cy.get('[data-cy=email]').type(email);
    cy.get('[data-cy=password]').type('TestPass123!');
    cy.get('[data-cy=submit]').click();
    cy.contains('Check your email to confirm your account').should('be.visible');

    // 2. Fetch the email
    cy.getLatestEmail(inbox).then((message) => {
      expect(message.subject).to.equal('Confirm your email address');
      expect(message.from).to.include('noreply@yourapp.com');

      // 3. Fetch the body and assert on the activation link
      cy.getEmailBody(message.id).then((body) => {
        expect(body).to.include('activate?token=');
        expect(body).to.include(email); // personalization check
      });
    });
  });
});

This test does in one spec what usually takes five minutes of manual checking in staging — and it runs on every push.

How Do You Test OTP Flows in Cypress?

OTP testing is the highest-stakes email scenario in end-to-end test suites. A broken OTP delivery silently blocks every new login or 2FA enrollment — and without an automated assertion, it can stay broken for days.

The pattern: trigger the OTP send, extract the code from the email body with a regex, then submit it back through the UI.

describe('OTP login flow', () => {
  it('completes login with a valid OTP from email', () => {
    const runId = Date.now();
    const inbox = `cypress-otp-${runId}`;
    const email = `${inbox}@${Cypress.env('MAILINATOR_DOMAIN')}`;

    // Trigger OTP send
    cy.visit('/login');
    cy.get('[data-cy=email]').type(email);
    cy.get('[data-cy=send-otp]').click();

    // Retrieve the OTP
    cy.getLatestEmail(inbox).then((message) => {
      cy.getEmailBody(message.id).then((body) => {
        const match = body.match(/\b(\d{6})\b/);
        expect(match, 'OTP code found in email body').to.not.be.null;
        const otp = match[1];

        // Submit the OTP in the UI
        cy.get('[data-cy=otp-input]').type(otp);
        cy.get('[data-cy=verify-otp]').click();
        cy.url().should('include', '/dashboard');
      });
    });
  });
});

This works for any numeric OTP pattern. For alphanumeric tokens, adjust the regex accordingly (e.g. /token=([A-Za-z0-9]+)/).

For magic links and activation buttons, extract the URL from the email body and visit it directly with cy.visit() — no clicking through the email client required.

cy.getLatestEmail(inbox).then((message) => {
  cy.getEmailBody(message.id).then((body) => {
    // Extract the magic link from HTML body
    const linkMatch = body.match(
      /href=["'](https:\/\/yourapp\.com\/activate\?token=[^"']+)["']/
    );
    expect(linkMatch, 'Activation link present in email').to.not.be.null;

    const activationUrl = linkMatch[1];
    cy.visit(activationUrl);
    cy.url().should('include', '/welcome');
    cy.contains('Your account is confirmed').should('be.visible');
  });
});

Extend this test to verify expiry behavior: visit the same link a second time and assert your app returns the expected error response.

What Should Your Cypress Email Tests Actually Assert?

Asserting only that an email arrived is the most common email testing mistake. A delivered email with the wrong subject line, broken template rendering, or a missing token can still create a broken user experience. Here’s what a thorough email test covers:

AssertionWhat to CheckWhy It Matters
DeliveryEmail arrives within 30sConfirms the send pipeline is working
SubjectExact string or pattern matchCatches template regressions
Senderfrom address matches expectedGuards against misconfigured ESP settings
PersonalizationUser’s name or email in bodyCatches broken template variable substitution
Token / OTPCode is present and well-formedEnsures authentication flows will work
Link targetsCTA links point to correct URLsCatches environment config mistakes (staging vs. prod URLs)
Link expirySecond use of magic link failsSecurity regression check

What Are Common Mistakes When Testing Emails in Cypress?

MistakeWhy It HurtsFix
Using public @mailinator.com inboxesSecurity risk; no test isolationUse a private domain on a paid plan
Hardcoding inbox namesParallel test runs collide on the same inboxAppend Date.now() or run ID to inbox name
No polling / retry logicEmail arrives slightly late; test fails intermittentlyPoll with a timeout (30s covers most delivery scenarios)
Asserting only on deliveryBroken templates and wrong tokens ship silentlyAssert on subject, body, token format, and link URLs
Making API calls directly in spec filesVerbose, hard to maintain, leaks secrets into test codeEncapsulate in cy.task() and a custom command
Testing against a real user inboxReal users receive test emails; PII riskAlways test against Mailinator private domain addresses

Quick Reference: Mailinator API Endpoints for Cypress

EndpointPurpose
GET /api/v2/domains/{domain}/inboxes/{inbox}List messages in a private inbox
GET /api/v2/message/{id}Fetch full message content (subject, from, body)
DELETE /api/v2/message/{id}Clean up a message after a test
GET /api/v2/domains/{domain}/inboxes/{inbox}/linksExtract all links from messages in an inbox

Start Testing Emails in Cypress Today

With Mailinator’s REST API and a private domain, adding email assertions to your Cypress test suite is a one-afternoon project — not a week-long infrastructure effort. Every signup, OTP, and magic link in your app can be covered the same way you cover button clicks and form submissions.

Leave a comment

Your email address will not be published. Required fields are marked *