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. Tick Read messages and Create and delete inboxes (inbox:read + inbox:manage): a new key can only read, so opening an inbox fails with 403 until you add the second one. 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
// prefix = the local part of the address. label is a display name only.
const inbox: Inbox = await mf.create({ prefix: "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(); // received mail, 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 | JSON field (msg.raw) |
|---|---|---|---|
| subject | string | Subject line | subject |
| sender | string | From address | sender |
| text | string | Plain text body | body_text |
| html | string | HTML body | body_html |
| otp | string | null | One-time code, extracted by us | otp_code |
| toAddress | string | The exact address it was sent to, tag included | to_address |
| tag | string | null | Plus-addressing tag, if the sender used one | tag |
| receivedAt | ISO 8601 UTC (ends with Z) | When it landed | received_at |
| isEncrypted | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable | is_encrypted |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
On the Free plan you can send to your own addresses (your account email or any of your inboxes). To send anywhere else, connect and verify a domain. Free includes one; Pro plans include more.
import type { SendOptions, OutgoingAttachment, Message } from "@mailflat/sdk";
import { SendFailedError, SendTimeoutError } from "@mailflat/sdk";
const attachment: OutgoingAttachment = {
filename: "invoice.pdf",
content: pdfBytes, // Uint8Array | ArrayBuffer
contentType: "application/pdf", // optional; defaults to application/octet-stream
};
const opts: SendOptions = {
subject: "Welcome",
body: "Plain text body",
html: "<p>Optional HTML body</p>",
cc: ["team@example.com"], // in the headers
bcc: ["archive@example.com"], // in NO header, not even in its own copy
attachments: [attachment],
inReplyTo: "<abc@example.com>", // keeps the mail in an existing thread
};
const result = await inbox.send("someone@example.com", opts);
// 202 Accepted: queued, not delivered. Follow it by id.
try {
const sent: Message = await inbox.waitUntilSent(result.message_id);
console.log(sent.sendStatus); // "sent" | "unsigned"
} catch (err) {
if (err instanceof SendFailedError) throw err; // the queue gave up
if (err instanceof SendTimeoutError) {
// NOT a failure: still queued and still being retried. Do not send again.
}
}
// Answering a mail keeps the threading headers and takes the same fields:
const msg = await inbox.waitForMessage({ timeout: 60_000 });
await msg.reply("Thanks, receipt attached.", { attachments: [attachment] });
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.