TEST RUNNERS

Test email verification in Cypress with a real disposable inbox

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:

  1. Custom Cypress command that provisions an inbox and returns the address + an cy.waitForCode() helper.
  2. A test that submits the address to your signup form and waits for the code.
  3. 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

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.

Next