Skip to content
Docs

Command palette

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

Changelog

Account emails in React Email

The repository's architectural decision, rationale and consequences.

Markdown

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

  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'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

  • 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.
Edit on GitHubDocumentation is in English.