Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Email

One transport boundary serves verification, recovery, invitations and security notices.

Markdown

Transport selection

The email package selects Cloudflare's Worker EMAIL binding first, then Cloudflare REST credentials, then the optional Resend fallback, then development console delivery. Deployment setup uses Cloudflare with an onboarded sender domain. Set EMAIL_FROM to the intended display name and address; its display name is the product name every template prints (appNameFromEmailEnv), so there is no second brand knob. EMAIL_REPLY_TO (optional, Name <addr> or a bare address) sets a Reply-To on every message through all three transports: the Worker binding (replyTo), Cloudflare REST and Resend (reply_to). Unset means no header.

Node tooling cannot use the Worker binding. Real Node delivery needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_EMAIL_API_TOKEN. A configured Worker binding does not prove Node can send.

Delivery semantics

sendEmailEffect owns the bounded send; its Promise facade serves Better Auth callbacks. There is one send attempt inside the round, with a 10-second timeout. Cloudflare acceptance requires a nonempty message ID. Provider acceptance and inbox receipt are separate checks.

Required verification/recovery mail failures must not report a successful auth action. Informational welcome and security notices have their own handling. Never silently turn required production email into console-only delivery.

User-controlled text

Names a User typed (their own name, an Organization name) are rendered by userText() in templates.ts: control and zero-width characters removed, whitespace collapsed, length capped at 100, and link-shaped tokens defanged (evil.example becomes evil[.]example) so no mail client turns them into a link. They never appear in a subject. The invitation subject is a constant built from the product name, and the body attributes the invitation to the inviter's name and verified address, states that the names were entered by the inviter, and carries exactly one link: the app's accept URL. This closes the open-relay shape where an inviter-controlled Organization name became the subject line of mail to any address; who may trigger that mail is covered under Invitations.

Timestamps in notices are formatted as 11 July 2026, 12:34 UTC by formatUtc, not as ISO strings.

Emails are English only; a localized React Email port is a separate change.

Sender domain DNS

Whichever transport sends, the domain in EMAIL_FROM needs three records or recipients' filters will reject or junk the mail:

  • SPF: a TXT on the sending domain that includes the provider (include:_spf.mx.cloudflare.net for Cloudflare Email Sending; include:amazonses.com or the record Resend shows for Resend) and ends in ~all or -all.
  • DKIM: the CNAME or TXT records the provider issues when the domain is onboarded (Cloudflare Email Sending creates them in the zone; Resend lists them under the domain's settings).
  • DMARC: a TXT at _dmarc.<domain> such as v=DMARC1; p=quarantine; rua=mailto:dmarc@<domain>; start with p=none while checking reports.

Cloudflare verifies these when the domain is onboarded to Email Sending; Resend shows a status per record. The Reply-To address, when set, must be a mailbox that exists. Steps and evidence: email domain setup.

Add a template

Add a pure template in templates.ts returning subject, plain text and HTML. Escape user content, pass app branding and URLs explicitly, and call the shared transport. Use a local capture transport in tests; real sends need an explicitly chosen recipient and environment.

Inspect email domain setup for provider onboarding and the previous deployed preview verification for evidence of actual delivery.

Key files

Edit on GitHubDocumentation is in English.