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:
- XSS-safety by default — React escapes interpolated values, so
we cannot accidentally forget an
escapeHtmlcall when a new prop is introduced. The old monolithic file had a recurring audit finding around manual escaping. - Component reuse — header, footer, button, info-box, and
eyebrow are shared across templates. Brand-theming threads
through a single
MailBrandprop instead of token-replacement on raw HTML strings. - Live preview —
pnpm --filter @toolctrl/mail previewboots 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
- Create the component in
packages/mail/src/templates-react/transactional/<TemplateName>.tsx. Accept abrand: MailBrandprop plus the data the template needs. Compose withTransactionalScaffoldfor the standard layout. AvoiddangerouslySetInnerHTML— it is banned by code review. - Validate URLs — every CTA must go through
<Button>. The component throwsInvalidActionUrlErrorfor anything other thanhttps://ormailto:URLs. - 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
COMPONENTSmap
- Add the name to
- Add a snapshot test at
packages/mail/src/templates-react/transactional/<TemplateName>.test.tsxcovering: 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 anactionUrl. - Add a preview entry in
packages/mail/scripts/previews.tsso the new template renders inpnpm 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.snapcapturing 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<script>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 testto 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
escapeHtmlcalls 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 viajavascript:ordata:URLs.dangerouslySetInnerHTMLis forbidden inpackages/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/brandwhich enforces an https-only allow-list; the React templates never load arbitrary tenant-supplied URLs.