@mailflat/sdk
v0.11.0
Published
Real email inboxes for AI agents and test suites, from TypeScript or JavaScript. Create one from code, wait for the one-time code, keep the address as long as you need.
Maintainers
Readme
@mailflat/sdk
Official TypeScript/JavaScript client for MailFlat: real email
inboxes for AI agents and test suites, with one-line OTP retrieval. The address is
permanent and stays until you delete it; only the messages inside expire, on a retention
window you choose. Zero dependencies, uses the platform fetch (Node 18+, modern browsers,
edge runtimes).
npm i @mailflat/sdkQuickstart
import { MailFlat } from "@mailflat/sdk";
const mailflat = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY });
// 1 · open an inbox
const inbox = await mailflat.create({ prefix: "signup-test", label: "checkout flow" });
console.log(inbox.address); // → [email protected]
// 2 · your app/browser submits the form using inbox.address ...
// 3 · grab the OTP (polls until it arrives or times out)
const otp = await inbox.waitForOtp({ timeout: 30000 });
console.log(otp); // → "123456"
// inbox auto-clears in 2h — no cleanup needed (or call inbox.delete())For AI agents
const inbox = await mailflat.create({ prefix: "deep-research" });
await browser.fill("#email", inbox.address);
await browser.click("Sign up");
const otp = await inbox.waitForOtp({ timeout: 30000 });
await browser.fill("#code", otp);API
new MailFlat(options)
{ apiKey?, baseUrl?, timeout?, maxRetries? }. apiKey falls back to process.env.MAILFLAT_API_KEY.
Use baseUrl for self-hosted / BYOD deployments (default https://mailflat.net).
create(opts) => Promise<Inbox>— open a new inbox.prefixshapes the address,labeldoes not:prefix: "signup-test"givessignup-test@…, whilelabelis only a dashboard name. Withoutprefixyou get a randomagent-…address.createInbox(opts)is an alias.opts:{ prefix?, label?, subdomain?, domain?, retentionHours? }.list() => Promise<Inbox[]>— inboxes opened with this key.inbox(address) => Inbox— attach to an existing address without a network call.
Inbox
.address— the email address..messages({ direction? }) => Promise<Message[]>— messages, newest first..latest({ direction? }) => Promise<Message | null>— most recent message..waitForOtp({ timeout?, pollInterval? }) => Promise<string>— poll until an OTP arrives; returns the code.timeoutis in ms (default 30000)..waitForMessage({ timeout?, pollInterval?, direction? }) => Promise<Message>— poll until any message arrives..send(to, { subject?, body?, html?, cc?, bcc?, attachments? }) => Promise<...>— send a DKIM-signed email from this inbox. Resolves once the mail is accepted for delivery, not once it is delivered (see below)..message(messageId) => Promise<Message>— fetch one message; how you check what happened to a send..waitUntilSent(messageId, { timeout?, pollInterval? }) => Promise<Message>— wait for delivery to finish. ThrowsSendFailedErrorif it permanently failed,SendTimeoutErrorif it is still queued at the deadline..markRead(messageId) => Promise<...>— mark a message read so later polls can skip it..burn() => Promise<...>— delete every message but keep the address..downloadAttachment(messageId, attachmentId) => Promise<Uint8Array>— fetch attachment bytes..delete() => Promise<...>— delete the inbox and all its messages.
Reads return received mail by default (
direction: "in"). Use"out"for mail you sent from this address,"all"for both. Without this,send()followed bywaitForMessage()matches your own outgoing message — which breaks agent-to-agent flows.
Attachments, cc and bcc
await inbox.send("[email protected]", {
subject: "Your invoice",
body: "Attached.",
cc: ["[email protected]"],
bcc: ["[email protected]"],
attachments: [{ filename: "invoice.pdf", content: bytes, contentType: "application/pdf" }],
});Give an attachment content (a Uint8Array) or contentBase64 — the encoding happens in
the SDK. ⚠️ Unlike the Python SDK there is no file path option: this package also runs in
a browser, where there is no filesystem. Read the file yourself and pass the bytes.
Size and count limits depend on your plan; going over throws with the limit spelled out
rather than silently dropping the file. bcc recipients receive the mail but never appear
in its headers — not even in their own copy. reply() accepts all three too.
Did it actually go out?
send() resolves as soon as the mail is accepted; delivery runs on a queue:
const res = await inbox.send("[email protected]", { subject: "Your invoice" });
await inbox.waitUntilSent(res.message_id, { timeout: 120_000 }); // throws if it failedFor anything long-running, subscribe to the message.delivered / message.failed
webhooks instead of polling.
Calendar invitations
An invitation sent to an inbox (Google Calendar, Outlook, ...) becomes an event on that inbox's calendar. Times are UTC. Answer it and the organizer's calendar shows your answer:
const [event] = await inbox.calendarEvents(); // soonest first, cancelled hidden
console.log(event.title, event.start, event.myStatus);
const res = await inbox.rsvp(event.id, "accepted", { comment: "See you there" });
await inbox.waitUntilSent(res.messageId!); // the reply is a queued emailmessage.calendarEvent carries the invitation a message brought in (action, eventId).
If the organizer moves the meeting, the event updates in place and myStatus goes back to
needs-action. Subscribe to the calendar.* webhook to hear about changes without polling.
The inbox can also be the organizer. Attendees get a normal invitation with Yes / No / Maybe
buttons, and their answers land on the event (calendar.attendee.responded webhook):
const { event } = await inbox.createCalendarEvent({
title: "Intro call", start: "2026-10-06T14:00:00", timezone: "America/New_York",
durationMinutes: 45, attendees: ["[email protected]", { email: "[email protected]", optional: true }],
});
await inbox.updateCalendarEvent(event.id, { start: "2026-10-07T14:00:00-04:00" });
await inbox.cancelCalendarEvent(event.id, { message: "Something came up" });A time without an offset needs timezone; nothing is guessed. Moving a meeting keeps its
length and asks everyone again.
A person can follow the agent's calendar in their own calendar app. Hand them the read-only subscribe link; every call returns the same link, so sharing it twice never breaks anything:
const feed = await inbox.calendarFeed();
feed.feedUrl; // https: Google "From URL", Outlook "Subscribe from web"
feed.subscribeLinks?.google; // one-click add links: google, apple, outlook
await inbox.rotateCalendarFeed(); // the link leaked: new one, the old one stops working
feed.lastClient; // "google" | "apple" | "outlook" | "other": who checked last
feed.stale; // true: the calendar changed after that checkGoogle refreshes a subscribed link every 8 to 24 hours. To have the account owner see meetings right away, send them a copy as an invitation. It is off by default and goes only to the account's sign-in address; guests never see it:
await inbox.setCalendarCopy(true);
(await inbox.calendarCopy()).email; // where the copies goGoogle refreshes subscribed calendars every 8 to 24 hours; the API above is live.
Message
.otp, .subject, .sender, .text, .html, .toAddress, .direction, .receivedAt,
.links, .attachments, .spam, .headers, .isRead, .raw.
.links— URLs found in the body (HTML hrefs first). For "click the verification link" flows..attachments— metadata;msg.attachments[0].download()fetches the bytes..spam—{score, required, is_spam, rules, scanner}, ornullwhen never scanned. ⚠️ Spam scanning is currently disabled on mailflat.net, so today this isnullfor every message..headers— raw headers, exactly as they arrived. Names are case-insensitive per RFC 5322 but this is a plain object, so do not index it: the real key isMessage-ID, andheaders["Message-Id"]isundefined. Use.header("message-id")or.messageId..reply(body, { html?, subject? })— answer this message in the same conversation (recipient,Re:subject andIn-Reply-To/Referencesare filled in). A hand-rolledsend()starts a new thread instead.- ⚠️
.senderis the SMTP envelope sender (MAIL FROM) — usually a bounce address on transactional mail. Use.replyToAddress(or.reply()), which resolvesReply-To→From→ envelope sender. .markRead()/.delete()— act on this message directly.
Errors
All errors extend MailFlatError: AuthenticationError (401), PermissionError (403),
NotFoundError (404), RateLimitError (429), APIError (other), OTPTimeoutError
(no OTP before timeout), EncryptedInboxError (the inbox is end-to-end encrypted, so the
server cannot read its contents — use a non-encrypted inbox for agent automation).
A message or attachment id that is not in an inbox you own throws NotFoundError. An inbox you
do not own always throws PermissionError, whether or not it exists — the API will not confirm
other people's addresses. So the two answer different questions: "wrong id" versus "wrong key".
License
MIT
