---
title: "Email"
description: "Send transactional and marketing email with one-click unsubscribe, a suppression list, no tracking pixels and no double sends."
section: "interfaces"
status: "Building for 0.3 · ships as a stable API"
spec: "email-notify"
canonical: "https://mainsdk.dev/docs/interfaces/email"
updated: "2026-10-28"
---

# Email

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

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

> **Note:** Built: `send`, `marketing`, templates, the suppression list, unsubscribe links, bounce and complaint events, per-tenant sender and limits, and the `resend`, `ses` and `local` drivers. Not built: Postmark, Bird and SMTP drivers. The provider request shapes are written from each provider's documentation and are unverified against the live API; verify with a sandbox key before production.

## At a glance

- Status: Building for 0.3 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/email-notify.md
- Category: Messaging and notifications

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

In main.yaml:

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

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

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Resend | `resend` | yes |  |
| Amazon SES | `ses` |  |  |
| Local inbox | `local` |  |  |
| Postmark | `postmark` |  | planned driver |
| Bird | `bird` |  | planned driver |
| SMTP | `smtp` |  | planned driver |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-EMAIL-0006` | check | The `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-0009` | check | Email tracks opens or clicks; reported as a warning |
| `MAIN-EMAIL-0010` | check | A real email driver in production with no `email.webhook`, so bounces and complaints never reach the suppression list; reported as a warning |
| `MAIN-EMAIL-0011` | check | The source calls `email.marketing` with no `unsubscribe` |
| `MAIN-CHECK-0026` | check | An interface resolves to a local driver in production |
| `MAIN-EMAIL-0001` | runtime | Marketing email without an unsubscribe link (the rule behind the `email.unsubscribe` guarantee) |
| `MAIN-EMAIL-0002` | runtime |  |
| `MAIN-EMAIL-0003` | runtime |  |
| `MAIN-EMAIL-0004` | runtime |  |
| `MAIN-EMAIL-0005` | runtime |  |
| `MAIN-EMAIL-0007` | runtime |  |
| `MAIN-EMAIL-0008` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| email@1 contract | 1 | 0.3 | `email-notify#contract` |
| Send and marketing with the unsubscribe rule | 1 | 0.3 | `email-notify#email-core` |
| Suppression list | 1 | 0.3 | `email-notify#suppression` |
| No pixels or link rewriting | 1 | 0.3 | `email-notify#no-pixels` |
| Idempotent sends | 1 | 0.3 | `email-notify#idempotency` |
| Resend and Amazon SES drivers and the local inbox | 1 | 0.3 | `email-notify#drivers-email` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

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

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

```ts title="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](#suppression-and-unsubscribe)). 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.

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

| Key | Type | Default | What it does |
| --- | --- | --- | --- |
| `driver` | string | `resend` when `key` is set, else `local` | `resend`, `ses` or `local`. `local` in production stops the app with `MAIN-EMAIL-0008` |
| `key` | secret reference | none | The provider key. For `ses`, the secret access key (see [Amazon SES](https://mainsdk.dev/docs/integrations/amazon-ses)) |
| `from` | string | a dev sender | The sender, `Name <address>` |
| `reply-to` | string | none | The reply-to address |
| `tracking.opens` | boolean | `false` | Keeps open-tracking pixels when `true`; they are stripped otherwise |
| `tracking.clicks` | boolean | `false` | Allows click tracking when `true` |
| `unsubscribe-url` | HTTPS URL | none | Where your unsubscribe route lives. `email.unsubscribe.create()` needs it |
| `webhook` | string | none | The `webhooks.inbound` entry that receives provider bounce and complaint events |
| `limits.per-minute`, `limits.per-day` | integer | none | Send caps per tenant. More fails with `MAIN-EMAIL-0005` |
| `limits.tenants.<tenant>.per-minute`, `.per-day` | integer | none | Caps for one tenant |
| `senders.<tenant>.from`, `.reply-to` | string | none | A sender for one tenant |
| `fallback` | list | none | Drivers to try in order when the first fails in a way a retry could fix |
| `region`, `access-key-id`, `configuration-set` | string | none | `ses` only |
| `suppression-key` | secret reference | from the project name | Keys the hash of each address in the suppression list |
| `templates.<name>.subject`, `.text`, `.html` | string | none | A 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:

```yaml title="main.env.prod.yaml"
email:
  driver: resend
  key: secret:RESEND_PROD_API_KEY
```

See [main.yaml](https://mainsdk.dev/docs/concepts/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`](https://mainsdk.dev/docs/interfaces/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`.

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

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

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

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

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

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

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

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

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

### Sign-in links and auth mail

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`](https://mainsdk.dev/docs/interfaces/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.

npm:

```bash
npx mainsdk dev
```

pnpm:

```bash
pnpm 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.

| Code | Where | Meaning and fix |
| --- | --- | --- |
| `MAIN-EMAIL-0001` | runtime | `email.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-0002` | runtime | The recipient is not a single address, or a header or the subject holds a line break |
| `MAIN-EMAIL-0003` | runtime | The template does not exist, or a placeholder has no value in `data` |
| `MAIN-EMAIL-0004` | runtime | The provider could not send; `retryable` says whether a retry or a fallback may help (502) |
| `MAIN-EMAIL-0005` | runtime | The per-minute or per-day send limit is reached (429, `retryAfter`) |
| `MAIN-EMAIL-0006` | check, runtime | The `email` section is invalid, names no driver, or lacks a value a call needs, such as `unsubscribe-url` |
| `MAIN-EMAIL-0007` | runtime | The unsubscribe link was changed or is not valid |
| `MAIN-EMAIL-0008` | runtime | `email.driver: local` in production; the app refuses to start |
| `MAIN-EMAIL-0009` | check | Email tracks opens or clicks (warning) |
| `MAIN-EMAIL-0010` | check | A real driver in production with no `email.webhook` (warning) |
| `MAIN-EMAIL-0011` | check | The source calls `email.marketing` with no `unsubscribe` |
| `MAIN-CHECK-0026` | check | A 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](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/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.

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

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

| Driver | Status | Page |
| --- | --- | --- |
| `resend` | Built, request shape unverified against the live API | [Resend](https://mainsdk.dev/docs/integrations/resend) |
| `ses` | Built, request shape unverified against the live API | [Amazon SES](https://mainsdk.dev/docs/integrations/amazon-ses) |
| `local` | Built, development and tests only | this page |
| `postmark`, `bird`, `smtp` | Not built | none |

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

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

- [Notifications](https://mainsdk.dev/docs/interfaces/notify): reach a person on whichever channel they allow.
- [Send email and notifications](https://mainsdk.dev/docs/guides/send-email-and-notifications): the steps, from `main.yaml` to a passing test.
- [Webhooks](https://mainsdk.dev/docs/interfaces/webhooks): the inbound endpoint for bounces.
- [Testing kit](https://mainsdk.dev/docs/interfaces/testing): fakes and assertions.

## Helpers for wiring and tests

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