emailSend transactional and marketing email with one-click unsubscribe, a suppression list, no tracking pixels and no double sends.
email-notifyFree in your app, Apache-2.0Status 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.
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}}." }import { email } from "mainsdk";
await email.send({
to: user.email,
template: "receipt",
data: { orderId: order.id },
});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");
});
});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)
Drivers
6 drivers. Select one to see its line in main.yaml.
email:
driver: resendChange this one line to swap providers; your calls stay the same.Resend integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-EMAIL-0006 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | Email tracks opens or clicks; reported as a warning |
MAIN-EMAIL-0010 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | The source calls email.marketing with no unsubscribe |
MAIN-CHECK-0026 | Checkmainsdk check, before deploy | An interface resolves to a local driver in production |
MAIN-EMAIL-0001 | RuntimeError responses and logs | Marketing email without an unsubscribe link (the rule behind the email.unsubscribe guarantee) |
MAIN-EMAIL-0002 | RuntimeError responses and logs | See the spec |
MAIN-EMAIL-0003 | RuntimeError responses and logs | See the spec |
MAIN-EMAIL-0004 | RuntimeError responses and logs | See the spec |
MAIN-EMAIL-0005 | RuntimeError responses and logs | See the spec |
MAIN-EMAIL-0007 | RuntimeError responses and logs | See the spec |
MAIN-EMAIL-0008 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| email@1 contract | Phase 1 | 0.3 | email-notify#contract |
| Send and marketing with the unsubscribe rule | Phase 1 | 0.3 | email-notify#email-core |
| Suppression list | Phase 1 | 0.3 | email-notify#suppression |
| No pixels or link rewriting | Phase 1 | 0.3 | email-notify#no-pixels |
| Idempotent sends | Phase 1 | 0.3 | email-notify#idempotency |
| Resend and Amazon SES drivers and the local inbox | Phase 1 | 0.3 | email-notify#drivers-email |
Quick start
Declare the driver, the sender and a template in main.yaml, then send.
email:
driver: resend
key: secret:RESEND_API_KEY
from: "Shop <shop@example.com>"
templates:
receipt:
subject: "Receipt {{orderId}}"
text: "Thanks for order {{orderId}}."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.
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) |
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:
email:
driver: resend
key: secret:RESEND_PROD_API_KEYSee 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.
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 localeMainSDK 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.
"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.
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.
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:
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.
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/resendA 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.
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
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.
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. 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 devpnpm mainsdk devThe 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 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.
email:
driver: ses # was resend
region: eu-west-1Verify 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:
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 |
ses | Built, request shape unverified against the live API | 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.
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, oremail.<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: reach a person on whichever channel they allow.
- Send email and notifications: the steps, from
main.yamlto a passing test. - Webhooks: the inbound endpoint for bounces.
- Testing kit: 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. |
Was this page helpful?