Test verification emails and OTPs with Cypress
Cypress runs tests in the browser, so the MailBots API is called from a Node task instead. The key stays in Node, each test gets its own address on your catch-all subdomain, and the task returns the code once the verification email has arrived.
Before you start
- An active Starter or Pro plan with a provisioned catch-all subdomain. See DNS onboarding.
- An API key with the
messages:readscope, created in Dashboard → Developer. See REST API. - Cypress 10 or later, running on Node.js 18 or later for the built-in fetch. The example was run on Cypress 16.1.
Both values are read by the Node task only, so neither reaches the browser under test:
- MAILBOTS_API_KEY
mb_live_…- MAILBOTS_DOMAIN
otp.example.com
Do not give either variable the CYPRESS_ prefix. Nothing here uses Cypress.env(), which Cypress 16 removed, so the same code runs from Cypress 10 to 16.
Add Node tasks
Two tasks run in Node. One creates a unique address, the other asks for the 50 newest messages and returns the code sent to that address, or null while it has not arrived. A Cypress task may not resolve to undefined, which is why "not yet" is null.
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on) {
on('task', {
mailbotsAddress(prefix) {
if (!process.env.MAILBOTS_DOMAIN) throw new Error('MAILBOTS_DOMAIN is not set');
const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return `${prefix}-${unique}@${process.env.MAILBOTS_DOMAIN}`.toLowerCase();
},
async mailbotsOtp(address) {
const response = await fetch('https://api.mailbots.top/v1/messages?limit=50', {
headers: { Authorization: `Bearer ${process.env.MAILBOTS_API_KEY}` },
});
if (!response.ok) throw new Error(`MailBots API ${response.status}: ${await response.text()}`);
const { data } = await response.json();
const message = data.find((item) => item.envelope_to.toLowerCase() === address && item.otp);
return message ? message.otp : null;
},
});
},
},
});Add a polling helper
Cypress commands are queued, so the helper retries by chaining another task after a short wait instead of looping.
// cypress/support/mailbots.js
export function waitForOtp(address, attemptsLeft = 30) {
return cy.task('mailbotsOtp', address, { log: false }).then((otp) => {
if (otp) return otp;
if (attemptsLeft <= 1) throw new Error(`No email with a code reached ${address}`);
return cy.wait(2000, { log: false }).then(() => waitForOtp(address, attemptsLeft - 1));
});
}Write the test
Replace the URL and selectors with the ones in your app.
// cypress/e2e/signup.cy.js
import { waitForOtp } from '../support/mailbots';
describe('signup', () => {
it('verifies a new account with an email code', () => {
cy.task('mailbotsAddress', 'signup').then((address) => {
cy.visit('/signup');
cy.get('input[type=email]').type(address);
cy.contains('button', 'Continue').click();
waitForOtp(address).then((otp) => {
cy.get('input[name=code]').type(otp);
});
});
cy.contains('button', 'Verify').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});Run it:
MAILBOTS_API_KEY=mb_live_REPLACE_ME MAILBOTS_DOMAIN=otp.example.com npx cypress runRun it in CI
- name: Cypress
run: npx cypress run
env:
MAILBOTS_API_KEY: ${{ secrets.MAILBOTS_API_KEY }}
MAILBOTS_DOMAIN: otp.example.comWith 30 attempts two seconds apart, a test waits about a minute for mail before failing. Messages expire after 72 hours, so no cleanup step is needed.
Troubleshooting
| Symptom | What to check |
|---|---|
| Task fails with 401 | The key is revoked, lacks messages:read, or the plan is no longer active |
| Task fails with "MAILBOTS_DOMAIN is not set" | The variable is missing from this run, or it was given the CYPRESS_ prefix |
| "Cypress.env() was removed" | Code copied from an older version of this guide. Create the address with the mailbotsAddress task instead |
| Times out, message is in the dashboard | More than 50 messages arrived during the wait. Raise limit to 100 |
| Message found but no code | No convincing code was detected. Read the message in the dashboard |