blog
Cypress Email Testing and Email Verification for OTP, Password Reset, and Signup Flows
Learn how to do Cypress email testing and Cypress email verification with real inboxes so you can verify account confirmation, password resets, OTP codes, and transactional messages without brittle polling hacks.
A signup test is unfinished when the app says "email sent." Receive the verification message, use its link or code, and check that the intended account is verified.
Use a private inbox for each test so parallel signups and resends do not share message state. Keep the message wait, browser continuation and cleanup within the same lifecycle.
Quick answer
Cypress email testing should let you control the message layer inside the same test run:
- generate a private disposable inbox for each test or run
- trigger an application flow that sends a real email
- wait for the matching message with bounded timeouts
- assert subject, sender, links, codes, and content
- continue the browser test using the extracted artifact
If your workflow still relies on a shared mailbox or a local SMTP viewer that does not behave like production, you are not testing the full path.
Use the email testing API for programmable inbox and wait controls, Email Sandbox for safe staging capture, and the official Cypress plugin example for the complete cypress-mailslurp setup. Use Authentication testing when email and SMS are part of login or recovery, and Phone number and SMS service when Cypress needs to complete SMS verification too.
How do I read email in Cypress?
MailSlurp gives your test a private email address and an API for waiting for the message sent to it. You can use the official cypress-mailslurp plugin through cy.mailslurp(), or keep the standard MailSlurp client in Cypress's Node process and call it through cy.task().
The complete password-reset example uses Node tasks. It includes a small local app, inbox creation, message matching, two browser tests and cleanup. Set your test account's API key, install the pinned dependencies and follow the README. The plugin guide covers the cy.mailslurp() setup if you prefer that approach.
In the example, this helper submits the reset form and waits for the message belonging to that request:
function submitAndReceive(context: ResetCase, requestId: string) {
cy.get('input[name="email"]').type(context.emailAddress, { log: false });
return cy.then(() => {
const since = new Date().toISOString();
cy.contains('button', 'Send reset email').click();
cy.contains('h1', 'Reset email sent').should('be.visible');
return cy.task<ReceivedReset>(
'waitForReset',
{ id: context.id, since, requestId },
{ log: false },
);
});
}
ResetCase, ReceivedReset and the waitForReset task are defined in the download. The task uses a 60-second MailSlurp wait inside a 75-second Cypress task timeout. It matches the request ID in the subject, uses the request timestamp, checks the sender and recipient, and validates the reset link's origin and path before returning it to the browser. API credentials stay in the Node process.
For your application, replace the local app with your staging URL, selectors and actual sending route. Keep the message wait close to the action that sends it. A subject match is useful, but a subject such as "Password reset" can describe several different requests.
Cypress email testing for password resets
Receiving a reset email is only part of the test. Follow its link, set a new password, then sign in and check the exact account email. A generic success message could otherwise hide a test that reached the wrong account.
The downloadable example exercises two cases:
- Successful reset: receive the current email, change the password, reject the original password, sign in with the new one and confirm the intended account. Reusing the consumed link returns an error.
- Resend with an older email still present: show that a loose latest-message read finds the old email while a wait for the new request times out. Send the new request, confirm that the earlier link is invalid and complete the reset with the new link.
The download tests the included local application with real MailSlurp messages. Its reset policy and selectors are examples to adapt to your own application; message receipt and inbox placement are separate checks.
The example invalidates the previous link after a resend. Use the policy your application actually promises, and add a separate expiry case for its token lifetime. Keep each test's inbox private to that scenario and run cleanup after failures too.
Cypress email testing for OTP and signup verification
For an email code, the browser should submit the code from this signup's message and check the resulting account. Keep the API client in Node so the API key stays outside the browser. The following example expects your application to have a signup page, an email-code form and an element displaying the signed-in account email. Replace its URL, selectors and subject with your application's actual contract.
Install cypress and mailslurp-client, then add these tasks to cypress.config.cjs. Merge them into setupNodeEvents if your project already has one. Set MAILSLURP_API_KEY in the Node environment and CYPRESS_BASE_URL to your test application's origin.
const { defineConfig } = require("cypress");
const { MailSlurp } = require("mailslurp-client");
module.exports = defineConfig({
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL,
setupNodeEvents(on, config) {
const apiKey = process.env.MAILSLURP_API_KEY;
if (!apiKey) throw new Error("Set MAILSLURP_API_KEY");
const mail = new MailSlurp({ apiKey });
async function receive({ inboxId, address, since, subject }) {
const email = await mail.waitController.waitForMatchingFirstEmail({
inboxId, since: new Date(since), timeout: 60_000, unreadOnly: false,
matchOptions: {
matches: [{ field: "SUBJECT", should: "CONTAIN", value: subject }],
},
});
if (!email.to.includes(address)) throw new Error("Unexpected email recipient");
return email;
}
on("task", {
createTestInbox: () => mail.createInboxWithOptions({ expiresIn: 600_000 }),
async receiveOtp(input) {
const email = await receive(input);
const result = await mail.emailController.getEmailCodes({
emailId: email.id,
extractCodesOptions: {
method: "PATTERN", allowFallback: false,
minLength: 6, maxLength: 6, maxCandidates: 5,
},
});
const candidates = new Set(result.candidates.map(item => item.code));
if (!result.found || typeof result.code !== "string" ||
!/^[0-9]{6}$/.test(result.code) || candidates.size !== 1 ||
!candidates.has(result.code)) {
throw new Error("Expected one six-digit verification code");
}
return result.code;
},
async receiveLoginLink(input) {
const email = await receive(input);
const { links } = await mail.emailController.getEmailLinks({ emailId: email.id });
const allowed = [...new Set(links)].filter(link => {
try {
const url = new URL(link);
return url.origin === input.origin && url.pathname === input.path;
} catch { return false; }
});
if (allowed.length !== 1) throw new Error("Expected one application login URL");
return allowed[0];
},
async deleteTestInbox(inboxId) {
await mail.inboxController.deleteInbox({ inboxId });
return null;
},
});
return config;
},
},
});
The spec owns the inbox through afterEach, including when an assertion fails. Its task budget includes the email wait and code extraction; the code remains text so leading zeros survive.
describe("signup email code", () => {
let inbox;
beforeEach(() => {
inbox = undefined;
cy.task("createTestInbox", null, { log: false }).then(value => { inbox = value; });
});
afterEach(() => {
if (inbox) cy.task("deleteTestInbox", inbox.id, { log: false });
});
it("verifies the intended account", () => {
cy.visit("/signup");
cy.get('input[name="email"]').type(inbox.emailAddress, { log: false });
cy.then(() => {
const since = new Date().toISOString();
cy.contains("button", "Create account").click();
return cy.task("receiveOtp", {
inboxId: inbox.id, address: inbox.emailAddress,
since, subject: "Verify your email",
}, { timeout: 90_000, log: false });
}).then(code => {
cy.get('input[name="code"]').type(code, { log: false });
cy.contains("button", "Verify code").click();
cy.get('[data-testid="signed-in-email"]').should("have.text", inbox.emailAddress);
});
});
});
Cypress commands are queued. Return the SDK promise from a Node task and chain the result; do not use await cy.task(...) or read an inbox variable before the command that creates it has finished. The plugin documentation covers the alternative cy.mailslurp() command. Keep OTPs, passwords and login URLs out of command logs and shared test artifacts.
Follow a magic link in the same browser journey
For passwordless login, use the same isolated inbox lifecycle. Replace the code task with receiveLoginLink, passing the application's exact origin and callback path. This continuation belongs after you fill the login form; capture since immediately before its send button:
cy.then(() => {
const since = new Date().toISOString();
cy.contains("button", "Email me a login link").click();
return cy.task("receiveLoginLink", {
inboxId: inbox.id, address: inbox.emailAddress, since,
subject: "Sign in", origin: Cypress.config("baseUrl"), path: "/auth/verify",
}, { timeout: 90_000, log: false });
}).then(link => {
cy.visit({ url: link, log: false });
cy.get('[data-testid="signed-in-email"]').should("have.text", inbox.emailAddress);
});
Use an origin without a trailing path for baseUrl. Do not first call the login URL through cy.request: that can consume a one-use token before the browser visits it. Check expired and consumed links in separate tests, using your application's policy and clean session state. For codes, add incorrect-code, expiry and resend tests from the OTP testing guide.
Plugin vs non-plugin Cypress email testing
There are two broad styles:
Plugin-led flow
Use the official cypress-mailslurp package when you want cy.mailslurp() and Cypress-aware chaining. The plugin returns the standard MailSlurp client, so inbox, wait-controller, message, and cleanup APIs remain available without inventing a polling helper.
API-client-led flow
Use mailslurp-client in setupNodeEvents when you want inbox creation, message waits and cleanup to run in Node. The downloadable reset example uses cy.task() to connect those operations to the browser test.
For practical setup paths, see:
- Cypress email testing guide
- In-depth Cypress tutorial
- Cypress plugin example
- Cypress implementation example
How to reduce flaky Cypress email tests
The biggest improvements usually come from:
- unique inboxes per scenario
- exact message matching by recipient plus subject or timestamp
- a bounded API wait and a Cypress timeout long enough to contain it
- explicit parsing of links or codes instead of broad regex against unrelated content
- keeping assertions close to the user action that caused the email
When a wait times out, check whether the app sent to the address created for that test and whether the subject or request ID changed. Increasing every timeout can hide a mismatched message filter.
Extend the same test to SMS and message appearance
If the user also verifies a phone number, keep the SMS step in the same account journey. The Cypress SMS guide explains how to receive the text and continue verification. Reserve the assigned number for that test through its final assertion.
For the email itself, use device previews to review the delivered template on supported clients, and Email Audit to check content and links. A working token and a readable verification button are separate parts of a successful signup.
The test-address guide covers inbox choice, isolation and cleanup for the next scenario you add.
FAQ
How do I test emails in Cypress?
Create a unique inbox, trigger the app flow, wait for the matching email, extract the link or code, and continue the Cypress test with that artifact.
How do I test email verification in Cypress?
Create an inbox through the MailSlurp API or Cypress plugin, use that address in the signup flow, wait for the confirmation email, extract the verification URL or code, continue the Cypress test, and assert the verified account state.
What is the main difference between Cypress email testing and Playwright email testing?
The email pattern is similar. The main difference is framework choice, browser model, and how your team structures end-to-end testing.
Do I need a plugin for Cypress email testing?
No. The official cypress-mailslurp plugin provides convenient cy.mailslurp() chaining, while the standard mailslurp-client package works well for direct API access or custom commands.
Is there a Cypress plugin for testing received email?
Yes. MailSlurp maintains the official cypress-mailslurp package. It exposes the standard MailSlurp client through cy.mailslurp(), so a Cypress test can create an inbox, wait for a subject-matched email, inspect its content, and delete the inbox during cleanup.
Can Cypress test OTP emails too?
Yes. Cypress can validate OTP, magic-link, and password reset flows when paired with real inbox control.
Can Cypress test SMS OTP too?
Yes. Pair Cypress with MailSlurp phone numbers when the flow sends an SMS code instead of an email code. The test can wait for the inbound text, extract the OTP, and continue the same authentication path.
