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:
| Workaround | Problem |
|---|---|
| Skip email assertions entirely | Email regressions ship silently to production |
| Check a shared staging inbox manually | Not automated; messages from other runs pollute results |
| Mock the email sending service | Proves 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:
- Cypress test — triggers your app’s email-sending flow (signup, login, password reset)
cy.task()plugin — calls the Mailinator API from Node.js context (outside the browser sandbox) and returns the email data- 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]+)/).
How Do You Test Magic Links and Email CTAs in Cypress?
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:
| Assertion | What to Check | Why It Matters |
|---|---|---|
| Delivery | Email arrives within 30s | Confirms the send pipeline is working |
| Subject | Exact string or pattern match | Catches template regressions |
| Sender | from address matches expected | Guards against misconfigured ESP settings |
| Personalization | User’s name or email in body | Catches broken template variable substitution |
| Token / OTP | Code is present and well-formed | Ensures authentication flows will work |
| Link targets | CTA links point to correct URLs | Catches environment config mistakes (staging vs. prod URLs) |
| Link expiry | Second use of magic link fails | Security regression check |
What Are Common Mistakes When Testing Emails in Cypress?
| Mistake | Why It Hurts | Fix |
|---|---|---|
Using public @mailinator.com inboxes | Security risk; no test isolation | Use a private domain on a paid plan |
| Hardcoding inbox names | Parallel test runs collide on the same inbox | Append Date.now() or run ID to inbox name |
| No polling / retry logic | Email arrives slightly late; test fails intermittently | Poll with a timeout (30s covers most delivery scenarios) |
| Asserting only on delivery | Broken templates and wrong tokens ship silently | Assert on subject, body, token format, and link URLs |
| Making API calls directly in spec files | Verbose, hard to maintain, leaks secrets into test code | Encapsulate in cy.task() and a custom command |
| Testing against a real user inbox | Real users receive test emails; PII risk | Always test against Mailinator private domain addresses |
Quick Reference: Mailinator API Endpoints for Cypress
| Endpoint | Purpose |
|---|---|
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}/links | Extract 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.