Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/email-templates.md
T

6.1 KiB

Email Templates (React-Email)

Package: packages/mail/src/templates-react/ Status: active (Phase 10 task #75) Replaces: packages/mail/src/templates/transactional.ts (deprecated)

Overview

Transactional emails are React components. Every template is rendered deterministically to { html, text } via react-dom/server and delivered through the standard Mailjet/queue provider chain.

We chose React components over the legacy string-template approach for three reasons:

  1. XSS-safety by default — React escapes interpolated values, so we cannot accidentally forget an escapeHtml call when a new prop is introduced. The old monolithic file had a recurring audit finding around manual escaping.
  2. Component reuse — header, footer, button, info-box, and eyebrow are shared across templates. Brand-theming threads through a single MailBrand prop instead of token-replacement on raw HTML strings.
  3. Live preview — pnpm --filter @toolctrl/mail preview boots a local HTTP server that renders every template with realistic sample data so designers can iterate without triggering live email sends.

Adding a new template

  1. Create the component in packages/mail/src/templates-react/transactional/<TemplateName>.tsx. Accept a brand: MailBrand prop plus the data the template needs. Compose with TransactionalScaffold for the standard layout. Avoid dangerouslySetInnerHTML — it is banned by code review.
  2. Validate URLs — every CTA must go through <Button>. The component throws InvalidActionUrlError for anything other than https:// or mailto: URLs.
  3. Register the template in packages/mail/src/templates-react/render.ts:
    • Add the name to ReactTransactionalTemplateName
    • Add the prop type to ReactTemplatePropsMap
    • Add the component to the COMPONENTS map
  4. Add a snapshot test at packages/mail/src/templates-react/transactional/<TemplateName>.test.tsx covering: default render (HTML + text snapshots), tenant brand override, XSS-safety on at least one user-supplied prop, and URL-validation error if the template takes an actionUrl.
  5. Add a preview entry in packages/mail/scripts/previews.ts so the new template renders in pnpm preview.

Brand theming

Templates accept a brand: MailBrand prop with the following fields:

Field Source Notes
productName @toolctrl/brand BrandTokens.productName Defaults to "ToolCTRL".
logoUrl BrandTokens.assets.logoUrl Optional. Falls back to wordmark.
primaryColor BrandTokens.mail.colors.primary CTA background.
primaryTextColor BrandTokens.mail.colors.primaryText CTA foreground.
tenantName derived from tenant displayName Used in greeting/footer copy.
supportEmail / supportFromName BrandTokens.support.* Footer + sign-off.
privacyUrl / termsUrl / imprintUrl BrandTokens.legal.* DSGVO/CAN-SPAM compliance.

withBrandDefaults(override?) merges a partial override into the platform defaults — call it once per render so missing tenant fields do not blank out the design.

Plain-text fallback

renderTransactionalEmail(template, props) returns both html and a plain-text text variant. The text is produced from the rendered HTML by htmlToPlainText in render-helpers.ts — anchor URLs are preserved as label (url) so text-mode clients keep functional CTAs.

Mailjet forwards both parts in a MIME-multipart envelope; clients choose which to display.

Testing

  • Snapshot tests are mandatory. Each template owns a __snapshots__/<TemplateName>.test.tsx.snap capturing both the HTML and the text output. Reviewers compare snapshot diffs against the design intent.
  • XSS-safety tests verify that React's default escaping turns <script> into &lt;script&gt; for at least one user-supplied prop per template.
  • URL-validation tests verify that javascript:, http://, data:, and malformed URLs are rejected when passed to <Button>.
  • Run pnpm --filter @toolctrl/mail test to execute the suite.

Live preview workflow

pnpm --filter @toolctrl/mail preview

Then open http://localhost:3001 — the index page lists every template and links to the rendered HTML plus a ?format=text link for the plain-text fallback.

Sample props live in packages/mail/scripts/previews.ts. Update them when adding a template; never commit real PII.

Render pipeline

renderTransactionalEmail(template, props) from @toolctrl/mail is the canonical entry point. The pre-Stripe-19 string-template renderer (renderTransactionalMailTemplate) was retired in the legacy-cleanup pass; the host application's wrapper at apps/web/app/modules/emails/utils/EmailTemplates.ts adapts the legacy template-name surface onto the React-Email pipeline.

Legacy name React template Notes
welcome welcome direct migration
password-reset password-reset direct migration
user-invitation invitation renamed for consistency
email-verification magic-link merged into magic-link flow (30-min expiry variant)
account-setup welcome unified onboarding (overridable subject/eyebrow/title/intro/actionLabel)
— magic-link new
— subscription-success / subscription-failed new (Stripe webhooks)
— invoice-sent new (Stripe invoices)
— tenant-notification new (in-app digest)

Security notes

  • React's default escaping is the primary defense — manual escapeHtml calls are no longer required and any new template that performs string concatenation in HTML attributes will be rejected in code review.
  • <Button> enforces an allow-list of URL schemes (https:, mailto:) to prevent magic-link phishing via javascript: or data: URLs.
  • dangerouslySetInnerHTML is forbidden in packages/mail/src/templates-react/. If you need rich text, build a structured prop shape and render it as JSX.
  • Brand logo URLs come from packages/brand which enforces an https-only allow-list; the React templates never load arbitrary tenant-supplied URLs.