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.
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
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: queued → sent → delivered | 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.