← All lessons/Add-ons
54
Add-ons

Mail Add-on

Full two-way email inside sapix-agent: outbound via Resend or SMTP, inbound via Cloudflare email-router, delivery webhooks, suppression list, minijinja templates, and per-inbox ACL — all backed by the immutable strand.

Prerequisite: Lesson 5 complete

What you'll learn

  • SAPIX_MAIL_ENABLED=true — Resend (preferred) or SMTP fallback
  • POST /v1/mail/send — to, subject, body, html_body; or template_id + vars
  • Status lifecycle: queued → sent → delivered | bounced | complained
  • Inbound: POST /v1/mail/addresses + POST /v1/mail/inbound via Cloudflare worker
  • GET /v1/mail/inbox/:agent_id — per-agent inbox with attachment BlobStore promotion
  • POST /v1/mail/webhook — Resend delivery events, Svix HMAC-SHA256 signature validation
  • POST /v1/mail/suppress — CAN-SPAM / GDPR suppression list; blocks sends automatically
  • POST /v1/mail/templates — named minijinja templates; send via template_id + vars
  • Per-inbox ACL: mail:inbox:{agent_id} scopes restrict key access to specific inboxes
  • SAPIX_MAIL_BOUNCE_ADDRESS — inbound NDR auto-marks original message as bounced
Challenge

Enable the mail add-on with Resend. Send a templated welcome email using a stored template with variables. Register an address for your agent. Simulate an inbound reply using POST /v1/mail/inbound and read it via GET /v1/mail/inbox/:agent_id. Add the sender to the suppression list and verify the next send attempt returns 400.

# Lesson 54 — Mail Add-on

## Overview

The Mail add-on turns sapix-agent into a full two-way email system — outbound delivery via Resend or SMTP, inbound routing via Cloudflare email-router, delivery tracking via webhooks, a suppression list for compliance, and a minijinja template library. Every send, receive, and delivery event is written as an immutable strand record.

---

## Activation

`bash SAPIX_MAIL_ENABLED=true

# Resend (preferred) SAPIX_MAIL_RESEND_API_KEY=re_...

# SMTP fallback (requires --features mail) SAPIX_MAIL_SMTP_HOST=smtp.example.com SAPIX_MAIL_SMTP_PORT=587 SAPIX_MAIL_SMTP_USER=user@example.com SAPIX_MAIL_SMTP_PASS=secret

SAPIX_MAIL_FROM_ADDRESS=noreply@yourdomain.com SAPIX_MAIL_FROM_NAME="My App"

# Phase 3: webhooks + bounce SAPIX_MAIL_WEBHOOK_SECRET=whsec_... SAPIX_MAIL_BOUNCE_ADDRESS=bounce@yourdomain.com `

---

## Outbound: Send an Email

`bash POST /v1/mail/send { "to": ["alice@example.com"], "subject": "Hello", "body": "Hi Alice" }

# Response { "id": "msg_...", "status": "sent" } `

Status lifecycle: queuedsentdelivered | bounced | complained

Returns 400 if any recipient is suppressed.

---

## Templates (minijinja)

`bash # Create a template POST /v1/mail/templates { "name": "welcome", "subject": "Welcome, {{ name }}!", "body": "Hi {{ name }},\n\nActivate: {{ url }}" }

# Send via template POST /v1/mail/send { "to": ["alice@example.com"], "template_id": "welcome", "vars": { "name": "Alice", "url": "https://..." } }

GET /v1/mail/templates GET /v1/mail/templates/welcome DELETE /v1/mail/templates/welcome `

---

## Inbound: Receive Email

Step 1 — Register an address: `bash POST /v1/mail/addresses { "email": "orders@yourdomain.com", "agent_id": "orders" } `

Step 2 — Configure Cloudflare email-router to POST to /v1/mail/inbound.

Step 3 — Read the inbox: `bash GET /v1/mail/inbox/orders?limit=20 GET /v1/mail/inbox/orders/imsg_... `

Inbound attachments are promoted to BlobStore when SAPIX_BLOB_DIR is set — data_base64 is replaced with blob_hash.

---

## Delivery Webhooks

Configure Resend to POST events to /v1/mail/webhook. Set SAPIX_MAIL_WEBHOOK_SECRET=whsec_... to validate Svix HMAC-SHA256 signatures.

Events handled: email.delivered, email.bounced, email.complained, email.opened, email.clicked.

Each event updates MailMessage.status and appends to delivery_events[].

---

## Bounce Detection

Set SAPIX_MAIL_BOUNCE_ADDRESS=bounce@yourdomain.com. When an NDR arrives at that address, SapixDB reads In-Reply-To, finds the original message, marks it bounced, and does not store the bounce in any inbox.

---

## Suppression List (CAN-SPAM / GDPR)

POST   /v1/mail/suppress  { "email": "user@example.com", "reason": "unsubscribed" }
GET    /v1/mail/suppress
DELETE /v1/mail/suppress/user@example.com

---

## Per-Inbox ACL

Scope a key to one inbox using mail:inbox:{agent_id}:

POST /v1/admin/api-keys
{ "label": "orders-reader", "scopes": ["strand:read", "mail:inbox:orders"] }

Keys with any mail:inbox:* scope are restricted to only those agent IDs.

---

## Backward Compatibility

The body field name on POST /v1/mail/send is fixed. Existing callers continue to work without changes.

← Previous
Lesson 53: Go SDK
Next →
Lesson 55: Chat Add-on