Using MailFlat with Cucumber
Keep the mailbox out of the feature file: the scenario says a user signs up and enters the code, the step definitions do the work.
How it fits together
A feature file should read like the business rule, not like an API client. Open the inbox in a Before hook, keep the address on the world object, and let each step use it. Cucumber builds a fresh world for every scenario, which is exactly the lifetime an inbox wants, so this shape gives isolation for free as long as the box is created in Before rather than in BeforeAll. The one thing to count before running it is scenarios rather than feature files: a Scenario Outline with four rows in its Examples table is four scenarios, and therefore four inboxes.
The feature
No mention of MailFlat: the scenario describes what a person does. This is also why the address has to live on the world rather than in a module variable, since the step text has nowhere to carry it.
Feature: Signup
Scenario: A new user verifies their email
Given I am on the signup page
When I sign up with a fresh email address
And I enter the verification code from my inbox
Then I land on the dashboard
The step definitions
Hooks own the lifecycle so no step has to remember to clean up. Before and After receive the world as this, which BeforeAll and AfterAll do not, and that alone is a good reason to keep inbox creation here even when a run has many scenarios.
// features/steps/signup.steps.js
const { Before, After, When, Then } = require("@cucumber/cucumber");
const { MailFlat } = require("@mailflat/sdk");
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
Before(async function () {
this.inbox = await mf.create({
prefix: `cuke-${Date.now().toString(36)}`,
retentionHours: 2,
});
});
After(async function () {
await this.inbox?.delete();
});
When("I sign up with a fresh email address", async function () {
await this.page.fill("#email", this.inbox.address);
await this.page.click("#submit");
});
When("I enter the verification code from my inbox", async function () {
await this.page.fill("#code", await this.inbox.waitForOtp({ timeout: 60000 }));
});
Worth knowing
Cucumber's default step timeout is 5 seconds. Raise it with setDefaultTimeout(70000) or the waiting step is killed early, and the report then blames the step rather than the mail.
Raising the default covers the hooks too, which matters because opening an inbox in Before is an HTTP call running under the same clock. A single slow step can also take its own timeout as a second argument to the step definition.
waitForOtp defaults to a thirty second timeout and polls once a second. It returns the moment the code lands, so a generous timeout costs nothing on a healthy run.
A Scenario Outline is one scenario per Examples row, so the Examples table is the number that decides how many inboxes a run opens. Free allows three at once.
The address is permanent until you delete it. A read only smoke feature can therefore run against one fixed address held in an environment variable, with a key carrying inbox:read and nothing else.
When it goes wrong
| Symptom | Why | What to do |
|---|---|---|
| The waiting step fails at five seconds no matter what the client is told | Cucumber's own step timeout ran out first. The client was still polling; Cucumber stopped listening. | Call setDefaultTimeout(70000) in a support file, or give that one step definition its own timeout option. The tell is the failure landing at exactly five seconds rather than at the client's thirty. |
| Scenarios pass individually and read each other's codes when run together | The inbox was created in BeforeAll, so one box serves the whole run. waitForOtp returns the newest message in it, and the second scenario gets whatever the first triggered. | Create the box in Before, where the world is per scenario, as shown above. This one stays green while proving nothing, so it will not appear in a failing report. |
| The fourth row of an Examples table cannot open an inbox | Each row is a separate scenario and Free allows three agent inboxes at once. That is a plan allowance, not a rate limit, so the refusal is a 400 that names the plan and the number. | Make sure After deletes the box so the allowance frees as the outline advances, or run the outline against one shared address. Paid plans do not cap agent inboxes. |
| A retried scenario passes on the second attempt | The retry got a fresh world and a fresh box only if Before created it. If the box came from anywhere longer lived, the retry read mail the first attempt had already triggered. | Keep creation in Before and deletion in After. A scenario that only ever passes on retry deserves this check before it is labelled flaky. |
| Before fails with 403 and every scenario is skipped | Opening and deleting boxes sit behind the inbox:manage scope, and a newly issued key carries inbox:read only. | Reissue the key with inbox:manage, or read a fixed address from the environment and drop the create call. The same 403 also answers for an address that belongs to another account, so compare against GET /api/v1/inboxes before assuming it is the scope. |
See also
JavaScript
Zero dependencies, built on the runtime's own fetch. Drop it into Jest, Vitest, Playwright, Cypress or a Node script.
JavaBuilt on java.net.http, no HTTP dependency of its own. Made for Selenium and JUnit suites that need a real address per test.
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.