---
title: "Inbound mail and mailboxes"
description: "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."
section: "interfaces"
status: "Building for 1.2 · ships as a experimental API"
spec: "mail"
canonical: "https://mainsdk.dev/docs/interfaces/mail"
updated: "2026-10-28"
---

# Inbound mail and mailboxes

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

Status: Building for 1.2 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/mail · 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.

> **Note:** Experimental. Import it from `mainsdk/experimental/mail` and build a `Mail` with your stores and ports; the Node webhook verifier is in `mainsdk/experimental/mail-node`. It is not on the `app` object yet, and the IMAP wire client is a port you pass in (the tests use a fake server).

## At a glance

- Status: Building for 1.2 · ships as a experimental API
- Phase: 3 (Mobile and regulated)
- Milestone: 1.2 Regulated industries
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/mail.md
- Category: Messaging and notifications

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()`

In main.yaml:

```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 }
```

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

Pick one in main.yaml with `mail.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Amazon SES inbound | `ses` |  |  |
| Postmark inbound | `postmark` |  |  |
| Mailgun routes | `mailgun` |  |  |
| Cloudflare Email Routing | `cloudflare` |  |  |
| IMAP (with a connection) | `imap` |  |  |
| JMAP (with a connection) | `jmap` |  |  |
| Local (tests and mainsdk dev) | `local` |  |  |

## Codes

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

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| mail@1 contract | 3 | 1.2 | `mail#contract` |
| Inbound from provider webhooks | 3 | 1.2 | `mail#inbound` |
| MIME parsing in the standard library | 3 | 1.2 | `mail#mime` |
| SPF, DKIM, DMARC and spam verdicts | 3 | 1.2 | `mail#verdicts` |
| Attachments into files | 3 | 1.2 | `mail#attachments` |
| Mailboxes, folders and flags | 3 | 1.2 | `mail#store` |
| Reply-by-email threading | 3 | 1.2 | `mail#threads` |
| Safe display of message HTML | 3 | 1.2 | `mail#display` |
| IMAP and JMAP mailbox pulls | 3 | 1.2 | `mail#pull` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="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 }
```

```ts title="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

| 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`, 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

| 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](https://mainsdk.dev/docs/reference/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](https://mainsdk.dev/docs/guides/testing).

## 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

- [Receive and thread email](https://mainsdk.dev/docs/guides/use-mail): the steps, end to end.
- [Email](https://mainsdk.dev/docs/interfaces/email)
- [Files](https://mainsdk.dev/docs/interfaces/files)
- [Connections](https://mainsdk.dev/docs/interfaces/connections)
