Cypress Email Testing with Real Inboxes

End-to-end tests that exercise email flows — sign-up confirmation, OTP entry, password reset — are notoriously hard to write reliably. The TempMailGrab API makes them straightforward: create a real inbox per test, wait for the message, read the structured JSON, and assert.

The problem with shared test email addresses

The most common approach to email testing is a shared inbox: one address used across all tests, polling for the most recent message. This breaks under parallel test execution — test A's verification email gets claimed by test B, the wrong OTP is entered, and the failure is intermittent and hard to reproduce. The fix is isolation: one inbox per test run, discarded when the test finishes.

Prerequisites

  • A TempMailGrab API key — available from the developer dashboard
  • Cypress 12+ (the cy.request() approach works in any version)
  • The API key stored as a Cypress environment variable: CYPRESS_TEMPMAILGRAB_API_KEY

Adding custom Cypress commands

Add the following to cypress/support/commands.ts:

const API = 'https://tempmailgrab.com/api/v1';
const headers = () => ({
  Authorization: `Bearer ${Cypress.env('TEMPMAILGRAB_API_KEY')}`,
});

Cypress.Commands.add('createTempInbox', () =>
  cy
    .request({ method: 'POST', url: `${API}/inbox`, headers: headers() })
    .its('body')
    .then(({ id, address }: { id: string; address: string }) => ({ id, address })),
);

Cypress.Commands.add(
  'waitForEmail',
  (inboxId: string, options: { timeout?: number } = {}) => {
    const deadline = Date.now() + (options.timeout ?? 30_000);
    function poll(): Cypress.Chainable {
      return cy
        .request({ method: 'GET', url: `${API}/inbox/${inboxId}/messages`, headers: headers() })
        .its('body')
        .then((messages: { extracted_otp?: string; subject: string }[]) => {
          if (messages.length > 0) return messages[0];
          if (Date.now() > deadline) throw new Error('Timed out waiting for email');
          // eslint-disable-next-line cypress/no-unnecessary-waiting
          return cy.wait(2_000).then(poll);
        });
    }
    return poll();
  },
);

Register the types in cypress/support/index.d.ts:

declare namespace Cypress {
  interface Chainable {
    createTempInbox(): Chainable<{ id: string; address: string }>;
    waitForEmail(
      inboxId: string,
      options?: { timeout?: number },
    ): Chainable<{ extracted_otp?: string; subject: string; text_body: string }>;
  }
}

Writing the test

describe('Sign-up email verification', () => {
  it('completes email verification with OTP', () => {
    cy.createTempInbox().then(({ id, address }) => {
      // Fill the registration form with the temporary address.
      cy.visit('/signup');
      cy.get('input[name="email"]').type(address);
      cy.get('button[type="submit"]').click();

      // Wait for the verification email and read the auto-extracted OTP.
      cy.waitForEmail(id, { timeout: 30_000 }).then((message) => {
        expect(message.extracted_otp).to.match(/^d{4,8}$/);
        cy.get('input[name="otp"]').type(message.extracted_otp!);
        cy.get('button[type="submit"]').click();
      });

      cy.url().should('include', '/dashboard');
    });
  });
});

Testing password reset

it('sends a password-reset link', () => {
  cy.createTempInbox().then(({ id, address }) => {
    // Create a user with the temp address first (or use a fixture).
    cy.request('POST', '/api/test/seed-user', { email: address });

    cy.visit('/forgot-password');
    cy.get('input[name="email"]').type(address);
    cy.get('button[type="submit"]').click();

    cy.waitForEmail(id).then((message) => {
      // extracted_links is a JSON array of all links in the email.
      const links: string[] = JSON.parse(message.extracted_links ?? '[]');
      const resetLink = links.find((l) => l.includes('/reset-password'));
      expect(resetLink).to.be.a('string');
      cy.visit(resetLink!);
      cy.get('input[name="password"]').type('NewPassword123!');
      cy.get('button[type="submit"]').click();
      cy.url().should('include', '/login');
    });
  });
});

CI/CD integration

Store CYPRESS_TEMPMAILGRAB_API_KEY as a CI secret. Since each test creates its own inbox, parallel test runners work without coordination — two workers running simultaneously cannot interfere with each other's inboxes. After the test finishes, the inbox expires automatically. No teardown code required.

For high-volume test suites, the TempMailGrab API supports creating up to 50 inboxes per minute on the developer plan. If you need more, the enterprise plan removes the rate limit.

Debugging failed tests

If waitForEmail times out, the most common causes are: the sign-up form is not submitting (check the Cypress command log for the POST request), the email is filtered as spam by the test server (check your mail worker logs), or the OTP TTL expired before the test reached the assertion. Increase the timeout option on waitForEmail for slow email flows. The GET /api/v1/inbox/:id/messages endpoint is available in the Cypress DevTools console for manual inspection during a paused test.

Related articles

See also: API documentation · OTP verification guide