# Email

One transport boundary and one localized React Email layout serve verification, recovery, invitations and security notices.

## Transport selection [#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 [#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 [#user-controlled-text]

Names a User typed (their own name, an Organization name) are rendered by `userText()` in `user-text.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, a preheader or a title. 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](/docs/features/auth#invitations).

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

## Sender domain DNS [#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](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/email-domain.md).

## Layout and languages [#layout-and-languages]

Templates are React Email ([ADR-0014](/docs/adr/0014-account-emails-in-react-email)). A template in `templates.ts` builds a typed document (`document.ts`: title, greeting, text, note, one button with its copyable link, details, help, footer); `layout.tsx` draws every document with React Email's components, and the same document gives the plain-text part, so the two never drift. React escapes every string and `safeUrl` admits only HTTP(S) links without credentials. Rendering is synchronous (`renderToStaticMarkup`, held equal to React Email's `render` by a test), so templates stay plain functions and `html-to-text`/`prettier` never reach the Worker.

The layout is neutral: the brand mark and the product name, a white card, an ink button, a muted footer with why the reader got the email and `© <year> <product>`, a dark palette under `prefers-color-scheme`, system fonts. The colours mirror the tokens of `packages/ui` in `packages/email/src/brand.ts`. The brand mark is `SITE_LOGO_PATH` from the site identity, loaded from the public origin (`BETTER_AUTH_URL`, else the request's origin); the name beside it is the text fallback when images are blocked. No email links anything but its one action.

Copy lives in `packages/email/src/locales/{en,de,fr}.ts`, one catalog per language of `packages/intl/src/locale.config.ts` (a missing key fails `check-types`; a test holds placeholders equal and keeps product names, addresses and URLs out of the copy). The language is read from the request that caused the email: the site's language cookie, then `Accept-Language`, then English (`emailLanguage` in `request.ts`).

## Add a template [#add-a-template]

Add a `…Document` and `…Email` pair in `templates.ts`, its copy in all three catalogs, and a preview in `packages/email/emails`. `pnpm email:dev` serves the previews on port 3060 with live reload; its UI (`@react-email/ui`, a Next.js app) is installed on first run into `packages/email/node_modules/.cache`, outside the workspace graph and the Worker. Pass user text through `userText()`, pass the product name, language and origin 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](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/email-domain.md) for provider onboarding and the previous deployed preview verification for evidence of actual delivery.

## Key files [#key-files]

- [`packages/email/src/transports.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/src/transports.ts)
- [`packages/email/src/templates.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/src/templates.ts)
- [`packages/email/src/layout.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/email/src/layout.tsx)
- [`packages/email/src/locales`](https://github.com/CarlosZiegler/template-cloudflare/tree/main/packages/email/src/locales)
- [`packages/email/emails`](https://github.com/CarlosZiegler/template-cloudflare/tree/main/packages/email/emails)
- [`packages/auth/src/invitation-policy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/invitation-policy.ts)
- [`packages/env/src/email.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/env/src/email.ts)
- [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/index.ts)

