Email testing with JavaScript
Zero dependencies, built on the runtime's own fetch. Drop it into Jest, Vitest, Playwright, Cypress or a Node script.
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+ (or any fetch-capable runtime)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 } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
// 1. a real, deliverable address
const inbox = await mf.create({ label: "signup" });
console.log(inbox.address); // signup@a7f2c.mailflat.net
// 2. your app sends the code to that address
await myApp.register({ email: inbox.address });
// 3. read it back, no mocking anywhere
const otp = await inbox.waitForOtp({ timeout: 30000 });
console.log(otp); // "482913"
// 4. done with it
await inbox.delete();
Line by linewhat each step of this JavaScript 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.
// timeout and pollInterval are MILLISECONDS here (the Python client uses seconds)
const otp = await inbox.waitForOtp({ timeout: 30000, pollInterval: 1000 });
// need the whole message?
const msg = await inbox.waitForMessage({ timeout: 30000 });
console.log(msg.subject, msg.sender, msg.text);
// both reject on timeout, so a missing email fails the test loudly
import { OTPTimeoutError } from "@mailflat/sdk";
try {
await inbox.waitForOtp({ timeout: 5000 });
} catch (err) {
if (err instanceof OTPTimeoutError) console.log("no code arrived");
}
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
for (const msg of await inbox.messages()) { // newest first
console.log(msg.subject, msg.sender, msg.receivedAt);
console.log(msg.text); // plain text body
console.log(msg.html); // HTML body
console.log(msg.otp); // extracted code, or undefined
}
const latest = await inbox.latest(); // null until the first message lands
if (latest?.subject?.includes("Reset")) {
await latest.delete(); // drop one message, keep the inbox
}
| 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.
// mail leaves from the inbox address, DKIM-signed by our own MTA
await inbox.send("someone@example.com", {
subject: "Welcome",
body: "Plain text body",
html: "<p>Optional HTML body</p>",
});
Cleaning up
await inbox.delete(); // inbox and every message, immediately
// or leave it: messages expire on their own at the retention you asked for
const inbox = await mf.create({ label: "ci", retentionHours: 2 });
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 Jest, Vitest, Playwright, Cypress and anything else that gives you a setup and teardown hook.
// tests/signup.spec.js — one isolated inbox per test, torn down even on failure
import { test, expect } from "@playwright/test";
import { MailFlat } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
test("user can sign up with a real code", async ({ page }) => {
const inbox = await mf.create({
prefix: `ci-${Date.now().toString(36)}`,
retentionHours: 2,
});
try {
await page.goto("/signup");
await page.fill("#email", inbox.address);
await page.click("#submit");
await page.fill("#code", await inbox.waitForOtp({ timeout: 60000 }));
await expect(page).toHaveURL(/dashboard/);
} finally {
await inbox.delete();
}
});
Line by linewhat each step of this JavaScript 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.
JavaScript 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.