/cypress Email Testing
Receive OTP codes and verification emails inside real Cypress E2E tests — using disposable inboxes from the OpenInbox REST API.
Email-dependent flows — signup confirmation, OTP login, password reset — are some of the hardest things to cover in Cypress, because the verification email lands in a real mailbox your test can't see. The fix is to give each test its own disposable inbox over the OpenInbox API, drive the UI with Cypress as usual, then poll the API for the email and pull the code or link out of its body. Every snippet below runs against the real OpenInbox API.
How the flow works
Cypress drives the browser; a small REST helper handles the mailbox. The whole loop — create, trigger, poll, extract, assert — usually completes in 10–20 seconds.
- Create a disposable inbox: POST /api/inbox (no auth) returns { id, email, expiresAt }.
- Type that address into your signup/login form and submit, so your app sends the email.
- Poll GET /api/inbound/api/emails?inboxEmail=<email> with your X-API-KEY (premium) until an email arrives.
- Extract the OTP (or confirmation link) from the email textBody with a regex.
- Enter the code / visit the link in Cypress and assert the success state.
Setup
Install Cypress and cypress-recurse — the latter gives you a clean retry loop so you wait exactly as long as the email takes, instead of a brittle fixed cy.wait().
Polling the inbox requires a premium API key. Store it as a Cypress environment variable (never hard-code it): either in a git-ignored cypress.env.json, or as CYPRESS_OPENINBOX_API_KEY in your shell/CI.
npm i -D cypress cypress-recurse
# cypress.env.json (add to .gitignore)
# { "OPENINBOX_API_KEY": "your-premium-api-key" }
# …or pass it at run time:
CYPRESS_OPENINBOX_API_KEY=your-premium-api-key npx cypress runAdd an OpenInbox custom command
Wrap the API in two Cypress commands so your specs stay readable. createInbox creates a fresh inbox; waitForOtp polls until an email lands and returns the extracted code. Because cy.request runs Node-side (not in the app under test), there are no CORS issues calling api.openinbox.io.
import { recurse } from 'cypress-recurse';
const API = 'https://api.openinbox.io/api';
declare global {
namespace Cypress {
interface Chainable {
createInbox(): Chainable<{ id: string; email: string }>;
waitForOtp(inboxEmail: string): Chainable<string>;
}
}
}
// Create a disposable inbox — no auth required.
Cypress.Commands.add('createInbox', () => {
return cy
.request('POST', `${API}/inbox`)
.its('body')
.then((inbox) => ({ id: inbox.id, email: inbox.email }));
});
// Poll the inbox until an email arrives, then return the OTP from textBody.
Cypress.Commands.add('waitForOtp', (inboxEmail: string) => {
return recurse(
() =>
cy.request({
url: `${API}/inbound/api/emails?inboxEmail=${encodeURIComponent(inboxEmail)}`,
headers: { 'X-API-KEY': Cypress.env('OPENINBOX_API_KEY') },
}),
(res) => res.body.emails.length > 0, // keep retrying until an email lands
{ timeout: 30000, delay: 2000 },
).then((res) => {
const otp = res.body.emails[0].textBody.match(/\b\d{4,8}\b/)?.[0];
expect(otp, 'OTP code in email body').to.exist;
return otp as string;
});
});Write the OTP login test
With the commands in place, the test reads like the user story: create an inbox, request a code, wait for it, type it in, and assert you reached the dashboard.
describe('OTP login', () => {
it('logs in with an emailed code', () => {
cy.createInbox().then((inbox) => {
cy.visit('/login');
cy.get('[name="email"]').type(inbox.email);
cy.contains('button', 'Send code').click();
cy.contains('Check your email').should('be.visible');
cy.waitForOtp(inbox.email).then((otp) => {
cy.get('[data-testid="otp"]').type(otp);
cy.contains('button', 'Verify').click();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-menu"]').should('be.visible');
});
});
});
});Variant: password reset (extract a link)
The same pattern extracts a URL instead of a code — just change the regex you run against textBody. Here the poll is inlined with cypress-recurse so you can see the full shape.
import { recurse } from 'cypress-recurse';
const API = 'https://api.openinbox.io/api';
describe('Password reset', () => {
it('resets via the emailed link', () => {
cy.createInbox().then((inbox) => {
cy.visit('/forgot-password');
cy.get('[name="email"]').type(inbox.email);
cy.contains('button', 'Send reset link').click();
recurse(
() =>
cy.request({
url: `${API}/inbound/api/emails?inboxEmail=${encodeURIComponent(inbox.email)}`,
headers: { 'X-API-KEY': Cypress.env('OPENINBOX_API_KEY') },
}),
(res) => res.body.emails.length > 0,
{ timeout: 30000, delay: 2000 },
).then((res) => {
const link = res.body.emails[0].textBody.match(/https?:\/\/\S*reset\S*/i)?.[0];
cy.visit(link!);
cy.get('[name="password"]').type('NewSecurePass123!');
cy.contains('button', 'Reset password').click();
cy.contains('Password updated').should('be.visible');
});
});
});
});Running it in CI (GitHub Actions)
Store your premium key as a repository secret and expose it to Cypress as CYPRESS_OPENINBOX_API_KEY. No SMTP servers or ports are involved — cy.request just makes HTTPS calls — so it works on any runner.
name: E2E
on: [push]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx cypress run
env:
CYPRESS_OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}Common pitfalls
- •Don't poll with a fixed cy.wait(5000) — emails can take 2–10s and a fixed wait is either flaky or slow. Use cypress-recurse (or a recursive cy.request) so the test waits exactly as long as needed, up to your timeout.
- •Creating an inbox needs no auth, but the inbound emails endpoint is premium — a 401/403 from /inbound/api/emails means the X-API-KEY is missing or the account is not on a paid plan.
- •The /\b\d{4,8}\b/ regex matches any 4–8 digit run, so it can catch a year or an order number. If your email contains other numbers, anchor it (e.g. /code is\s*(\d{6})/i).
- •Free inboxes expire after one hour. That is plenty for a test run, but create a fresh inbox per test rather than reusing one across a long suite.
- •cy.request runs from Cypress (Node), not the browser, so calls to api.openinbox.io are not subject to your app's CORS policy — you do not need to proxy them.
Frequently Asked Questions
Can Cypress read emails directly?
No — Cypress runs in the browser and can't open a real mailbox. Instead you create a disposable inbox via the OpenInbox API and poll it with cy.request, which keeps the whole flow inside your Cypress test.
Do I need an API key for this?
Creating an inbox (POST /api/inbox) is unauthenticated. Polling for received emails (GET /api/inbound/api/emails) requires a premium X-API-KEY, so store one as CYPRESS_OPENINBOX_API_KEY.
How long does it take for the email to arrive?
Usually 2–5 seconds. Poll with cypress-recurse using a ~2s delay and a 30s timeout, which is comfortable for CI while keeping tests fast.
How do I extract the OTP code in Cypress?
Run a regex against the email textBody field, for example res.body.emails[0].textBody.match(/\b\d{4,8}\b/)?.[0]. The API returns the raw textBody, so OTP extraction is always a one-line regex.
Can I run these tests in parallel?
Yes. Create one inbox per test (cy.createInbox), so each spec has an isolated mailbox and parallel workers never see each other's emails.
Will this work in CI / GitHub Actions?
Yes — cy.request only makes HTTPS calls to api.openinbox.io (no SMTP, no open ports), so it runs on any CI runner. Pass the key via the CYPRESS_OPENINBOX_API_KEY secret.
Related Pages
Explore More
Your private inbox is one click away
Free, instant, no registration needed. Works with any service. Expires automatically after 1 hour.
