Transactional email

Send receipts, codes, and alerts from app code with a managed sender: one declaration file, a worker binding, a pinned From address, and the error codes and limits that matter in production.

The Spikefrost Team30 Jul 20264 min read

Apps send email through a managed sender. Declare it once, deploy, and you have a binding — no API keys, no SMTP configuration, no provider account.

Sending only. Inbound email to an app or agent is not available. If your design depends on receiving mail, that dependency can't be satisfied today.

Setup

// app/email.json
{ "senders": [{ "name": "default", "displayName": "Acme Shop" }] }

After sf deploy, the worker has two things per sender:

  • env.EMAIL_DEFAULT — the send binding
  • env.EMAIL_DEFAULT_ADDRESS — the assigned From address

Names are lowercase letters, digits, and dashes, and dashes become underscores in the binding: sender order-updates gives EMAIL_ORDER_UPDATES. Up to five senders per app — default and alerts is a common split.

The address is deterministic per app, environment, and sender, so each environment gets its own address automatically. Nothing to configure, and staging can't accidentally send as production.

sf email status                        # senders per environment
sf email test --to you@example.com     # a real send from the assigned address

Removing a sender fails the deploy until you acknowledge it with "confirmDelete": ["<name>"] in the same file. Nothing is destroyed — re-adding the sender restores the same address.

Don't declare [[send_email]] in wrangler.toml. Deploys don't read it and will fail pointing you back to email.json.

Sending

await env.EMAIL_DEFAULT.send({
  to: user.email,
  from: { email: env.EMAIL_DEFAULT_ADDRESS, name: "Acme Shop" },
  replyTo: "support@acme.com",          // optional, any address
  subject: "Order #1234 confirmed",
  html: "<h1>Thanks!</h1><p>Your order shipped.</p>",
  text: "Thanks! Your order shipped.",
});

The From address is pinned

The binding only accepts from.email === env.EMAIL_<NAME>_ADDRESS. Anything else throws E_SENDER_NOT_VERIFIED.

So: always read the env var, never hardcode an address, never invent one. Branding comes from displayName and from.name, and replyTo can be any address at all — which is usually what people actually want, since replies then land in a real monitored inbox.

Always send both bodies

Provide html and text. Missing the text part hurts spam scoring and breaks text-only clients.

Don't block a response on it

Email is fire-and-forget from your app's perspective. In a Hono route, hand it to the execution context rather than making the user wait:

app.post("/api/checkout", async (c) => {
  const order = await createOrder(c.env, await c.req.json());
  c.executionCtx.waitUntil(sendReceipt(c.env, order));   // don't await
  return c.json({ ok: true, orderId: order.id });
});

Transactional only

Receipts, magic links, verification codes, status notifications, alerts — mail a user action triggered. Not newsletters, not marketing blasts, not list mail.

This isn't a style preference. The sending domain is shared across apps, so one app's bulk sending degrades deliverability for everyone else's receipts. Two rules follow:

  • Send only to addresses your app actually collected. Hard bounces to made-up recipients damage the shared reputation.
  • Recurring mail the user asked for — a daily digest they subscribed to — must carry a working unsubscribe link and honor it. Store the opt-out in your database and check it before every send.

Limits

Limit Value
Recipients per send 50 combined to + cc + bcc
Total size per send 25 MiB including attachments

For many recipients, loop and send individually. Never put a recipient list in cc — that leaks your users' addresses to each other.

Error codes

Handle failures by err.code, because the right response differs sharply:

Code Meaning What to do
E_SENDER_NOT_VERIFIED From ≠ the assigned address Fix the code
E_RECIPIENT_SUPPRESSED Previously hard-bounced or complained Don't retry. Mark the address invalid in your data
E_RATE_LIMIT_EXCEEDED Too fast Back off
E_DAILY_LIMIT_EXCEEDED Daily quota — platform-shared Back off; keep batch jobs small and spread out
E_FIELD_MISSING, E_TOO_MANY_RECIPIENTS, E_CONTENT_TOO_LARGE Invalid request Fix it; retrying can't help

The two worth coding against deliberately are E_RECIPIENT_SUPPRESSED — a retry loop on a suppressed address is pure waste and a reputation signal — and E_DAILY_LIMIT_EXCEEDED, since the quota is shared and a large batch job is the usual way to hit it.

Email from an agent

Everything above is app code. An agent sends mail the same way: put the send in a domain function and give the agent a tool over it, so the same rules and the same suppression handling apply whether a route or an agent triggered it.

For a genuinely conversational thread — mail a human replies to and expects continuity from — you'd need inbound email, which isn't available yet.

Next

Frequently asked questions

How do I send email from my app?

Declare a sender in app/email.json and deploy. The worker gets an env.EMAIL_<NAME> binding and an env.EMAIL_<NAME>_ADDRESS variable, and send() is the whole integration — no API keys, no SMTP.

Can I send from my own address?

The From address is pinned to the assigned one; any other value is rejected. Branding comes from the display name, and replyTo can be any address you like — so replies land in your real inbox.

Can I send a newsletter?

No. This is transactional only — mail a user action triggered. The sending domain is shared, so bulk or marketing sends degrade deliverability for every app on the platform.

Can my app receive email?

Not yet. Inbound email is not available. Don't build handlers expecting it.

What if a send fails?

Handle it by error code. A suppressed recipient has hard-bounced before and must not be retried; rate and daily limits mean back off; validation errors mean fix the request, since retrying cannot help.