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