MainSDK
Search⌘K
How-to

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.

Building for 1.2

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

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.

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

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.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet