MainSDK
Search⌘K
How-to

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.

Building for 0.3

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

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.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • 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

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

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.

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:

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:

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:

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

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:

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, WhatsApp and 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

CodeWhat it meansFix
MAIN-EMAIL-0011The source calls email.marketing with no unsubscribe.Pass unsubscribe: await email.unsubscribe.create(address).
MAIN-EMAIL-0006The email section is invalid, or unsubscribe-url is not set where a call needs it.Fix the key the message names.
MAIN-EMAIL-0009Email tracks opens or clicks.Set tracking: { opens: false, clicks: false } unless people agreed to be tracked.
MAIN-EMAIL-0010A real driver in production has no email.webhook.Add the inbound entry and name it.
MAIN-NOTIFY-0001The notify section is invalid or a provider block is missing.Add the block the message names.
MAIN-CHECK-0026A local driver in production.Set a production driver in main.env.prod.yaml.
MAIN-NET-0007mainsdk 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet