ADR-0025: Email delivery — one MailSender on the Cloudflare Email Service binding
- Status: Accepted
- Date: 2026-10-13
- Related: RFC-0011 (the design), RFC-0005 §7.2 and open question 6 (emailed invites, reset links), RFC-0007 D1 (mail adapters), ADR-0012 (two Workers)
- Sources: Email Service Workers API, send bindings, pricing, limits and allowed headers — https://developers.cloudflare.com/email-service/ (checked 2026-10-13 in
cloudflare/cloudflare-docsat0cd4753)
Context
MailSender has existed since RFC-0005 without an implementation, so invites were copied by hand and only an Administrator could reset a password; an Owner who forgot theirs had nobody to ask. Email Service sends from a Worker through a send_email binding: no API key, no second vendor, no external fetch. Sending to any recipient needs Workers Paid (3,000 mails a month included); on Free only verified destination addresses of the account can receive.
Decision
Adopt RFC-0011 with its defaults, accepted by the owner on 2026-10-13 (PR #24):
- Switch. An optional
mail: {from, fromName?}incloudhal.json.cloudhal deploygives the admin Worker asend_emailbinding locked to that one address (allowed_sender_addresses) and theCLOUDHAL_MAIL_FROM(_NAME)vars. No key: no binding, link delivery only. - Sender.
cloudflareEmailSenderin@cloudhal/cloudflare: plain text,Auto-Submitted: auto-generated, CR/LF stripped from subjects, bodies never logged, failures asMailError {code, retryable}.cloudhal studioprints mail to the terminal instead of sending. - Invites. A failed send cancels the invite and answers 502
delivery_failed; the attempt is audited asmember.invite-failed. - Password reset by email.
POST /api/password-reset/request {email}always answers 202; the lookup and the send run after the response. Limits: 10 per minute per IP, 3 mails per address per hour. It issues the existing auditedchrst_link (1 hour, single use, voids older links) with the member ascreatedBy; completing a self-issued link marks the email verified. Every completed reset sends a “your password was changed” notice when a sender exists./api/instancereportspasswordReset, and Studio shows “Forgot password?” only when email is offered. - Not in v1: HTML mail, a
cloudhal mail testcommand, staff and reader notifications (a follow-up RFC: they need preferences and unsubscribe links).
Alternatives considered
| Alternative | Pros | Cons | Why not |
|---|---|---|---|
| Resend with an API key | Works on Free | A secret, a second vendor, an external fetch |
Possible later adapter |
| Email Service REST API | Usable from the CLI | An account token stored as a secret | The binding needs no secret and is lockable to one sender |
Better Auth sendResetPassword |
Built in | A second reset system beside the audited chrst_ links |
One mechanism, one audit trail |
Consequences
- Positive: invites by email work end to end; no deployment depends on an Administrator to recover a password; no secrets added.
- Negative: account recovery now also runs through the member’s mailbox; arbitrary recipients need Workers Paid.
- Risks: an unonboarded sending domain fails at the first send. Invites surface the service’s code; reset requests fail silently by design (no enumeration), so the owner sees it only in Worker logs.