Email testing with Vitest
The same flow as Jest with typed fixtures: Vitest picks up the client's TypeScript types with no extra setup.
How it fits together
Vitest inherits Vite's TypeScript handling, so the client's types work out of the box. The default timeout being shorter than real email delivery is the obvious trap; two subtler ones are worth knowing before the suite grows. Tests marked concurrent inside one file share whatever a module level variable holds, so an inbox assigned in beforeEach belongs to whichever test wrote to it last. And the retry option re-runs a failed test against a box that still holds the previous attempt's mail, which can turn a genuine failure into a pass on the second try.
Skip the setup
The steps below are what a working project looks like. If you want that project rather than the explanation, generate it: one runnable smoke test, one signup test to edit, and a gitignored .env for your key.
npm create mailflat@latest -- --template vitest
Zero dependencies of its own; the generated project pulls the SDK.
The test
The second argument to test is the per-test timeout in milliseconds. Keep it above the waitForOtp timeout so the client fails first and gets to say whether nothing arrived or something arrived without a readable code.
// signup.test.ts
import { beforeEach, afterEach, expect, test } from "vitest";
import { MailFlat, type Inbox } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY! });
let inbox: Inbox;
beforeEach(async () => {
inbox = await mf.create({
prefix: `vitest-${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);
Or in the config
hookTimeout is a separate setting with its own default, and creating an inbox in beforeEach is an HTTP call that runs under it. A suite that raises only testTimeout still fails in the hook.
// vitest.config.ts
export default defineConfig({
test: { testTimeout: 70_000, hookTimeout: 30_000 },
});
A fixture instead, if any test is concurrent
test.extend hands each test its own inbox as an argument rather than through a shared variable, which is the only version that is safe under test.concurrent. Everything after use is teardown, so a failed test still cleans up.
// fixtures.ts
import { test as base } from "vitest";
import { MailFlat, type Inbox } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY! });
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
const box = await mf.create({
prefix: `vitest-${Date.now().toString(36)}`,
retentionHours: 2,
});
await use(box);
await box.delete();
},
});
// signup.test.ts
test.concurrent("signup sends a code", async ({ inbox }) => {
await api.post("/signup", { email: inbox.address });
expect(await inbox.waitForOtp({ timeout: 60_000 })).toMatch(/^\d{6}$/);
}, 70_000);
Worth knowing
hookTimeout matters too: creating an inbox in beforeEach is an HTTP call.
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.
vi.useFakeTimers() stops the polling loop. The client waits between attempts with setTimeout, so under a frozen clock the next attempt never happens and the call hangs until the test timeout kills it. Switch back with vi.useRealTimers() before anything waits on mail.
Vitest runs test files in parallel by default. Free allows three agent inboxes at once, so a suite that opens one box per file can pass on a two core runner and fail on a laptop, which reads like flakiness and is not.
The address is permanent until you delete it. If a suite is easier to reason about with one known address, create it once outside the run and give the run a key with inbox:read only; only the messages inside it expire.
When it goes wrong
| Symptom | Why | What to do |
|---|---|---|
| Concurrent tests in one file read each other's codes | beforeEach writes the inbox to a module level variable and concurrent tests run at the same time, so all of them see whichever value was written last. | Move the inbox into a test.extend fixture as shown, so each test receives its own. Sequential tests hide this, which is why it appears the day someone adds concurrent. |
| A retried test passes on the second attempt for no clear reason | The retry ran against a box that still held the first attempt's message. waitForOtp returns the newest message already in the box, so the code was there before the retry even triggered the mail. | Let the fixture open a fresh box per attempt, or delete the messages at the start of the test. A retry that only passes on the second try deserves this check before it is called flaky. |
| beforeEach times out while the test timeout is generous | hookTimeout is a separate setting. Raising testTimeout alone leaves the hook on its own shorter clock, and opening an inbox is an HTTP call made inside that hook. | Set both in vitest.config.ts as shown. The message names the hook rather than the test, which is the quickest way to tell the two apart. |
| mf.create throws 403 before the first assertion | 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 pass a fixed address in through the environment and skip creation entirely. |
| waitForOtp throws EncryptedInboxError | That box is end to end encrypted, so the server cannot read the body and cannot extract a code from it. Boxes opened through the API are always plain text, so this one was created in the dashboard on an account with encryption enabled. | Let the suite open its own inbox, or turn to a non encrypted box for automated runs. Reading it in your own code and decrypting there also works, but the code will not arrive as a field. |
See also
TypeScript
The same package as JavaScript, with types shipped in the box: Message, CreateInboxOptions, WaitOptions and typed errors.
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.