Platform
Hosting & deploysPush to a branch, get a live linkDatabaseReal Postgres, one per appSign-inEmail and Google, users includedFile storageUploads, photos and documentsPaymentsCheckout and plans, same projectAI gatewayEvery model, with a hard spend cap
Solutions
Moving an app overFrom Lovable, Bolt, Replit or v0Your first real appNever shipped anything beforeSide project → businessWhen people start paying youPricing$20/month plus prepaid credits
Resources
DocsGuides written for people who are newFor AI agentsClaude Code, Cursor, MCP, JSON CLIChangelogWhat shipped, most recent firstComponentsPaste-ready UI for sign-in and moreSupportA person answers, not a queue botRefer a friendBoth of you get credits Start for $20
Open in ChatGPT Open in Claude Last updated 2026-09-24

Email

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 statuspending, 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:

MethodPathWhat it does
GET/POST/email/templatesList or create templates
PATCH/email/templates/:idEdit subject, body, blocks or the enabled switch
POST/email/previewRender merge tags against sample data
POST/email/sendTest-send a saved template to yourself
GET/email/logsDelivery log, newest first
GET/email/stats30-day totals and rates
GET/POST/email/suppressionsList or add suppressed addresses
GET/email/suppressions/checkIs this address suppressed?
GET/POST/email/inboxesList or create inboxes (max 100 per project; domainId for a custom domain)
PATCH/DELETE/email/inboxes/:idRename, retarget the webhook, toggle locked/status, or delete
POST/email/inboxes/:id/rotate-secretNew whsec_…, shown once — also revives an auto-disabled webhook
POST/email/inboxes/:id/smtpCreate the SMTP login — {smtp: {host, port, username, password}} shown once
POST/email/inboxes/:id/smtp/rotateNew SMTP password, shown once; the old one dies
GET/POST/email/domainsList custom mail domains, or register one and get its DNS records
POST/email/domains/:id/verifyRe-check the _camplax TXT, MX and DKIM records
GET/email/inboxes/:id/threadsThreads, newest first (?limit, ?before)
GET/email/inboxes/:id/messagesMessages (?limit, ?before, ?summary=1)
GET/email/threads/:id/messagesOne thread's messages in order
GET/email/messages/:id/rawThe stored .eml, as message/rfc822
GET/email/inboxes/:id/rejectionsMail the inbox's policy dropped, and why
GET/email/rejections/:id/rawA rejected mail's raw .eml
GET/POST/email/inboxes/:id/listsList or add allow/block entries
DELETE/email/inboxes/:id/lists/:listIdRemove an entry
POST/email/inboxes/:id/sendSend 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.

© 2026 Camplax $20 / month · credits stop at zero
Ask about Camplax