---
title: "Send email and notifications"
description: "Send a receipt, a newsletter with one-click unsubscribe and an order update on WhatsApp, SMS or email, with consent and quiet hours, offline."
section: "guides"
status: "Building for 0.3"
spec: "email-notify"
canonical: "https://mainsdk.dev/docs/guides/send-email-and-notifications"
updated: "2026-10-28"
---

# Send email and notifications

> Send a receipt, a newsletter with one-click unsubscribe and an order update on WhatsApp, SMS or email, with consent and quiet hours, offline.

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/send-email-and-notifications · Updated: 2026-10-28

You will add messaging to Pinecart, a Next.js app on MainSDK: a receipt after checkout, a newsletter that carries an unsubscribe link, and an "order shipped" message that goes to WhatsApp, SMS or email, whichever the person allows. You build it offline on local drivers, test it, and finish with a passing `mainsdk check`. It takes about 30 minutes.

> **Note:** `email` and `notify` run from the repository in 0.3 (spec `email-notify`). The Resend, Amazon SES, Twilio, MSG91, WhatsApp Cloud API and Bird drivers send requests written from each provider's documentation. They are unverified against the live API; verify with a sandbox key before production. Push is not built, so the push channel is skipped.

## You need

- A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- For production later: a sending domain with Resend or Amazon SES, and for WhatsApp or SMS an account with a provider and approved templates. You need none of these to follow the steps.

## 1. Declare the email and a template

```yaml title="main.yaml"
email:
  driver: local                          # in development; production uses resend or ses
  from: "Pinecart <hello@pinecart.dev>"
  unsubscribe-url: https://app.pinecart.dev/unsubscribe
  templates:
    receipt:
      subject: "Receipt {{orderId}}"
      text: "Thanks for order {{orderId}}."
    newsletter:
      subject: "Pinecart news, issue {{issue}}"
      text: "Issue {{issue}} is out."
    shipped:
      subject: "Order {{orderId}} shipped"
      text: "Order {{orderId}} is on its way."
network:
  allow: [app.pinecart.dev]              # mainsdk check reads the URL above as a host
```

`driver: local` keeps every message in a local mailbox, so no key or account is needed. Tracking is off by default: no pixel, no rewritten links.

## 2. Send a receipt

```ts title="app/checkout/receipt.ts"
import { email } from "mainsdk";

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

The result is `{ id, status: "sent" }`. Call it again with the same key and the driver gets one message. Never build your own mailer: use `email.send` so the suppression list, the key and the limits apply.

## 3. Send a newsletter with an unsubscribe link

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

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

Without `unsubscribe` this does not compile. The message carries `List-Unsubscribe` and `List-Unsubscribe-Post` headers, so mail apps show an unsubscribe button. Serve the link at the path you put in `unsubscribe-url`:

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

A POST unsubscribes. A GET shows a confirm button and changes nothing, because mail scanners open links. After an unsubscribe, marketing to that address returns `suppressed`; receipts still go. A bounce or a complaint blocks both.

## 4. Notify a person on the channel they allow

Add the channels, a template for each, and local drivers for development:

```yaml title="main.yaml"
notify:
  channels: [whatsapp, sms, email]
  whatsapp: local
  sms: local
  quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata }
  templates:
    order.shipped:
      whatsapp: { name: order_shipped, language: en, params: [orderId] }
      sms: { text: "Pinecart: order {{orderId}} shipped", dlt-template-id: "1007000000000001" }
      email: shipped
```

The `email: shipped` line points the email channel at the `shipped` template from step 1. Send, and record consent when a person agrees to WhatsApp or SMS:

```ts title="app/orders/ship.ts"
import { notify } from "mainsdk";

type Customer = { id: string; email: string; phone: string };

export async function agreeToWhatsApp(customer: Customer) {
  await notify.contact.update(customer.id, { phone: customer.phone, email: customer.email });
  return notify.consent.create(customer.id, "whatsapp", { source: "checkout-form" });
}

export async function orderShipped(customer: Customer, orderId: string) {
  return notify(customer, {
    template: "order.shipped",
    data: { orderId },
    category: "orders",
    idempotencyKey: `order.shipped:${orderId}`,
  });
}
```

The result says which channel carried the message and every channel it skipped:

- A person with no recorded consent is skipped on WhatsApp with `MAIN-NOTIFY-0004` and on SMS with `MAIN-NOTIFY-0003`; email is the fallback.
- An India number needs `dlt-template-id` for SMS (`MAIN-NOTIFY-0005` without it).
- A person can opt out of a channel or a category with `notify.preferences(id).set({ channels: { sms: false }, optOut: ["marketing"] })`.
- A message that is not urgent, sent in the person's quiet hours, comes back `held` with a `deliverAt` time and goes out when the hours end. Pass `urgent: true` for a security alert.

## 5. Test it offline

```ts title="app/messaging.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { email, notify } from "mainsdk";
import { channels, createTestApp, mailbox } from "@mainsdk/testing";

// The functions from the steps above.
declare function sendReceipt(to: string, orderId: string): Promise<unknown>;
declare function sendIssue(to: string, issue: number): Promise<{ status: string }>;
declare function orderShipped(
  customer: { id: string; email: string; phone: string },
  orderId: string,
): Promise<{ channel: string; skipped: { code: string }[] }>;

const yaml = [
  "schema: 1",
  "project: pinecart",
  "email:",
  "  from: 'Pinecart <hello@pinecart.dev>'",
  "  unsubscribe-url: https://app.pinecart.dev/unsubscribe",
  "  templates:",
  "    receipt: { subject: 'Receipt {{orderId}}', text: 'Thanks.' }",
  "    newsletter: { subject: 'News {{issue}}', text: 'Issue {{issue}}.' }",
  "    shipped: { subject: 'Shipped {{orderId}}', text: 'On its way.' }",
  "notify:",
  "  channels: [whatsapp, email]",
  "  whatsapp: local",
  "  templates:",
  "    order.shipped:",
  "      whatsapp: { name: order_shipped, language: en, params: [orderId] }",
  "      email: shipped",
].join("\n");

const jane = { id: "u1", email: "jane@example.com", phone: "+919876543210" };

describe("messaging", () => {
  it("email-notify AC8 sends a receipt once", async () => {
    const app = createTestApp({ yaml });
    await sendReceipt(jane.email, "A1");
    await sendReceipt(jane.email, "A1");
    mailbox(app).toHaveSent({ template: "receipt", times: 1 });
  });

  it("email-notify AC1 sends a newsletter with the one-click headers", async () => {
    const app = createTestApp({ yaml });
    await sendIssue(jane.email, 7);
    const headers = mailbox(app).last(jane.email)?.headers;
    assert.equal(headers?.["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
  });

  it("email-notify AC3 does not send marketing to an address that unsubscribed", async () => {
    const app = createTestApp({ yaml });
    await email.suppression.create(jane.email, { reason: "unsubscribe" });
    const result = await sendIssue(jane.email, 8);
    assert.equal(result.status, "suppressed");
    mailbox(app).toHaveSent({ template: "newsletter", times: 0 });
  });

  it("email-notify AC2 uses WhatsApp only after consent", async () => {
    const app = createTestApp({ yaml });
    const first = await orderShipped(jane, "A2");
    assert.equal(first.channel, "email");
    assert.equal(first.skipped[0]?.code, "MAIN-NOTIFY-0004");

    await notify.consent.create(jane, "whatsapp", { source: "test" });
    const second = await orderShipped(jane, "A3");
    assert.equal(second.channel, "whatsapp");
    assert.deepEqual(channels(app).whatsapp?.last()?.params, { orderId: "A3" });
  });
});
```

`mailbox(app)` reads the local mailbox and `channels(app)` reads the local SMS and WhatsApp channels. Neither touches the network. To drill a provider outage, call `mailbox(app).failNext(1)`.

## 6. Go to production

Local drivers are refused in production: `email.driver: local` stops the app with `MAIN-EMAIL-0008`, and a local `notify.sms` or `notify.whatsapp` with `MAIN-NOTIFY-0011`. Put the real drivers in the production overlay, and a webhook so bounces and complaints reach the suppression list:

```yaml title="main.env.prod.yaml"
email:
  driver: resend
  key: secret:RESEND_API_KEY
  webhook: resend-events
notify:
  whatsapp: whatsapp-cloud
  sms: msg91
  whatsapp-cloud: { phone-number-id: "1234567890", token: secret:WHATSAPP_TOKEN }
  msg91: { key: secret:MSG91_AUTH_KEY }
webhooks:
  inbound:
    resend-events:
      preset: standard
      secret: secret:RESEND_WEBHOOK_SECRET
      path: /api/webhooks/resend
```

MSG91 sends SMS from its own template id, so also set `notify.templates.order.shipped.sms.template` and `vars` in `main.yaml` when you use it. Register your templates with each provider before the first live send. See [Resend](https://mainsdk.dev/docs/integrations/resend), [WhatsApp](https://mainsdk.dev/docs/integrations/whatsapp) and [MSG91](https://mainsdk.dev/docs/integrations/msg91), and send one message through each provider's sandbox first.

## Verify

```bash
npx mainsdk check
npx mainsdk check --env prod
npx mainsdk info --json
```

`mainsdk check` passes in development. With the `main.env.prod.yaml` above, `mainsdk check --env prod` reports no `MAIN-CHECK-0026` (it may still warn about a missing `security.txt`, which is not about messaging), and `mainsdk info --json` shows `email.tracking: none`. Run your tests; all four pass.

## If it fails

| Code | What it means | Fix |
| --- | --- | --- |
| `MAIN-EMAIL-0011` | The source calls `email.marketing` with no `unsubscribe`. | Pass `unsubscribe: await email.unsubscribe.create(address)`. |
| `MAIN-EMAIL-0006` | The `email` section is invalid, or `unsubscribe-url` is not set where a call needs it. | Fix the key the message names. |
| `MAIN-EMAIL-0009` | Email tracks opens or clicks. | Set `tracking: { opens: false, clicks: false }` unless people agreed to be tracked. |
| `MAIN-EMAIL-0010` | A real driver in production has no `email.webhook`. | Add the inbound entry and name it. |
| `MAIN-NOTIFY-0001` | The `notify` section is invalid or a provider block is missing. | Add the block the message names. |
| `MAIN-CHECK-0026` | A local driver in production. | Set a production driver in `main.env.prod.yaml`. |
| `MAIN-NET-0007` | `mainsdk check` found a host that `network.allow` does not list, such as the unsubscribe URL's host. | Add the host. |

## For agents

```text
Goal: send email and notifications from this app.
Read: docs/specs/email-notify.md, this page, main.yaml.
Touch only: main.yaml, main.env.prod.yaml, the files that send messages, their tests.
Do: use email.send and notify with templates declared in main.yaml; never build a mailer or call a provider directly.
Pass an idempotencyKey on every send. Pass unsubscribe: await email.unsubscribe.create(address) to email.marketing.
Record notify.consent.create before any SMS or WhatsApp. Leave tracking off. Use local drivers in development only.
Verify: npx mainsdk check --json --agent, then the tests.
Never hard-code a provider key, and never send marketing without an unsubscribe link.
```

## Next

- [Email](https://mainsdk.dev/docs/interfaces/email): every call, key and code.
- [Notifications](https://mainsdk.dev/docs/interfaces/notify): channels, preferences, consent and staged broadcasts.
- [Send engagement reminders](https://mainsdk.dev/docs/guides/engagement-messages): reminders that stop when people return.
- [Testing](https://mainsdk.dev/docs/guides/testing): fakes, time control and chaos drills.
