MainSDK
Search⌘K
Interfaces

Messaging and notifications

Email

email

Send transactional and marketing email with one-click unsubscribe, a suppression list, no tracking pixels and no double sends.

Building for 0.3ships as a stable APIPhase 1 · Core, public betaSpec email-notifyFree in your app, Apache-2.0

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

email sends transactional and marketing email through the provider you choose, with one-click unsubscribe, a suppression list and no double sends. Use it for receipts, sign-ups and newsletters.

At a glance

email:
  driver: resend
  key: secret:RESEND_API_KEY
  from: "Shop <shop@example.com>"
  tracking: { opens: false, clicks: false }
  unsubscribe-url: https://app.example.com/unsubscribe
  webhook: resend-events
  limits: { per-minute: 60, per-day: 5000 }
  fallback: [ses]
  templates:
    receipt: { subject: "Receipt {{orderId}}", text: "Thanks for order {{orderId}}." }

Calls

  • email.send({ to, template, data, idempotencyKey? })
  • email.marketing({ to, template, data, unsubscribe, idempotencyKey? })
  • email.raw()
  • email.templates.define(name, template)
  • email.templates.has(name)
  • email.suppression.create(address, opts?)
  • email.suppression.get(address)
  • email.suppression.delete(address)
  • email.unsubscribe.create(address, opts?)
  • email.unsubscribe.accept(token)
  • email.unsubscribe.handle(req)
  • email.ingest(body, provider?)
  • email.on(listener)
  • email.info()
  • createEmail(deps)
  • parseEmail(raw)

Guarantees

Whatever the driver.

  • Marketing email does not compile without an unsubscribe
  • Unsubscribes, bounces and complaints suppressed for every later send
  • No open-tracking pixels or rewritten links by default
  • A retry never sends twice

Drivers

6 drivers. Select one to see its line in main.yaml.

main.yamlResend (default)
email:
  driver: resend

Change this one line to swap providers; your calls stay the same.Resend integration

Codes

CodeWhere it appearsMeaning
MAIN-EMAIL-0006Checkmainsdk check, before deployThe email section of main.yaml is invalid, names no driver, or lacks a value a call needs, such as email.unsubscribe-url (400)
MAIN-EMAIL-0009Checkmainsdk check, before deployEmail tracks opens or clicks; reported as a warning
MAIN-EMAIL-0010Checkmainsdk check, before deployA real email driver in production with no email.webhook, so bounces and complaints never reach the suppression list; reported as a warning
MAIN-EMAIL-0011Checkmainsdk check, before deployThe source calls email.marketing with no unsubscribe
MAIN-CHECK-0026Checkmainsdk check, before deployAn interface resolves to a local driver in production
MAIN-EMAIL-0001RuntimeError responses and logsMarketing email without an unsubscribe link (the rule behind the email.unsubscribe guarantee)
MAIN-EMAIL-0002RuntimeError responses and logsSee the spec
MAIN-EMAIL-0003RuntimeError responses and logsSee the spec
MAIN-EMAIL-0004RuntimeError responses and logsSee the spec
MAIN-EMAIL-0005RuntimeError responses and logsSee the spec
MAIN-EMAIL-0007RuntimeError responses and logsSee the spec
MAIN-EMAIL-0008RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
email@1 contractPhase 10.3email-notify#contract
Send and marketing with the unsubscribe rulePhase 10.3email-notify#email-core
Suppression listPhase 10.3email-notify#suppression
No pixels or link rewritingPhase 10.3email-notify#no-pixels
Idempotent sendsPhase 10.3email-notify#idempotency
Resend and Amazon SES drivers and the local inboxPhase 10.3email-notify#drivers-email

Quick start

Declare the driver, the sender and a template in main.yaml, then send.

main.yaml
email:
  driver: resend
  key: secret:RESEND_API_KEY
  from: "Shop <shop@example.com>"
  templates:
    receipt:
      subject: "Receipt {{orderId}}"
      text: "Thanks for order {{orderId}}."
app/orders/receipt.ts
import { email } from "mainsdk";

export async function sendReceipt(user: { email: string }, orderId: string) {
  const { id, status } = await email.send({
    to: user.email,
    template: "receipt",
    data: { orderId },
  });
  return { id, status };
}

email.send() returns { id, status }. The status is sent, or suppressed when the address cannot receive this kind of email (see Suppression). With no email section, or driver: local, the message lands in a local mailbox and no provider key is needed.

Configuration

Every key lives under email:. The key is a secret: reference; a literal key fails with MAIN-CONFIG-0004. An unknown key, a bad address or a limit that is not a number stops the app at load with MAIN-EMAIL-0006, naming the key.

main.yaml
email:
  driver: resend                       # resend | ses | local
  key: secret:RESEND_API_KEY
  from: "Shop <shop@example.com>"
  reply-to: support@example.com
  tracking: { opens: false, clicks: false }
  unsubscribe-url: https://app.example.com/unsubscribe
  webhook: resend-events               # a webhooks.inbound entry for bounces and complaints
  limits: { per-minute: 60, per-day: 5000 }
  fallback: [ses]
  templates:
    receipt: { subject: "Receipt {{orderId}}", text: "Thanks for order {{orderId}}." }
KeyTypeDefaultWhat it does
driverstringresend when key is set, else localresend, ses or local. local in production stops the app with MAIN-EMAIL-0008
keysecret referencenoneThe provider key. For ses, the secret access key (see Amazon SES)
fromstringa dev senderThe sender, Name <address>
reply-tostringnoneThe reply-to address
tracking.opensbooleanfalseKeeps open-tracking pixels when true; they are stripped otherwise
tracking.clicksbooleanfalseAllows click tracking when true
unsubscribe-urlHTTPS URLnoneWhere your unsubscribe route lives. email.unsubscribe.create() needs it
webhookstringnoneThe webhooks.inbound entry that receives provider bounce and complaint events
limits.per-minute, limits.per-dayintegernoneSend caps per tenant. More fails with MAIN-EMAIL-0005
limits.tenants.<tenant>.per-minute, .per-dayintegernoneCaps for one tenant
senders.<tenant>.from, .reply-tostringnoneA sender for one tenant
fallbacklistnoneDrivers to try in order when the first fails in a way a retry could fix
region, access-key-id, configuration-setstringnoneses only
suppression-keysecret referencefrom the project nameKeys the hash of each address in the suppression list
templates.<name>.subject, .text, .htmlstringnoneA template. It needs a subject and text or html

Turning tracking on must be explicit, and mainsdk info lists it. With the defaults, mainsdk info --json shows email.tracking: none; email.info() returns the same label.

Overlays by environment

Put only the difference in an environment overlay, for example a separate production key and driver:

main.env.prod.yaml
email:
  driver: resend
  key: secret:RESEND_PROD_API_KEY

See main.yaml for overlay order and merge rules.

What you get

  • Marketing email does not compile without an unsubscribe link.
  • Every unsubscribe, hard bounce and complaint is suppressed for later sends.
  • Nothing tracks opens or rewrites links unless you turn it on.
  • A retry never sends twice.

When any channel will do, use notify, which uses this interface for its email channel.

Templates

A template has a subject and a plain-text body, and optionally an HTML body. Placeholders are {{name}}, and {{user.name}} reads a nested value. A value that is missing or not text, a number or a boolean fails with MAIN-EMAIL-0003. HTML bodies escape every value. A template with only HTML gets a text body made from it.

Declare templates under email.templates, or register one in code. A name with a locale suffix, such as welcome.hi, is that locale's copy; pass locale: "hi-IN" and the lookup goes welcome.hi-IN, welcome.hi, welcome, welcome.en.

app/mail/templates.ts
import { email } from "mainsdk";

email.templates.define("welcome", { subject: "Welcome", text: "Hi {{name}}, welcome." });
email.templates.define("welcome.hi", { subject: "स्वागत है", text: "नमस्ते {{name}}।" });

export const hasWelcome = email.templates.has("welcome"); // true, in any locale

MainSDK ships two templates you can override by declaring a template of the same name: auth.sign-in-link and mainsdk.message.

Examples

Receipt from a Next.js server action

Send a receipt after checkout. The idempotency key ties the email to the order, so a retried action sends once.

app/checkout/actions.ts
"use server";
import { auth, email } from "mainsdk";

type Order = { id: string; email: string; total: number };

// Load the order with your own `db` call, scoped to the signed-in user.
export async function completeOrder(req: Request, order: Order) {
  await auth.require(req);

  await email.send({
    to: order.email,
    template: "receipt",
    data: { orderId: order.id, total: order.total },
    idempotencyKey: `receipt:${order.id}`,
  });
}

When you leave out idempotencyKey, MainSDK makes one from the recipient, template and data. Sending three times with the same key reaches the driver once.

Marketing email with one-click unsubscribe

email.marketing() requires an unsubscribe value; without it, tsc fails and a JavaScript caller gets MAIN-EMAIL-0001. Make the link with email.unsubscribe.create(address). It needs email.unsubscribe-url. Every marketing message carries List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers.

app/newsletter/send.ts
import { email } from "mainsdk";

export async function sendNewsletter(subscriber: { id: string; email: string }, issue: number) {
  return email.marketing({
    to: subscriber.email,
    template: "newsletter",
    data: { issue },
    unsubscribe: await email.unsubscribe.create(subscriber.email),
    idempotencyKey: `newsletter:${issue}:${subscriber.id}`,
  });
}

You can pass your own link instead: { url: "https://app.example.com/u/abc", mailto: "unsubscribe@example.com" }. The URL must be HTTPS and unsubscribe with one POST. mailto adds a second List-Unsubscribe entry.

The unsubscribe route

Mount email.unsubscribe.handle at the path in email.unsubscribe-url. A POST unsubscribes (mail providers send it for the one-click header). A GET shows a confirm button and never unsubscribes, because mail scanners open links.

app/unsubscribe/route.ts
import { email } from "mainsdk";

export const GET = (req: Request) => email.unsubscribe.handle(req);
export const POST = (req: Request) => email.unsubscribe.handle(req);

To unsubscribe from your own code, call email.unsubscribe.accept(token) with the t value from the link. A repeat gives the same answer. A link that was changed or is older than 400 days fails with MAIN-EMAIL-0007.

Suppression and unsubscribe

Bounces and complaints block all email to an address. An unsubscribe blocks marketing only: a receipt still goes. A bounce or complaint is never downgraded to an unsubscribe. A send to a blocked address does not reach the driver, and the result is { status: "suppressed", reason }.

The list is keyed by an HMAC of the address, so it holds no addresses. Add, read and remove entries yourself:

app/admin/suppression.ts
import { email } from "mainsdk";

export async function block(address: string) {
  await email.suppression.create(address, { reason: "manual", source: "support-ticket" });
}

export async function why(address: string) {
  return email.suppression.get(address); // { reason, at, source? } or null
}

export async function allowAgain(address: string) {
  return email.suppression.delete(address); // true when it was on the list
}

The reasons are unsubscribe, bounce, complaint and manual. Changes are written to the audit log with the key, never the address.

Bounces and complaints from the provider

Set email.webhook to the name of a webhooks.inbound entry. MainSDK registers a handler on it that reads the provider's events and updates the list. Resend signs with Svix, so it uses the standard preset; SES delivers through SNS, so it uses ses.

main.yaml
email:
  driver: resend
  key: secret:RESEND_API_KEY
  webhook: resend-events
webhooks:
  inbound:
    resend-events:
      preset: standard
      secret: secret:RESEND_WEBHOOK_SECRET
      path: /api/webhooks/resend

A hard bounce and a complaint suppress the address. A soft bounce does not. email.ingest(body, provider?) does the same for a verified body you receive another way; it returns { events, suppressed }. Without a webhook in production, mainsdk check warns with MAIN-EMAIL-0010.

Listen for what happens with email.on. It returns a function that stops listening. A notice holds the address's key, never the address.

app/mail/events.ts
import { email } from "mainsdk";

export const stop = email.on((notice) => {
  if (notice.type === "bounce") console.warn("bounce", notice.severity, notice.key);
});

Sign-up email from a route handler

server/signup.ts
import { email } from "mainsdk";

export async function POST(req: Request) {
  const { address } = (await req.json()) as { address: string };
  // create the account here
  await email.send({
    to: address,
    template: "welcome",
    data: { name: "there" },
    idempotencyKey: `welcome:${address}`,
  });
  return Response.json({ ok: true }, { status: 201 });
}

A sender and limits per tenant

In a multi-tenant app, each tenant can send from its own address and have its own caps. Without an entry, the tenant gets the app's from and limits.

main.yaml
email:
  from: "Acme <hello@acme.example>"
  senders:
    tnt_1: { from: "Pine <hello@pine.example>", reply-to: help@pine.example }
  limits:
    per-minute: 60
    per-day: 5000
    tenants:
      tnt_1: { per-day: 500 }

A send over the cap fails with MAIN-EMAIL-0005 (429) and a retryAfter in seconds.

When an email section exists and the app passes its own sender nowhere, sign-in links from links.signIn and auth's own mail (invites and notices) go out through email.send with the built-in auth.sign-in-link and mainsdk.message templates. A sign-in link to a phone number goes by WhatsApp through notify. A suppressed address gets no link, and a warning is logged. In production, a links section with no email or notify section is reported as MAIN-CHECK-0026.

Check messages in the local mailbox

email.driver: local keeps every message in process, with its rendered subject, bodies and headers, and prints nothing. In mainsdk dev it also appends each message as a JSON line to .mainsdk/email/outbox.jsonl in the project, so a restart keeps the outbox.

npx mainsdk dev

The local driver is for development and tests only. In production the app refuses to start with MAIN-EMAIL-0008.

Errors and checks

Errors are MainError values that serialise as RFC 9457 problem details with a code, a fix and a docs link. Run mainsdk explain <code> for the cause and fix offline.

CodeWhereMeaning and fix
MAIN-EMAIL-0001runtimeemail.marketing had no unsubscribe link, or one that is not HTTPS or holds a character a header cannot carry. Pass email.unsubscribe.create(address)
MAIN-EMAIL-0002runtimeThe recipient is not a single address, or a header or the subject holds a line break
MAIN-EMAIL-0003runtimeThe template does not exist, or a placeholder has no value in data
MAIN-EMAIL-0004runtimeThe provider could not send; retryable says whether a retry or a fallback may help (502)
MAIN-EMAIL-0005runtimeThe per-minute or per-day send limit is reached (429, retryAfter)
MAIN-EMAIL-0006check, runtimeThe email section is invalid, names no driver, or lacks a value a call needs, such as unsubscribe-url
MAIN-EMAIL-0007runtimeThe unsubscribe link was changed or is not valid
MAIN-EMAIL-0008runtimeemail.driver: local in production; the app refuses to start
MAIN-EMAIL-0009checkEmail tracks opens or clicks (warning)
MAIN-EMAIL-0010checkA real driver in production with no email.webhook (warning)
MAIN-EMAIL-0011checkThe source calls email.marketing with no unsubscribe
MAIN-CHECK-0026checkA local driver, or links with no email, in production

The checks run in the email gate of mainsdk check. All codes are in check codes and errors. With MAINSDK_DEBUG=driver in dev, each provider call prints one line with driver, operation, duration and status, and no message body.

Swapping drivers

Change one line. Your email.send() and email.marketing() calls, templates and suppression list stay the same.

main.yaml
email:
  driver: ses   # was resend
  region: eu-west-1

Verify your sending domain with the new provider first. Email is stateless, so the swap needs no migration. To fail over when a provider is down, list a fallback; MainSDK tries the next driver only when the failure is retryable:

main.yaml
email:
  driver: resend
  fallback: [ses]

fallback: { email: [resend, ses] } at the top level of main.yaml works the same when email.fallback is empty. When you need a provider-only feature, email.raw() returns the driver. mainsdk check lists every use.

DriverStatusPage
resendBuilt, request shape unverified against the live APIResend
sesBuilt, request shape unverified against the live APIAmazon SES
localBuilt, development and tests onlythis page
postmark, bird, smtpNot builtnone

Testing

createTestApp() runs email on the local driver. Read the mailbox with mailbox(app) from @mainsdk/testing. It holds the same guarantees as production: a marketing email without an unsubscribe fails, and suppressed addresses are not sent.

app/orders/receipt.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, mailbox } from "@mainsdk/testing";
import { email } from "mainsdk";

async function sendReceipt(order: { email: string }, orderId: string) {
  await email.send({ to: order.email, template: "receipt", data: { orderId }, idempotencyKey: `receipt:${orderId}` });
}

describe("receipt email", () => {
  it("email-notify AC8 sends once when retried with the same key", async () => {
    const app = createTestApp({
      yaml: [
        "schema: 1",
        "project: shop",
        "email:",
        "  templates:",
        "    receipt: { subject: 'Receipt {{orderId}}', text: 'Thanks.' }",
      ].join("\n"),
    });
    await sendReceipt({ email: "a@pinecart.dev" }, "ord_1");
    mailbox(app).toHaveSent({ to: "a@pinecart.dev", template: "receipt", times: 1 });
    assert.equal(mailbox(app).last()?.subject, "Receipt ord_1");
  });
});

The mailbox has outbox, last(to?), clear(), failNext(times, retryable?) to simulate a provider outage, and toHaveSent({ to?, template?, kind?, times? }). When no message matches, the assertion lists what was sent, with recipients masked. fakeMessagingServer("resend") and fakeMessagingServer("ses") record the requests a real driver makes, with no network.

Security and privacy

  • No open-tracking pixels or rewritten links by default.
  • The suppression list holds a keyed hash of each address, never the address. Logs and audit records show a masked address.
  • The provider key is a secret reference read on the server, never in code. Message bodies are not logged.
  • A retry with the same idempotency key never sends twice. Resend is also given the key.
  • Every provider call uses the network layer, and each driver declares its host: api.resend.com, or email.<region>.amazonaws.com.

FAQ

Can I track opens? Yes, by setting tracking.opens: true. It is explicit in main.yaml, shown by mainsdk info and reported as a MAIN-EMAIL-0009 warning.

Do transactional emails need an unsubscribe? No. Only email.marketing() requires one.

What happens when an address bounces? A hard bounce adds it to the suppression list. Later sends to it, transactional or marketing, return suppressed without reaching the provider.

Why did a receipt go to someone who unsubscribed? An unsubscribe blocks marketing only. A receipt is transactional. Bounces and complaints block both.

Are Postmark, Bird and SMTP available? Not yet. They come through the driver kit later.

Next

Helpers for wiring and tests

CallWhat it does
createEmail(deps)Build the email interface from its dependencies, for tests and custom wiring.
parseEmail(raw)Read the email section of main.yaml into settings.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet