MainSDK
Search⌘K
Interfaces

Messaging and notifications

Inbound mail and mailboxes

mail

Receive email through your mail provider's webhook, keep it in mailboxes with threads and attachments, and pull people's existing mailboxes over IMAP or JMAP.

Building for 1.2ships as a experimental APIPhase 3 · Mobile and regulatedSpec mailFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

mail receives email through your provider's webhook, keeps it in mailboxes with threads and attachments, and pulls existing mailboxes over IMAP or JMAP. Reach for it for a support inbox, reply-by-email or a mail client.

MainSDK never runs an SMTP server. Every inbound post is verified before a byte is parsed, and mail is stored in the tenant's region.

At a glance

mail:
  domains: [in.acme.app]
  inbound: { provider: ses, path: /mainsdk/mail/inbound, secret: secret:MAIL_INBOUND_SECRET }
  mailboxes:
    support: { address: support@in.acme.app, owner: tenant }
  reject: { dmarc: fail, virus: infected }

Calls

  • mail.on(route, fn)
  • mail.receive(req)
  • mail.list({ mailbox, folder?, thread?, unread?, cursor?, limit? })
  • mail.get(id)
  • mail.update(id, { folder?, read?, flagged?, labels? })
  • mail.thread(id)
  • mail.replyAddress({ thread, ttl? })
  • mail.headers({ replyTo })
  • mail.allowImages(id)
  • mail.pull(mailbox)
  • mail.connect(req, { provider, mailbox? })
  • mail.mailboxes()

Guarantees

Whatever the driver.

  • No SMTP server; every inbound message is verified before it is parsed
  • Remote images in mail are blocked until the reader allows them
  • Attachments are scanned and stored through files
  • Mailbox tokens never reach app code

Drivers

7 drivers. Select one to see its line in main.yaml.

main.yamlAmazon SES inbound
mail:
  driver: ses

Change this one line to swap providers; your calls stay the same.Amazon SES inbound integration

Codes

CodeWhere it appearsMeaning
MAIN-MAIL-0008Checkmainsdk check, before deploymail is invalid: a mailbox outside domains, an inbound provider with no secret, or a pull provider not under connections.providers
MAIN-MAIL-0009Checkmainsdk check, before deployCode listens for SMTP: a server on port 25, 465 or 587, or an SMTP server package
MAIN-MAIL-0001RuntimeError responses and logsNo mailbox or route matches the recipient; dropped and logged
MAIN-MAIL-0002RuntimeError responses and logsA raw message can't be parsed, or passes the size, part or nesting limit
MAIN-MAIL-0003RuntimeError responses and logsRejected by mail.reject: DMARC failed or a virus was found
MAIN-MAIL-0004RuntimeError responses and logsAn attachment was refused by size, type or scan; a placeholder is kept
MAIN-MAIL-0005RuntimeError responses and logsA reply address token is invalid, expired or for another tenant; threaded by headers instead
MAIN-MAIL-0006RuntimeError responses and logsAn IMAP or JMAP server failed or timed out during a pull; retried
MAIN-MAIL-0007RuntimeError responses and logsA mailbox's cursor can't be continued; it is pulled again from the start
MAIN-MAIL-0010RuntimeError responses and logsAn Authentication-Results header from a server other than the provider's was ignored

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
mail@1 contractPhase 31.2mail#contract
Inbound from provider webhooksPhase 31.2mail#inbound
MIME parsing in the standard libraryPhase 31.2mail#mime
SPF, DKIM, DMARC and spam verdictsPhase 31.2mail#verdicts
Attachments into filesPhase 31.2mail#attachments
Mailboxes, folders and flagsPhase 31.2mail#store
Reply-by-email threadingPhase 31.2mail#threads
Safe display of message HTMLPhase 31.2mail#display
IMAP and JMAP mailbox pullsPhase 31.2mail#pull

Quick start

main.yaml
mail:
  domains: [in.acme.app]
  inbound: { provider: ses, path: /mainsdk/mail/inbound, secret: secret:MAIL_INBOUND_SECRET }
  mailboxes:
    support: { address: support@in.acme.app, owner: tenant }
  reject: { dmarc: fail, virus: infected }
app/mail.ts
import { Mail } from "mainsdk/experimental/mail";
import { presetVerifier } from "mainsdk/experimental/mail-node";

type MailDeps = ConstructorParameters<typeof Mail>[0];

// The ports you wire in: your app's config, stores, tenant and files.
declare const app: {
  config: { mail: Record<string, unknown> };
  sqlStores: MailDeps["stores"];
  tenant: MailDeps["tenant"];
  files: MailDeps["files"];
};
declare const currentUser: { id: string };
declare const appKey: string;
declare const tickets: { open(t: { from: string; subject: string; body: string }): Promise<void> };

const mail = new Mail({
  config: app.config.mail,
  stores: app.sqlStores,
  tenant: { current: () => app.tenant.current() },
  subject: () => ({ id: currentUser.id }),
  secret: appKey,                                       // signs reply addresses
  verify: presetVerifier("ses", { secrets: [] }),
  files: app.files,
});

mail.on("support@", async (m) => {
  await tickets.open({ from: m.from.address, subject: m.subject, body: m.replyText ?? m.text ?? "" });
});

export const POST = (req: Request) => mail.receive(req);   // the provider posts here

Configuration

KeyDefaultMeaning
storedbWhere messages live: db or local
domainsnoneDomains whose MX points at the inbound provider
inbound.providernoneses, postmark, mailgun or cloudflare; verified through a webhooks preset
inbound.path/mainsdk/mail/inboundThe path the provider posts to
inbound.secretnonesecret:NAME for the provider's signing secret
mailboxesnoneEach mailbox is { address, owner }; owner: tenant is shared by the tenant
reply{ expires: 90d }domain for reply+ addresses and how long they last
reject{ dmarc: fail, virus: infected }Verdicts dropped before any handler runs; spam goes to the spam folder
max-size25MBThe largest raw message
attachments{ max-size: 20MB, scan: true }The largest attachment and whether files scans it
pull{ every: 5m, providers: [] }Connections providers that may be pulled, and how often

What you get

CallWhat it does
mail.on(route, fn)Run fn for each message to a matching address (support@, reply+, *); returns a function that removes the handler
mail.receive(req)Verify and store one provider post; answers 200, or 400 for an unverified one
mail.list({ mailbox, folder?, thread?, unread?, cursor?, limit? })Page through messages in a mailbox, newest first
mail.get(id)Return one message with safe HTML, text, reply text and attachments
mail.update(id, { folder?, read?, flagged?, labels? })Move a message or change its flags
mail.thread(id)Return every message in the thread of a message
mail.replyAddress({ thread, ttl? })Return a signed reply+ address that routes a reply back into the thread
mail.headers({ replyTo })Return the In-Reply-To and References headers to pass to email.send
mail.allowImages(id)Show this sender's remote images to the caller from now on
mail.connect(req, { provider, mailbox? })Start a connection grant for a person's own mailbox and return the redirect
mail.pull(mailbox)Pull one connected mailbox now; the schedule runs the same call
mail.mailboxes()List the mailboxes the caller may use

Always:

  • No SMTP server; every inbound message is verified before it is parsed.
  • Remote images in mail are blocked until the reader allows them.
  • Attachments are scanned and stored through files, labelled personal.
  • Mailbox tokens never reach app code, messages or logs.

Examples

Reply by email

ts
import type { Mail } from "mainsdk/experimental/mail";

declare const mail: Mail;
declare const comment: { threadId: string };
declare const author: { email: string };
declare const lastMessageId: string;
declare const sender: { send(m: { to: string; replyTo: string; subject?: string; headers: Record<string, string> }): Promise<void> };   // your outbound mail sender

const replyTo = mail.replyAddress({ thread: comment.threadId });   // reply+<token>@in.acme.app
await sender.send({ to: author.email, replyTo, subject: "New comment", headers: await mail.headers({ replyTo: lastMessageId }) });

mail makes the address and the headers; email.send does not take a reply address or headers yet, so pass them to whichever sender you use. A reply to that address joins the thread when its token verifies. A tampered or expired token falls back to In-Reply-To and logs MAIN-MAIL-0005.

Connect a person's mailbox

ts
import type { Mail } from "mainsdk/experimental/mail";

declare const mail: Mail;

export const GET = (req: Request) => mail.connect(req, { provider: "gmail", mailbox: "my-gmail" });

The person grants access, and MainSDK pulls the mailbox over IMAP (or JMAP for Fastmail). Revoking the connection stops the pull with MAIN-CONN-0007.

Errors and checks

CodeWhenFix
MAIN-MAIL-0001No mailbox or route matches the recipient, or the mailbox is missing or denied; the provider still gets 200Add a mailbox or route for the address
MAIN-MAIL-0002A raw message can't be parsed or passes max-size, 100 parts or 10 levels; kept as a rejected recordRaise max-size or reject the sender
MAIN-MAIL-0003Rejected by mail.reject: DMARC failed or a virus was foundNothing; no handler ran
MAIN-MAIL-0004An attachment was refused (size, type or scan); the message keeps a placeholderRead the reason on the placeholder
MAIN-MAIL-0005A reply token is invalid, expired or for another tenant; threading falls back to headersSend replies to a fresh replyAddress
MAIN-MAIL-0006An IMAP or JMAP server failed during a pull; retried with backoffWait, or reconnect the mailbox
MAIN-MAIL-0007The UIDVALIDITY or JMAP state can't be continued; the mailbox is pulled againNothing; no duplicates are stored
MAIN-MAIL-0008mail is invalid: an address outside domains, an ses inbound with no secret, or an unlisted pull providerFix the key the finding names
MAIN-MAIL-0009Code runs an SMTP serverReceive through the provider webhook instead
MAIN-MAIL-0010An Authentication-Results header from a server other than the provider's was ignoredNothing; the provider's own verdicts stand

mainsdk check has a mail gate for MAIN-MAIL-0008 and MAIN-MAIL-0009, each with its file and line. Each code's cause and fix is in check codes.

Swapping drivers

The inbound provider is one line: change inbound.provider and the verifier you pass, and nothing else moves. The same messages, threads and handlers work for ses, postmark, mailgun and cloudflare.

Testing

@mainsdk/testing has rawMail, a builder of raw messages, and fakeImap and fakeJmap, servers that keep a mailbox in memory. Post a signed mailgun body to mail.receive and read the result with mail.list. See Test your app.

Security and privacy

  • A post is verified (SNS certificate, Basic credentials, HMAC or Standard Webhooks) before its body is parsed, and a replayed delivery runs the handler once.
  • HTML is sanitised with the built-in allowlist, and cid: images point at signed files URLs.
  • privacy.export holds a person's messages and attachment files; privacy.erase deletes them, revokes the connection and lists each in the erasure record.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet