MailFlatDocs
Documentation/Test frameworks & CI/Cucumber

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

SymptomWhyWhat to do
The waiting step fails at five seconds no matter what the client is toldCucumber'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 togetherThe 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 inboxEach 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 attemptThe 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 skippedOpening 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.