Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

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 @varu/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.
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.