MailFlatDocs
Documentation/Test frameworks & CI/Jest

Email testing with Jest

Open an inbox in beforeEach, delete it in afterEach, and give Jest a timeout long enough for real mail to arrive.

How it fits together

Nothing special is needed beyond one thing people forget: Jest's default five second timeout is shorter than email delivery. Raise it on the tests that wait for mail. Two Jest habits are worth checking before you blame delivery. Fake timers stop the client's polling loop dead, because it waits between attempts with setTimeout and a frozen clock never reaches the next attempt. And opening the inbox in beforeAll instead of beforeEach lets one box survive across the tests in a file, which turns a stale code into a passing test rather than a failing one.

The test

The third argument to test is the per-test timeout, in milliseconds. Keep it above the waitForOtp timeout so the client is the one that gives up first: its error distinguishes nothing arriving from mail arriving without a readable code, and Jest's own timeout message cannot tell you which of those happened.
JavaScript
// signup.test.js
import { MailFlat } from "@mailflat/sdk";
 
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
let inbox;
 
beforeEach(async () => {
inbox = await mf.create({
prefix: `jest-${Date.now().toString(36)}`,
retentionHours: 2,
});
});
 
afterEach(async () => {
await inbox.delete();
});
 
test(
"signup sends a verification code",
async () => {
await api.post("/signup", { email: inbox.address });
 
const otp = await inbox.waitForOtp({ timeout: 60_000 });
expect(otp).toMatch(/^\d{6}$/);
},
70_000, // longer than the waitForOtp timeout
);

Or raise it for the whole file

This covers the hooks as well as the tests, which matters because opening an inbox in beforeEach is an HTTP call and it runs under the same clock.
JavaScript
jest.setTimeout(70_000);

If the suite uses fake timers

Real timers have to be back before anything waits on mail. The client sleeps a second between polls with setTimeout, so under a frozen clock the loop never advances and the call hangs until Jest kills it.
JavaScript
beforeEach(() => jest.useFakeTimers());
 
test("signup sends a verification code", async () => {
await api.post("/signup", { email: inbox.address });
 
jest.useRealTimers(); // before anything waits on mail
const otp = await inbox.waitForOtp({ timeout: 60_000 });
expect(otp).toMatch(/^\d{6}$/);
}, 70_000);

Worth knowing

Always set the Jest timeout HIGHER than the waitForOtp timeout, or Jest kills the test first and you lose the useful error message.
Jest runs test files in parallel workers, so one inbox per test file is the minimum isolation. One per test is better, and it is the only version that rules out a test reading the code its predecessor triggered.
waitForOtp defaults to a thirty second timeout and polls once a second. Raising it costs nothing while mail is arriving normally, because the call returns the moment the code lands rather than waiting out the clock.
Free allows three agent inboxes at once. With maxWorkers left at the default, a suite that opens a box per file can exceed that on a laptop with four cores and pass on a two core CI runner, which reads like flakiness and is not.
The address is permanent until you delete it. A suite that is easier to reason about with one known address can create it once, keep it in an environment variable and use a key with inbox:read only, so nothing in the run is able to delete anything.

When it goes wrong

SymptomWhyWhat to do
waitForOtp never resolves and the test dies on the Jest timeoutFake timers are on. The client waits between polls with setTimeout, so a frozen clock means the second attempt never happens and no error is ever thrown.Call jest.useRealTimers() before the wait, as above. The tell is that the failure is always exactly the Jest timeout, never the client's own thirty second one.
mf.create throws 403 before any test body runsOpening 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 open one inbox by hand and pass its address in through the environment. Reuse is a supported pattern here rather than a workaround, because the address does not expire.
A test passes with the code from the test before itwaitForOtp returns the newest message already in the box. If the box was opened in beforeAll it outlives each test, so the call returns instantly with a stale code and nothing looks broken.Open the box in beforeEach as shown. A green test reading the previous test's code is the failure this page exists to prevent, because nothing in the output hints at it.
The fourth test file fails to open an inboxFree allows three agent inboxes at once and parallel workers open one each. This is a plan allowance rather than a rate limit, so the answer is a 400 naming the plan and the number, not a 429.Delete the box in afterEach so the allowance frees up, cap workers with --maxWorkers=2, or share one address across the suite. Paid plans do not cap agent inboxes.
The timeout error says mail did arriveA message landed but no code could be read out of it. That is a parsing miss, not a delivery failure, and the two need completely different fixes.The error quotes the newest message's subject. Read inbox.latest() and pull the code yourself, and send us the format so it stops needing a workaround.