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 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.
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
dbsection,filesandsecrets. - 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
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
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
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
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 them5. 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" });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 checkpasses with noMAIN-MAIL-0008orMAIN-MAIL-0009finding.- A signed test post shows in
mail.list({ mailbox: "support" })and runs your handler once. - A reply to the
replyToaddress has the samethreadas the message it answers.
If it fails
MAIN-MAIL-0008: a mailbox address outsidedomains, or ansesinbound with no secret. Fix the key the finding names.MAIN-MAIL-0009: code runs an SMTP server. Receive through the webhook instead.MAIN-MAIL-0005in 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
Was this page helpful?