# Account emails in React Email

The repository's architectural decision, rationale and consequences.

# Account emails in React Email

**Status:** accepted · **Date:** 2026-10-29 · **Related:** [ADR-0002](0002-no-barrels-on-server-capable-modules.md) (server-only packages), [ADR-0005](0005-effect-boundary.md) (transports)

**Enforcement:** `packages/email/src/layout.test.tsx` (output equal to React Email's `render`, Outlook comments, HTML snapshots), `packages/email/src/templates.test.ts` (every email in every language, catalog keys and placeholders, forbidden strings, one link per email, user text out of subjects), `packages/auth/src/organization-invitations.test.ts` (invitation subject and body through Better Auth), `pnpm --filter web build` (client bundle clean).

The five account emails (verification, welcome, password reset, the password-reset security notice, Organization invitation) were HTML strings concatenated in `packages/email/src/templates.ts`: English only, one grey card, a hand-written `escapeHtml` per interpolation and no way to see a change without sending mail. A product built from this template ported them to React Email first and kept the result; this records the same mechanics for the template, without that product's brand.

## Decision [#decision]

1. **React Email, rendered synchronously on the server.** `react-email` 6.11.4 (since 6.0 the components and `render` ship in this one package) with React 19.3.1, pinned exactly in `packages/email`, which stays server-only. A template 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 `renderText` writes the plain-text part from the same document, so the two parts cannot drift. React escapes every string; `safeUrl` admits only HTTP(S) links without credentials.
   - **Synchronous.** `render` is async only and would turn every template into a Promise and change every caller in `packages/auth`. `renderEmail` uses `renderToStaticMarkup` (the edge build of `react-dom/server` in the Worker, already there for SSR) and finishes the markup as `render` does (XHTML doctype, no `<link rel=preload as=image>`). A test holds the output equal to `render`'s, minus React's streaming hydration comments, in every language.
   - **Out of the Worker.** Only the components used are bundled (`sideEffects: false`); `render`'s `html-to-text` and `prettier` are never imported at runtime (`pretty` is used in tests for readable snapshots).
   - **Outlook.** React writes no HTML comments, so the layout leaves empty `<x-mso>` markers that `finishHtml` replaces with the conditional comments (Office settings, the 600 px ghost table).
   - **Preview.** `pnpm email:dev` runs React Email's `email dev` on port 3060 with one preview per template in `packages/email/emails`. Its UI, `@react-email/ui`, is a Next.js app; as a workspace dependency it would make pnpm wire `next` in as the optional peer of `better-auth` and `files-sdk`, both bundled into the Worker. So the launcher (`packages/email/scripts/email-dev.mjs`) installs it on first run into `packages/email/node_modules/.cache`, outside the workspace graph, at exactly the version of `react-email`, with npm's `--before` two days back as the release-age quarantine.
2. **One neutral layout.** The brand mark and the product name above a white card with a hairline border, an ink button, a muted footer; the colours are the neutral tokens of `packages/ui/src/styles/globals.css` as hex (`brand.ts`), a dark palette under `prefers-color-scheme`, system fonts (no font request), 600 px wide, narrower below 480 px. Rebranding is `EMAIL_FROM`'s display name and, if wanted, the few hex values in `brand.ts`.
   - **Logo.** `LOGO_PATH` mirrors `SITE_LOGO_PATH` of `apps/web/src/platform/seo/site-identity.ts` (a test keeps them equal) and resolves against the public origin (`BETTER_AUTH_URL`, else the request's origin). The product name beside it is the text: the image is decorative (`alt=""`), so a client that blocks images, or cannot reach a local origin, still shows the name. Without an origin the header is the name alone.
   - **No links but the action.** No footer links, no linked logo: each email carries at most one URL, the one Better Auth signed. For the invitation this keeps [PR #163](https://github.com/CarlosZiegler/template-cloudflare/pull/163)'s invariant that the accept URL is the only link.
3. **Every email in the reader's language.** The copy lives in a server-only catalog, `packages/email/src/locales/{en,de,fr}.ts`, typed so that every language of `packages/intl/src/locale.config.ts` must hold every key. The language comes from the request that caused the email (`emailLanguage` in `request.ts`): the site's language cookie, then `Accept-Language`, then English. Nothing is stored. `I18N_COOKIE_NAME` moved into the `locale.config.ts` leaf, and `@template-cloudflare/intl` exports `./locale-paths` (pure functions, no i18next or React) for `negotiateLanguage`.
4. **User text stays contained (PR #163).** Names reach a body only through `userText()` (controls stripped, whitespace collapsed, capped at 100, link-shaped tokens defanged) and never a subject, preheader or title; the invitation subject is a constant built from the product name in each language; the inviter is attributed by name and verified address; `formatUtc` prints `11 July 2026, 12:34 UTC` (and the same in German and French), never ISO. The product name is `EMAIL_FROM`'s display name and `EMAIL_REPLY_TO` is unchanged.
5. **Transports unchanged.** `sendEmail` and its single bounded attempt (ADR-0005) did not change; templates still return `{ subject, html, text }`.

## Consequences [#consequences]

- The Worker gains React Email's components and nothing else; the size delta is in `docs/measurements/2026-10-29-react-email.md`.
- `packages/auth` and `packages/api` type-check `packages/email` from source, so both set `"jsx": "react-jsx"`.
- `scripts/test-email.mjs` loads the templates through Vite's module runner (`runnerImport`), because Node does not compile TSX.
- The invitation is written in the inviter's request language: the invitee's language is unknown until they visit.
- An email sent outside a request (a future cron notice) has no language to read and falls back to English; the first such email needs a stored account language.
- The security notice shows the time in UTC only. Showing it in the reader's time zone with a device label and approximate country would need the request's `cf` data passed into the notice; that is a separate change.
- The mobile app's auth client sends no explicit language; Android's HTTP stack sends no `Accept-Language`, so a reset requested on Android arrives in English until the app sends its language header.

