MailFlatDocs
Documentation/Test frameworks & CI/Cypress

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

Shell
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.
JavaScript
// 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.
JavaScript
// 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

SymptomWhyWhat to do
createInbox fails with 403 and the spec never opens a pageThe 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 secondsThat 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 codeThe 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 EncryptedInboxErrorThe 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 growsNot 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.