Email testing with TypeScript
The same package as JavaScript, with types shipped in the box: Message, CreateInboxOptions, WaitOptions and typed errors.
Before you begin
- A MailFlat accountFree to start, no card. Every plan can open inboxes from the API.
- An account API keyAgents → API keys in the dashboard. It looks like mf_live_… and goes in the X-API-Key header. Keep it in an environment variable, never in the repo. See API keys and authentication.
- Node 18+, TypeScript 5+That is the whole toolchain requirement.
Install
npm i @mailflat/sdk
Package: @mailflat/sdk
Your first inbox and one-time code
The whole loop: open an address, let your app mail it, read the code back, clean up.
import { MailFlat, type Inbox, type Message } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY! });
// 1. a real, deliverable address
const inbox: Inbox = await mf.create({ label: "signup" });
// 2. your app sends the code to that address
await myApp.register({ email: inbox.address });
// 3. read it back, typed all the way down
const otp: string = await inbox.waitForOtp({ timeout: 30_000 });
const msg: Message = await inbox.waitForMessage({ timeout: 30_000 });
// 4. done with it
await inbox.delete();
Line by linewhat each step of this TypeScript example does
- create(...)
- Returns immediately with a real, deliverable address. Nothing is queued or simulated.
- retention_hours
- Optional. Messages purge themselves after it, so a skipped teardown never leaks.
- wait_for_otp
- Polls for you and fails loudly on timeout, instead of returning nothing three lines before the real error.
- delete()
- Optional but tidy. Retention would clean up anyway.
The address is real
Mail actually travels: SMTP, DKIM, the lot. Nothing is stubbed, so a broken template or a misconfigured sender fails here exactly like it would in production.
Waiting for the code
We extract the one-time code server-side and hand it to you as a field, so you never write a regex against an email body.
import { OTPTimeoutError, EncryptedInboxError } from "@mailflat/sdk";
// timeout and pollInterval are MILLISECONDS
const otp: string = await inbox.waitForOtp({ timeout: 30_000, pollInterval: 1_000 });
try {
await inbox.waitForOtp({ timeout: 5_000 });
} catch (err) {
if (err instanceof OTPTimeoutError) console.log("no code arrived in time");
if (err instanceof EncryptedInboxError) console.log("inbox is end-to-end encrypted");
}
Always set a timeout
A poll loop without a deadline turns a missing email into a hung job. Fail loudly instead: the error message should name the address you were waiting on.
Reading every message
import type { Message } from "@mailflat/sdk";
const all: Message[] = await inbox.messages(); // newest first
const codes: string[] = all
.map((m) => m.otp)
.filter((c): c is string => Boolean(c));
const latest: Message | null = await inbox.latest();
// raw keeps the untouched API payload if you need a field the type does not expose yet
console.log(latest?.raw.received_at);
| Field | Type | What it is |
|---|---|---|
| subject | string | Subject line |
| sender | string | From address |
| body_text | string | Plain text body |
| body_html | string | HTML body |
| otp_code | string | null | One-time code, extracted by us |
| to_address | string | The exact address it was sent to, tag included |
| tag | string | null | Plus-addressing tag, if the sender used one |
| received_at | ISO 8601 | When it landed |
| is_encrypted | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
import type { SendOptions } from "@mailflat/sdk";
const opts: SendOptions = {
subject: "Welcome",
body: "Plain text body",
html: "<p>Optional HTML body</p>",
};
await inbox.send("someone@example.com", opts);
Cleaning up
await inbox.delete();
// retentionHours is typed on CreateInboxOptions
import type { CreateInboxOptions } from "@mailflat/sdk";
const opts: CreateInboxOptions = { label: "ci", retentionHours: 2 };
const inbox = await mf.create(opts);
Two safety nets, use both
Delete in teardown so the list stays readable, and set retention_hours so a crashed run still cleans itself up.
In a test suite
Works with Playwright, Vitest, Jest, Cypress and anything else that gives you a setup and teardown hook.
// e2e/signup.spec.ts — a typed fixture that hands each test its own inbox
import { test as base, expect } from "@playwright/test";
import { MailFlat, type Inbox } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY! });
const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
const inbox = await mf.create({
prefix: `ci-${Date.now().toString(36)}`,
retentionHours: 2,
});
await use(inbox);
await inbox.delete();
},
});
test("user can sign up with a real code", async ({ page, inbox }) => {
await page.goto("/signup");
await page.fill("#email", inbox.address);
await page.click("#submit");
await page.fill("#code", await inbox.waitForOtp({ timeout: 60_000 }));
await expect(page).toHaveURL(/dashboard/);
});
Line by linewhat each step of this TypeScript example does
- unique prefix
- One inbox per test, so parallel workers never read each other's mail.
- teardown hook
- Runs even when the test fails — that is exactly when inboxes get left behind.
- real code, real email
- No test-mode backdoor in your app: the path under test is the one your users take.
TypeScript reference
| Method | What it does |
|---|---|
| mf.create({ prefix, label, subdomain, domain, retentionHours }) | Open an inbox, returns Inbox |
| mf.list() | Every inbox this key opened |
| mf.inbox(address) | Attach to an existing address without an API call |
| inbox.waitForOtp({ timeout, pollInterval }) | Poll until a code arrives, rejects with OTPTimeoutError |
| inbox.waitForMessage({ timeout }) | Same, but resolves to the whole Message |
| inbox.messages() / inbox.latest() | All messages (newest first) / the newest one or null |
| inbox.send(to, { subject, body, html }) | Send from this address, DKIM-signed |
| inbox.delete() / inbox.deleteMessage(id) | Drop the inbox / one message |
Full endpoint reference, including error shapes and rate limits: Agent API and MCP.