Transactional email templates, test sends, delivery logs and suppressions — plus per-project inbound mailboxes you can read, reply from, hand an SMTP login to, or point at your own domain.
Every project has an Email page in the console: a template builder, a delivery log, stats, and a suppression list. Mail goes out through Amazon SES on the project's sender address — each project under its own SES tenant, so one bad sender cannot burn the shared reputation.
One honest limit up front: templates and their test sends are console-only. The one place your app's code (or a manage-scoped key) can send is the mailbox — POST /v1/projects/<slug>/email/inboxes/<id>/send takes freeform to/subject/text/html, but the from is always the inbox's own address, and sends are capped at 100 per inbox per day. It is a mailbox, not a bulk-mailer.
Templates
The builder stores templates as blocks plus rendered HTML, with {{merge_tags}} filled in at send time. Four system templates are seeded the first time you open the page — Welcome, Password reset, Magic link and Receipt (off by default, since Stripe already sends one). Each template has an on/off switch, and the list shows its merge tags plus lifetime sent and open counts.
POST /v1/projects/<slug>/email/preview renders a template — or a draft subject and HTML — against sample data so you can check the merge tags before saving.
Test sends
Send test mails one saved template to your own signed-in address. That is deliberate, and the API enforces it: POST /v1/projects/<slug>/email/send takes a templateKey or templateId and merge-tag data, and nothing else — a request that passes subject, html or from is rejected, and the recipient is always the signed-in user, never an address from the request. A test send exercises the real provider, so it lands in your real inbox and your real log.
Delivery
Every send is a row in the log (GET /v1/projects/<slug>/email/logs, filterable by status) — queued, sent, delivered, opened, deferred, bounced, complained, failed or suppressed, with the provider id and any error attached. GET /email/stats rolls the last 30 days into sent, delivered, open and bounce numbers.
SES reports delivery events back through SNS to POST /v1/webhooks/ses, and they update the matching send row. A hard bounce or a spam complaint does more than mark the row — it puts the address on the suppression list, and later sends to it are refused before they reach the provider. Soft bounces only update the log. You can also add or check addresses yourself: GET/POST /email/suppressions and GET /email/suppressions/check?address=….
Inbound mail — mailboxes
POST /email/inboxes claims an address like support@inbound.camplax.app (or support+tag@… — +tag folds into the base inbox). Local parts follow the public-name rules, a project may hold at most 100, and a local part that matches another project's slug is refused — that would hijack their legacy agent-mail channel.
Every inbox is locked by default: only DKIM-authenticated mail from project owners or the allowlist is delivered. Set locked: false to accept any sender — the blocklist still applies, and it matches the envelope MAIL FROM too, so a spoofed From: header can't slip past it. Per-inbox GET/POST /lists manage the allow/block entries (a full address, or @domain/domain including subdomains).
Delivered mail becomes thread + message rows — GET /inboxes/:id/threads and GET /inboxes/:id/messages are newest-first and page with ?limit= (default 50, max 200) and ?before=<epoch ms>; add ?summary=1 for snippet-only list payloads. Replies to a sender's In-Reply-To/References land in the existing thread. The full .eml — attachments, every header — sits in R2 and streams from GET /messages/:id/raw.
Mail the policy turns away is not invisible: GET /inboxes/:id/rejections lists each drop with its reason (blocklist, unauthenticated, not_allowlisted, inbox_disabled), and GET /rejections/:id/raw serves the original .eml.
An inbox's webhookUrl gets a signed email.received POST per delivered message — same Stripe-style Camplax-Signature scheme as webhooks, signed with the inbox's whsec_… secret (shown once at create or POST /inboxes/:id/rotate-secret). That endpoint is managed only through these routes — it never appears in the project webhook list.
DELETE /inboxes/:id removes the inbox, its threads, messages, rejections, lists and webhook endpoint, then sweeps its .eml objects — the address is released.
Replies
POST /inboxes/:id/send sends freeform mail from the inbox address only. Pass threadId to reply inside a thread — the RFC In-Reply-To/References headers go out on the wire, so the recipient's client threads it too. With no threadId, a message to an existing counterpart joins that thread; otherwise it opens a new one. Merge tags are not expanded in mailbox sends — {{thing}} is literal text.
SMTP logins
An inbox is also an SMTP account. POST /email/inboxes/:id/smtp creates a login on the mail server and answers {smtp: {host, port, username, password}} — the password is shown once, so put it straight into your app's secrets. The inbox's smtp_enabled flag flips on. Hand those four values to an app or an agent and it signs in on port 465 (TLS) or 587 (STARTTLS) and sends as the inbox address — the server refuses a From: that is not the login. POST /email/inboxes/:id/smtp/rotate mints a new password (shown once, same smtp shape) and kills the old one.
Mail sent this way still relays through SES and still lands in the log, the thread, and the same 100/day cap — it is the same send path with a different door.
The legacy path
Mail to {slug}@inbound.camplax.app / agent+{slug}@… with no inbox claiming it falls back to the old behavior: the raw .eml lands under projects/<slug>/email/ in storage — but only when the slug maps to a project; mail to unmapped addresses is dropped.
Custom domains
A project can bring its own mail domain instead of sharing inbound.camplax.app. POST /email/domains registers a hostname like mail.example.com and answers the DNS records it needs: three DKIM CNAMEs (SES's Easy DKIM, so outbound is signed as your domain), an MX pointing at the Camplax mail host, and a _camplax TXT that proves you own the domain. GET /email/domains lists them with their status — pending, verified or failed — and which checks have passed so far.
POST /email/domains/:id/verify re-runs the checks: the _camplax TXT for ownership, the MX for receiving, the DKIM CNAMEs for sending. Verified means both directions are live — new inboxes can then pass domainId to take an address on your domain (support@mail.example.com), and mail to it arrives through the same hook into the same threads and the same Mailbox tab.
The Mailbox tab
The console's Mailbox tab is the whole feature in one place: the inbox list (create, the locked switch, an SMTP login, delete), the thread list with a reading pane and a reply box, each inbox's allow/block lists, the rejections log with the raw .eml of everything policy turned away, and the domains list with its DNS records and verify button. All of it goes through the routes below — the tab adds nothing the API does not have.
The endpoints
All of it is console-session routes under /v1/projects/<slug>/email:
| Method | Path | What it does |
|---|---|---|
GET/POST | /email/templates | List or create templates |
PATCH | /email/templates/:id | Edit subject, body, blocks or the enabled switch |
POST | /email/preview | Render merge tags against sample data |
POST | /email/send | Test-send a saved template to yourself |
GET | /email/logs | Delivery log, newest first |
GET | /email/stats | 30-day totals and rates |
GET/POST | /email/suppressions | List or add suppressed addresses |
GET | /email/suppressions/check | Is this address suppressed? |
GET/POST | /email/inboxes | List or create inboxes (max 100 per project; domainId for a custom domain) |
PATCH/DELETE | /email/inboxes/:id | Rename, retarget the webhook, toggle locked/status, or delete |
POST | /email/inboxes/:id/rotate-secret | New whsec_…, shown once — also revives an auto-disabled webhook |
POST | /email/inboxes/:id/smtp | Create the SMTP login — {smtp: {host, port, username, password}} shown once |
POST | /email/inboxes/:id/smtp/rotate | New SMTP password, shown once; the old one dies |
GET/POST | /email/domains | List custom mail domains, or register one and get its DNS records |
POST | /email/domains/:id/verify | Re-check the _camplax TXT, MX and DKIM records |
GET | /email/inboxes/:id/threads | Threads, newest first (?limit, ?before) |
GET | /email/inboxes/:id/messages | Messages (?limit, ?before, ?summary=1) |
GET | /email/threads/:id/messages | One thread's messages in order |
GET | /email/messages/:id/raw | The stored .eml, as message/rfc822 |
GET | /email/inboxes/:id/rejections | Mail the inbox's policy dropped, and why |
GET | /email/rejections/:id/raw | A rejected mail's raw .eml |
GET/POST | /email/inboxes/:id/lists | List or add allow/block entries |
DELETE | /email/inboxes/:id/lists/:listId | Remove an entry |
POST | /email/inboxes/:id/send | Send freeform mail from the inbox address (≤100/day) |
Under the hood: SES when the SES_* keys are set (each send under a per-project tenant), then Resend, then the Cloudflare Email Sending binding, and a stub that records without sending when none exists. Inbound has two doors — the Cloudflare Email Routing rule on the zone, and the Stalwart mail box on mx1.camplax.app posting to the same hook — and both feed the one store: D1 rows for inboxes/threads/messages, the raw .eml in R2.