---
title: "Receive and thread email"
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: "guides"
status: "Building for 1.2"
spec: "mail"
canonical: "https://mainsdk.dev/docs/guides/use-mail"
updated: "2026-10-28"
---

# Receive and thread email

> 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 · Canonical: https://mainsdk.dev/docs/guides/use-mail · Updated: 2026-10-28

You will add a support inbox to your app: mail arrives through your provider's webhook, a handler opens a ticket, replies thread back by a signed address, and a person connects their own Gmail. It takes about 30 minutes.

> **Note:** `mail` is experimental (spec `mail`) and runs from the repository; it is not published to npm.

## You need

- A MainSDK app with a `db` section, `files` and `secrets`.
- An inbound provider (Amazon SES, Postmark, Mailgun or Cloudflare Email Routing) with its MX records pointing at it, and its signing secret in `secrets`.

## 1. Declare the mailboxes

```yaml title="main.yaml"
mail:
  domains: [in.acme.app]
  inbound: { provider: mailgun, secret: secret:MAIL_INBOUND_SECRET }
  mailboxes:
    support: { address: support@in.acme.app, owner: tenant }
  reply: { domain: in.acme.app }
```

## 2. Receive and handle

```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 tenant: MailDeps["tenant"];
declare const subject: MailDeps["subject"];
declare const appKey: string;
declare const tickets: { open(t: { from: string; subject: string; body: string }): Promise<void> };
import { secrets } from "mainsdk";

const mail = new Mail({ config: app.config.mail, stores: app.sqlStores, tenant, subject, secret: appKey, files: app.files,
  verify: presetVerifier("mailgun", { secrets: [await secrets.get("MAIL_INBOUND_SECRET")] }) });

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

An unverified post gets 400 and nothing is parsed. A duplicate delivery runs the handler once.

## 3. Reply by email

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

declare const mail: Mail;
declare const ticket: { threadId: string };
declare const customer: { 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: ticket.threadId });
await sender.send({ to: customer.email, replyTo, headers: await mail.headers({ replyTo: lastMessageId }) });
```

`email.send` does not take a reply address or headers yet, so pass these to whichever sender you use. The customer's reply to `replyTo` joins the thread, and `replyText` holds only the new lines.

## 4. Show a message safely

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

declare const mail: Mail;
declare const id: string;

const m = await mail.get(id);   // m.html has no scripts; remote images are blocked
await mail.allowImages(id);     // when the reader asks to see them
```

## 5. 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" });
```

Pass an `imap` port (and `http` for JMAP providers) to `Mail`; the tests use `fakeImap` and `fakeJmap` from `@mainsdk/testing`. `mail.pull("my-gmail")` fetches only what is new.

## Check it worked

- `pnpm mainsdk check` passes with no `MAIN-MAIL-0008` or `MAIN-MAIL-0009` finding.
- A signed test post shows in `mail.list({ mailbox: "support" })` and runs your handler once.
- A reply to the `replyTo` address has the same `thread` as the message it answers.

## If it fails

- `MAIN-MAIL-0008`: a mailbox address outside `domains`, or an `ses` inbound with no secret. Fix the key the finding names.
- `MAIN-MAIL-0009`: code runs an SMTP server. Receive through the webhook instead.
- `MAIN-MAIL-0005` in the log: a reply token expired or was changed, so threading used the headers.

## Prompt for your coding agent

> Add a support inbox to my app with MainSDK's `mail` interface. Verify the provider webhook, open a ticket for each message to support@, thread replies with a signed reply address, show message HTML safely, and let a person connect their Gmail.
