If your Cypress suite stubs the verification step because “email testing is hard”, you are not testing the feature. This post shows how to drive a real signup flow end to end using MailSink’s disposable inboxes, wired as a custom Cypress command.
Same pattern works for Playwright; the code below is the Cypress equivalent.
The shape
Three pieces:
- Custom Cypress command that provisions an inbox and returns the address + an
cy.waitForCode()helper. - A test that submits the address to your signup form and waits for the code.
- A cleanup task so each test starts fresh.
No IMAP. No regex. No shared Gmail.
Setup
Sign in at mailsink.dev with GitHub and copy the API key. In cypress.env.json (gitignored):
{
"MAILSINK_API_KEY": "msk_your_key_here"
}
Custom command
Drop this in cypress/support/commands.ts:
const MAILSINK_BASE = "https://api.mailsink.dev";
interface Inbox {
id: string;
address: string;
expires_at: number;
}
declare global {
namespace Cypress {
interface Chainable {
createInbox(): Chainable<Inbox>;
waitForCode(inboxId: string, timeout?: number): Chainable<string>;
deleteInbox(inboxId: string): Chainable<void>;
}
}
}
const authHeader = () => ({
Authorization: `Bearer ${Cypress.env("MAILSINK_API_KEY")}`,
});
Cypress.Commands.add("createInbox", () => {
return cy
.request({
method: "POST",
url: `${MAILSINK_BASE}/v1/inboxes`,
headers: authHeader(),
})
.then((r) => r.body as Inbox);
});
Cypress.Commands.add("waitForCode", (inboxId: string, timeout = 30) => {
return cy
.request({
method: "GET",
url: `${MAILSINK_BASE}/v1/inboxes/${inboxId}/wait-for-code?timeout=${timeout}`,
headers: authHeader(),
timeout: (timeout + 5) * 1000,
})
.then((r) => {
if (!r.body.code) {
throw new Error(`No verification code arrived in ${timeout}s`);
}
return r.body.code as string;
});
});
Cypress.Commands.add("deleteInbox", (inboxId: string) => {
return cy
.request({
method: "DELETE",
url: `${MAILSINK_BASE}/v1/inboxes/${inboxId}`,
headers: authHeader(),
failOnStatusCode: false,
})
.then(() => undefined);
});
export {};
The command uses cy.request for direct HTTP, which sidesteps CORS (Cypress runs HTTP requests from the Node side, not the browser).
A real signup test
// cypress/e2e/signup.cy.ts
describe("signup flow with email verification", () => {
let inboxId: string;
afterEach(() => {
if (inboxId) cy.deleteInbox(inboxId);
});
it("new user signs up and verifies email", () => {
cy.createInbox().then((inbox) => {
inboxId = inbox.id;
cy.visit("https://example.com/signup");
cy.get('[name="email"]').type(inbox.address);
cy.get('[name="password"]').type("TestPassword123!");
cy.contains("button", "Create account").click();
cy.waitForCode(inbox.id, 45).then((code) => {
cy.get('[name="verificationCode"]').type(code);
cy.contains("button", "Verify").click();
});
cy.url().should("include", "/dashboard");
cy.contains("Welcome").should("be.visible");
});
});
});
Run: npx cypress run. Every execution gets a clean inbox, hits the real signup endpoint, pulls a real verification email, and asserts the redirect.
Using cy.intercept for other assertions
If the backend sends the verification email via a webhook or delayed job, you can still add network assertions around it:
cy.intercept("POST", "**/api/signup").as("signup");
cy.contains("button", "Create account").click();
cy.wait("@signup").its("response.statusCode").should("eq", 200);
cy.waitForCode(inbox.id).then((code) => {
// ...
});
This gives you one assertion on the backend path (signup accepted) and another on the email side (code arrived). Failures point at which layer broke.
Running in CI
GitHub Actions workflow:
# .github/workflows/cypress.yml
name: cypress
on: [push, pull_request]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- uses: cypress-io/github-action@v6
with:
install: false
env:
CYPRESS_MAILSINK_API_KEY: ${{ secrets.MAILSINK_API_KEY }}
Note Cypress env vars in CI need the CYPRESS_ prefix. Cypress.env("MAILSINK_API_KEY") picks it up.
Parallel execution
Cypress parallel runs give each spec a unique runner. Because each spec creates its own inbox, there is zero collision.
For the free tier (50 inboxes/month), a small suite with 10 signup tests running on every push burns through the cap fast. Bump to Pro ($15/mo, 2,000 inboxes) if your CI volume is higher than a few runs per day.
Failure modes to know
- Missing env var:
Cypress.env("MAILSINK_API_KEY")returns undefined, requests 401. Fix the.envor CI secret. - Inbox not yet propagated: right after creation, the first
list_messagesmight return empty for 1-2s.wait-for-codehandles this internally; do not calllist_messagesimmediately. - OTP already consumed: if you re-run a test without cleanup, the same address cannot sign up twice. The
afterEachhook ensures this.
What about magic links?
Swap waitForCode for waitForLink. Same pattern; different endpoint (/wait-for-link). The returned link can be opened via cy.visit(link) to complete the flow.
Cypress.Commands.add("waitForLink", (inboxId: string, timeout = 30) => {
return cy
.request({
method: "GET",
url: `${MAILSINK_BASE}/v1/inboxes/${inboxId}/wait-for-link?timeout=${timeout}`,
headers: authHeader(),
})
.then((r) => r.body.link as string);
});
// In the test:
cy.waitForLink(inbox.id).then((link) => cy.visit(link));
FAQ
Does this work on Cypress Cloud / parallelized runs?
Yes. The fixture uses cy.request which is not tied to browser instances. Each parallel worker creates its own inbox.
Why not stub the email entirely?
Stubbing verifies your frontend happy-path. A real-email test catches failures in: the email-sending service, template rendering in real clients, spam-filtering regressions, and the OTP extraction itself. These are the bugs that bite after deploy.
Can I use the raw message body?
Yes. GET /v1/inboxes/:id/messages returns metadata; GET /v1/messages/:id returns full content. If you need to assert on subject, from address, HTML structure, or template pieces, fetch it and parse in the test.
Is there a Cypress plugin I can install instead?
Not today. The custom command above is small enough that a plugin would be overkill. If demand shows up, a plugin is easy to add.