How to Test Email Sending with Cypress
By Denis · Published 2026-09-15.
Cypress specs read best when the email step looks like any other command. This is how to wrap the inbox API into cy.waitForEmail, and how to keep it deterministic under retries and parallel runs.
One task, one command
The polling loop lives in a cy.task (Node side, from the integration guide). Wrap it in a custom command so specs never touch plumbing:
// cypress/support/commands.mjs
Cypress.Commands.add("waitForEmail", (address, subjectRe, timeoutMs = 45000) => {
cy.task("tempxWait", { address, subjectRe, timeoutMs })
.should("not.be.null")
.as("lastEmail");
return cy.get("@lastEmail");
});
And a spec now says what it means:
const addr = "cy-" + Date.now().toString(36) + "-login@tempx.uk";
cy.visit("/login");
cy.get('input[name="email"]').type(addr);
cy.contains("button", "Send code").click();
cy.waitForEmail(addr, "login code").then((msg) => {
const code = (msg.preview.match(/code is (\d{6})/) || [])[1];
cy.get('input[name="code"]').type(code);
cy.contains("button", "Sign in").click();
cy.contains("Welcome back").should("be.visible");
});
Retries: know which loop you are in
Cypress retries commands until their assertions pass, but cy.task is not retried internally; it resolves once. That is why the task itself polls until deadline and resolves null on timeout: the .should("not.be.null") turns a missing email into a readable failure, and test-level retries start clean because each test mints a fresh address.
Parallel runs
- Address uniqueness is the whole game. Timestamp plus test key works; with
cypress-parallelor Dashboard parallelization, add the worker index. - Don't count messages. Assert via
.find()-style matching on subject/sender, never "inbox has exactly 1 message", which parallel runs and resends will break. - Rate limits. 240 reads/minute; the task's 2-second poll has huge headroom, but cap concurrent polling tasks if you shard heavily.
Debugging a failed email step
Open the address in the Tempx app: mail present means the matching or extraction failed (the MIME decoder shows exactly what text the mail carried); no mail means the send side never fired. For header-level surprises, paste the source into the header analyzer.
The starter cy.task config is in the Cypress integration; Playwright and Selenium have equivalents.
What to assert inside the message
"An email arrived" proves the pipeline; the bugs live in the payload. Three assertions earn their place in every email spec. First, the link host: extract the primary URL from the message and check it against the expected origin, verification links pointing at localhost or at production from a staging build are the classic environment leak, and it is invisible unless you read the actual URL. Second, the code format: match the pattern (\d{6}, or the sender's variant) rather than any digit run, or a phone number in the footer will satisfy your regex. Third, the plain-text part: if your transactional templates render HTML-only, text-only clients get nothing, and msg.preview going empty is your early warning.
cy.waitForEmail(addr, "reset").then((msg) => {
const link = (msg.preview.match(/https:\/\/[^\s]+/g) || []).find(u => u.includes("reset"));
expect(new URL(link).host).to.eq("app.example.com"); // not localhost
expect(msg.preview).to.match(/\b\d{6}\b/); // the code, not any digits
});
Timing is the quiet fourth assertion. If the code your app sends expires in five minutes, a spec that tolerates a ninety-second arrival keeps delivery regressions visible without flaking on normal variance. The task returns arrival metadata alongside the preview, assert on it in the specs where timing is contractual, and ignore it elsewhere.
A second spec: the reset-password loop
The login spec covers the happy path; the reset spec is where real bugs hide, and it is barely longer. Request a reset, wait for the mail, then request a second one and try the first link, the assertion is that it fails:
const addr = "cy-" + Date.now().toString(36) + "-reset@tempx.uk";
cy.request("POST", "/api/reset", { email: addr });
cy.waitForEmail(addr, "reset your password");
cy.request("POST", "/api/reset", { email: addr }); // second request
cy.waitForEmail(addr, "reset your password").then((second) => {
const firstLink = /* captured from the first wait */;
cy.request({ url: firstLink, followRedirect: false, failOnStatusCode: false })
.its("status").should("satisfy", (s) => s === 401 || s === 410);
});
The same skeleton covers duplicate signup ("you already have an account" versus a silently created second account), changed-email confirmation, and login-from-new-device notices. One mailbox per scenario keeps the regexes honest; with per-test addresses minted from the timestamp, retries rerun the whole scenario against a clean inbox instead of inheriting stale mail.
Running this in CI without regrets
Email specs are slower than DOM specs, so give them their own lane: tag them, exclude them from the every-commit run, and execute them in a scheduled suite (pre-release or nightly) where a ninety-second wait costs nothing. Inside that lane, two disciplines keep the suite green: one fresh address per spec file (never a shared constant, or parallel workers read each other's codes) and a cap on concurrent polling tasks when sharding heavily, since the read limit is 240 requests per minute and a wide fan-out with two-second polls can approach it.
On a failed scheduled run, resist rerunning it locally with the same address; the inbox has likely expired along with its mail. The failure report should carry the address and the subject regex it waited for, with those two, opening the address in the web app or hitting the same read endpoint manually reproduces the state in seconds, which is often enough to tell "never sent" from "sent, matched wrong" from "sent, arrived late".
The visual half: what to assert about rendered mail
Cypress lives in the DOM, which tempts teams toward screenshotting the mail body, resist it for anything except the one or two templates where markup itself is the contract. The stable assertions are structural: the text part contains the code (it is what the API returns), the primary link's host is right (parse it, do not click it in the app), and the sender matches. For a visual regression that matters, capture the sanitized render from the inbox app in a dedicated spec with a fixed viewport, and keep that spec out of the parallel lane, screenshots and workers multiply flakiness faster than they multiply coverage.
For the "what did we construct" questions (encodings, part ordering, attachment naming) the MIME decoder is the deterministic source; asserting against decoded structure beats asserting against any rendering, because rendering varies by client while structure varies only when your code does.
One helper file, kept deliberately small
Resist growing the task module into a framework. The durable shape is three functions: tempxWait (poll the inbox API until a matcher hits or the deadline passes), a subject/sender matcher vocabulary, and an extractor that takes the regex, nothing else. Everything speculative you are tempted to add (retries with backoff, caching, event emitters) solves problems the two-second poll does not have, and every addition is a new way for a spec to surprise you. A new need (batch waiting under heavy sharding, say) earns its helper when it appears, with the failing spec that justifies it.
The same minimalism applies to the command: it exists to hide plumbing, not to re-implement it. A spec should read as intent ("type the address, click send, wait for the code, enter it") and the day the helper stops making specs read that way is the day it has grown too clever to trust.
Create a temporary email address
Free, instant, no sign-up, messages auto-delete.
Go to your inbox