Using MailFlat with Cypress
Read real verification emails from a Cypress test by moving the API calls into a Node task, where network access belongs.
How it fits together
Cypress specs run in the browser, so calling an external API straight from a test is awkward and CORS-bound. The supported way is a Node task: register the MailFlat calls in setupNodeEvents once, then use cy.task from any spec. That split has a second benefit worth naming, because it is the reason to prefer a task even where CORS would not stop you: the API key stays in the Node process and never reaches the browser, so it cannot end up in a screenshot, a video, or the devtools network panel of a failed run.
Install
npm i -D @mailflat/sdk
Register the tasks
setupNodeEvents runs in Node, so the API key never reaches the browser. Three tasks are enough for a signup flow: one to open a box, one to wait for the code, one to clean up. Keep the waiting inside the task rather than looping in the spec, because a Cypress command chain has no way to pause for a poll without turning into a retry loop that fights the one inside the client.
// cypress.config.js
const { defineConfig } = require("cypress");
const { MailFlat } = require("@mailflat/sdk");
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
on("task", {
async createInbox() {
const inbox = await mf.create({
prefix: `cy-${Date.now().toString(36)}`,
retentionHours: 2,
});
return inbox.address;
},
async waitForOtp(address) {
return mf.inbox(address).waitForOtp({ timeout: 60000 });
},
async deleteInbox(address) {
await mf.inbox(address).delete();
return null;
},
});
},
},
});
The spec
cy.task returns a Cypress chainable, so it slots into the normal command chain. The address is stored in a plain variable set inside then, which is enough here because beforeEach runs before every it and the value is read in the same test that wrote it.
// cypress/e2e/signup.cy.js
describe("signup", () => {
let address;
beforeEach(() => {
cy.task("createInbox").then((a) => { address = a; });
});
afterEach(() => cy.task("deleteInbox", address));
it("accepts a real verification code", () => {
cy.visit("/signup");
cy.get("#email").type(address);
cy.get("#submit").click();
cy.task("waitForOtp", address).then((otp) => {
cy.get("#code").type(otp);
});
cy.url().should("include", "/dashboard");
});
});
Worth knowing
A task must return a value or null. Returning undefined makes Cypress fail with a confusing error.
Cypress default command timeout is 4 seconds; waiting happens inside the task, so it is not affected. The timeout that does apply is taskTimeout, which defaults to 60 seconds, and that is the one to check when a wait dies at a suspiciously round number.
Keep the waitForOtp timeout comfortably under taskTimeout. Setting both to 60 seconds means the two clocks expire together and the error you get is the one that explains least.
waitForOtp returns the code from the newest message in the box. Opening a box per test, as beforeEach does above, is what keeps one test from reading the code the previous test triggered.
The address is permanent until you delete it; only the messages inside expire, after 2 hours on Free and up to 30 days on paid plans.
When it goes wrong
| Symptom | Why | What to do |
|---|---|---|
| createInbox fails with 403 and the spec never opens a page | The key can read mail but cannot open a box. Creating and deleting live behind the inbox:manage scope, and a fresh key does not include it. | Reissue the key with inbox:manage, or open one inbox by hand and pass its address through the environment into the task. |
| cy.task times out at exactly 60 seconds | That is taskTimeout, not the mailbox. Cypress stopped waiting for the task while the task itself was still polling. | Raise taskTimeout in cypress.config.js, or lower the waitForOtp timeout so the client fails first and reports what it actually saw. |
| The test passes but types the previous run's code | The box was reused and still held an older message, so waitForOtp returned immediately with the newest code it found. | Open the box per test as shown, or delete the message in afterEach. This one passes silently, which is why it is worth checking before it hides a real regression. |
| The task rejects with EncryptedInboxError | The inbox is end to end encrypted, so the server cannot read the body and cannot extract a code from it. | Use a non encrypted inbox for automated runs, or return the raw message from the task and decrypt it yourself. |
| Everything works locally and fails in CI once the suite grows | Not a rate limit: polling an inbox you own is not throttled. What runs out is the plan's agent inbox allowance, which is three on Free, and a suite that opens one box per spec reaches that on the fourth spec. | The refusal is a 400 that names the plan and the number. Delete boxes in afterEach so the allowance frees up, or keep one address for the whole suite and delete its messages instead. Paid plans do not cap agent inboxes. |
See also
JavaScript
Zero dependencies, built on the runtime's own fetch. Drop it into Jest, Vitest, Playwright, Cypress or a Node script.
One-time codesMailFlat pulls the verification code out of the message for you and hands it over as a field, so your test never runs a regex against an email body.
Testing & CIGive every CI run its own real inbox: the four API calls, parallel shards, teardown, and how to keep an end-to-end suite fast and honest.