Status: accepted · Date: 2026-10-29 · Related: ADR-0002 (server-only packages), ADR-0005 (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
- React Email, rendered synchronously on the server.
react-email6.11.4 (since 6.0 the components andrendership in this one package) with React 19.3.1, pinned exactly inpackages/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.tsxdraws every document with React Email's components andrenderTextwrites the plain-text part from the same document, so the two parts cannot drift. React escapes every string;safeUrladmits only HTTP(S) links without credentials.- Synchronous.
renderis async only and would turn every template into a Promise and change every caller inpackages/auth.renderEmailusesrenderToStaticMarkup(the edge build ofreact-dom/serverin the Worker, already there for SSR) and finishes the markup asrenderdoes (XHTML doctype, no<link rel=preload as=image>). A test holds the output equal torender's, minus React's streaming hydration comments, in every language. - Out of the Worker. Only the components used are bundled (
sideEffects: false);render'shtml-to-textandprettierare never imported at runtime (prettyis used in tests for readable snapshots). - Outlook. React writes no HTML comments, so the layout leaves empty
<x-mso>markers thatfinishHtmlreplaces with the conditional comments (Office settings, the 600 px ghost table). - Preview.
pnpm email:devruns React Email'semail devon port 3060 with one preview per template inpackages/email/emails. Its UI,@react-email/ui, is a Next.js app; as a workspace dependency it would make pnpm wirenextin as the optional peer ofbetter-authandfiles-sdk, both bundled into the Worker. So the launcher (packages/email/scripts/email-dev.mjs) installs it on first run intopackages/email/node_modules/.cache, outside the workspace graph, at exactly the version ofreact-email, with npm's--beforetwo days back as the release-age quarantine.
- Synchronous.
- 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.cssas hex (brand.ts), a dark palette underprefers-color-scheme, system fonts (no font request), 600 px wide, narrower below 480 px. Rebranding isEMAIL_FROM's display name and, if wanted, the few hex values inbrand.ts.- Logo.
LOGO_PATHmirrorsSITE_LOGO_PATHofapps/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's invariant that the accept URL is the only link.
- Logo.
- 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 ofpackages/intl/src/locale.config.tsmust hold every key. The language comes from the request that caused the email (emailLanguageinrequest.ts): the site's language cookie, thenAccept-Language, then English. Nothing is stored.I18N_COOKIE_NAMEmoved into thelocale.config.tsleaf, and@template-cloudflare/intlexports./locale-paths(pure functions, no i18next or React) fornegotiateLanguage. - 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;formatUtcprints11 July 2026, 12:34 UTC(and the same in German and French), never ISO. The product name isEMAIL_FROM's display name andEMAIL_REPLY_TOis unchanged. - Transports unchanged.
sendEmailand its single bounded attempt (ADR-0005) did not change; templates still return{ subject, html, text }.
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/authandpackages/apitype-checkpackages/emailfrom source, so both set"jsx": "react-jsx".scripts/test-email.mjsloads 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
cfdata 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.