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