Inbound mail and mailboxes
mailReceive 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.
mailFree in your app, Apache-2.0Status 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.
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 }import { mail } from "mainsdk";
mail.on("support@", async (m) => {
await tickets.open({ from: m.from.address, subject: m.subject, body: m.replyText ?? m.text });
});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()
Drivers
7 drivers. Select one to see its line in main.yaml.
mail:
driver: sesChange this one line to swap providers; your calls stay the same.Amazon SES inbound integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-MAIL-0008 | Checkmainsdk check, before deploy | mail is invalid: a mailbox outside domains, an inbound provider with no secret, or a pull provider not under connections.providers |
MAIN-MAIL-0009 | Checkmainsdk check, before deploy | Code listens for SMTP: a server on port 25, 465 or 587, or an SMTP server package |
MAIN-MAIL-0001 | RuntimeError responses and logs | No mailbox or route matches the recipient; dropped and logged |
MAIN-MAIL-0002 | RuntimeError responses and logs | A raw message can't be parsed, or passes the size, part or nesting limit |
MAIN-MAIL-0003 | RuntimeError responses and logs | Rejected by mail.reject: DMARC failed or a virus was found |
MAIN-MAIL-0004 | RuntimeError responses and logs | An attachment was refused by size, type or scan; a placeholder is kept |
MAIN-MAIL-0005 | RuntimeError responses and logs | A reply address token is invalid, expired or for another tenant; threaded by headers instead |
MAIN-MAIL-0006 | RuntimeError responses and logs | An IMAP or JMAP server failed or timed out during a pull; retried |
MAIN-MAIL-0007 | RuntimeError responses and logs | A mailbox's cursor can't be continued; it is pulled again from the start |
MAIN-MAIL-0010 | RuntimeError responses and logs | An 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| mail@1 contract | Phase 3 | 1.2 | mail#contract |
| Inbound from provider webhooks | Phase 3 | 1.2 | mail#inbound |
| MIME parsing in the standard library | Phase 3 | 1.2 | mail#mime |
| SPF, DKIM, DMARC and spam verdicts | Phase 3 | 1.2 | mail#verdicts |
| Attachments into files | Phase 3 | 1.2 | mail#attachments |
| Mailboxes, folders and flags | Phase 3 | 1.2 | mail#store |
| Reply-by-email threading | Phase 3 | 1.2 | mail#threads |
| Safe display of message HTML | Phase 3 | 1.2 | mail#display |
| IMAP and JMAP mailbox pulls | Phase 3 | 1.2 | mail#pull |
Quick start
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 }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 hereConfiguration
| Key | Default | Meaning |
|---|---|---|
store | db | Where messages live: db or local |
domains | none | Domains whose MX points at the inbound provider |
inbound.provider | none | ses, postmark, mailgun or cloudflare; verified through a webhooks preset |
inbound.path | /mainsdk/mail/inbound | The path the provider posts to |
inbound.secret | none | secret:NAME for the provider's signing secret |
mailboxes | none | Each 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-size | 25MB | The 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
| Call | What 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, labelledpersonal. - Mailbox tokens never reach app code, messages or logs.
Examples
Reply by email
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
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
| Code | When | Fix |
|---|---|---|
MAIN-MAIL-0001 | No mailbox or route matches the recipient, or the mailbox is missing or denied; the provider still gets 200 | Add a mailbox or route for the address |
MAIN-MAIL-0002 | A raw message can't be parsed or passes max-size, 100 parts or 10 levels; kept as a rejected record | Raise max-size or reject the sender |
MAIN-MAIL-0003 | Rejected by mail.reject: DMARC failed or a virus was found | Nothing; no handler ran |
MAIN-MAIL-0004 | An attachment was refused (size, type or scan); the message keeps a placeholder | Read the reason on the placeholder |
MAIN-MAIL-0005 | A reply token is invalid, expired or for another tenant; threading falls back to headers | Send replies to a fresh replyAddress |
MAIN-MAIL-0006 | An IMAP or JMAP server failed during a pull; retried with backoff | Wait, or reconnect the mailbox |
MAIN-MAIL-0007 | The UIDVALIDITY or JMAP state can't be continued; the mailbox is pulled again | Nothing; no duplicates are stored |
MAIN-MAIL-0008 | mail is invalid: an address outside domains, an ses inbound with no secret, or an unlisted pull provider | Fix the key the finding names |
MAIN-MAIL-0009 | Code runs an SMTP server | Receive through the provider webhook instead |
MAIN-MAIL-0010 | An Authentication-Results header from a server other than the provider's was ignored | Nothing; 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 signedfilesURLs. privacy.exportholds a person's messages and attachment files;privacy.erasedeletes them, revokes the connection and lists each in the erasure record.
Next
- Receive and thread email: the steps, end to end.
- Files
- Connections
Was this page helpful?