|
| 1 | +--- |
| 2 | +paths: application/*/WebApp/emails/**,application/shared-webapp/emails/** |
| 3 | +description: Authoring localized transactional email templates with React Email plus the @repo/emails component library and Scriban helpers |
| 4 | +--- |
| 5 | + |
| 6 | +# Emails |
| 7 | + |
| 8 | +Guidelines for authoring transactional email templates that ship as Scriban-substituted HTML and plaintext for the .NET backend's email renderer. |
| 9 | + |
| 10 | +## Implementation |
| 11 | + |
| 12 | +1. Pick the right home for the template: |
| 13 | + - Templates owned by a self-contained system live under `application/<system>/WebApp/emails/templates/<Name>.tsx` (each system has its own `lingui.config.ts`, `translations/locale/{locale}.po`, optional `static/`, and a build-emitted `dist/` folder) |
| 14 | + - Generic showcase templates that demonstrate every shared component live under `application/shared-webapp/emails/showcase/` and ship alongside the package itself |
| 15 | + - Default-export the template component; the build looks up the default export |
| 16 | + - Wrap the body in `<TransactionalEmail locale={...} preview={...}>` from `@repo/emails/components/TransactionalEmail` and include exactly one `<Subject>` element so the renderer can extract the email subject from the rendered HTML |
| 17 | + |
| 18 | +2. Use the `@repo/emails` component library for layout and content: |
| 19 | + - `Heading`, `Subject`, `Button`, `Avatar`, `AvatarGroup`, `Badge`, `Alert`, `ProgressBar`, `Separator`, `Image`, `TransactionalEmail` |
| 20 | + - Add new components only when an existing one is genuinely insufficient — prefer composition |
| 21 | + - Style with Tailwind classes already supported by `@react-email/components`. Email-friendly Tailwind cannot inline complex selectors, so avoid `[&_*]:`, `first:[&_*]:`, and similar child-combinator variants — use inline `style={{ ... }}` for per-child overrides instead |
| 22 | + |
| 23 | +3. Substitute runtime values with the JSX helpers — never hand-write `{{ ... }}` in text content: |
| 24 | + - `<Value path="user.firstName" sample="Alex" />` → emits `{{ user.firstName }}` in the build, renders `Alex` in the dev preview |
| 25 | + - `<Loop path="items" sample={[{...}, {...}]}>{() => <div><Value path="item.name" sample="" /></div>}</Loop>` → emits `{{ for item in items }}...{{ end }}`. The iteration variable inside the loop is always named `item` (this is the Scriban binding name, fixed by the helper — the JSX render callback parameter is unused), so field references inside the loop must use `item.field` (Scriban requires explicit binding for field access; there is no implicit `this` like in Handlebars `{{#each}}`) |
| 26 | + - `<If path="hasBalance" sample={true}>...<Else>...</Else></If>` → emits `{{ if hasBalance }}...{{ else }}...{{ end }}`. Omit `<Else>` to skip the else branch |
| 27 | + - `<OtpAutofill code="otpCode" domain="domain" />` emits the iOS-compatible `@{domain} #{code}` autofill suffix; place it once at the end of the template body so it lands on the last line of the plaintext output |
| 28 | + - **Exception — HTML attributes and Trans strings.** `<Value>` renders a `<span>`, so it cannot live inside attributes like `href` or inside the literal text of a `<Trans>` marker. In those two cases, hand-write the Scriban placeholder verbatim: |
| 29 | + - Attribute: `<Link href="{{LoginUrl}}">…</Link>` (the entire attribute value is a single placeholder) |
| 30 | + - Trans string: `<Trans>{`You have been invited to join '{{'TenantName'}}' on PlatformPlatform`}</Trans>` — the ICU single-quote escape `'{{'…'}}'` is required so Lingui doesn't mistake `{{TenantName}}` for an ICU placeholder. The same escaped form lands verbatim in the `.po` files |
| 31 | + |
| 32 | +4. Use the Scriban helpers registered in `SharedKernel/Emails/EmailHelpers.cs` for value formatting (called with Scriban pipe syntax): |
| 33 | + - `amount | format_currency "USD" "en-US"` — both currency code and locale are required |
| 34 | + - `value | format_date "en-US" "yyyy-MM-dd"?` — format is optional and defaults to the locale's long date pattern |
| 35 | + - `count | pluralize "singular" "plural"?` — explicit plural is optional; Humanizer derives one when omitted |
| 36 | + - Pass these as the `path` of `<Value>` (e.g., `<Value path={'balance | format_currency "USD" "en-US"'} sample="$129.00" />`). `<Value>` renders raw HTML in build mode so the embedded quotes are preserved verbatim for Scriban |
| 37 | + |
| 38 | +5. Translate every user-facing string with the Lingui macro form, exactly like the rest of the app: |
| 39 | + - Import `Trans` from `@lingui/react/macro` and use the children form: `<Trans>Welcome to PlatformPlatform</Trans>`. Lingui's CLI applies the macro at extract time to populate `.po` ids, and the email build aliases `@lingui/react/macro` (via `tsconfig.json` paths and a tiny runtime wrapper at `application/shared-webapp/emails/build/lingui-macro-runtime.tsx`) so Node-side rendering produces the same id-and-message contract the macro generates in the SPA build |
| 40 | + - For inline JSX inside translated copy, just nest it: `<Trans>Hello <Value path="firstName" sample="Alex" /></Trans>`. The wrapper walks children and emits `<0/>` placeholders matching the macro |
| 41 | + - The build runs `lingui extract --clean` and `lingui compile --typescript` per target before rendering, so re-running the build is enough to regenerate `.po` files after adding new markers |
| 42 | + - Translate every empty `msgstr` in every locale before handoff — never approximate Danish characters (`æøå`) with ASCII |
| 43 | + |
| 44 | +6. Build and preview pipelines: |
| 45 | + - **Standalone email build:** `dotnet run --project developer-cli -- build --emails` — fastest path while iterating; outputs `<Name>.<locale>.html` and `<Name>.<locale>.txt` to `application/<system>/WebApp/emails/dist/` (per-system templates) and `application/shared-webapp/emails/dist/` (showcase templates) |
| 46 | + - **Full frontend build:** `dotnet run --project developer-cli -- build --frontend` (and the no-flag default) include the email build automatically via turbo |
| 47 | + - **Dev preview:** from `application/shared-webapp/emails`, run `npm run dev` to launch the React Email dev server on port 4000 with the showcase folder. Per-system previews can launch their own server by pointing `--dir` at `application/<system>/WebApp/emails/templates` |
| 48 | + - The build sets `EMAIL_RENDER_MODE=build`; the dev preview leaves it unset so the helpers substitute their `sample` props |
| 49 | + |
| 50 | +7. Brand assets: |
| 51 | + - Place static assets (logos, hero images, fonts) in `application/<system>/WebApp/emails/static/` |
| 52 | + - Reference them with absolute URLs that resolve through the backend's `UseEmailStaticFiles()` middleware (e.g., `https://app.dev.localhost/emails/assets/logo.png`) |
| 53 | + - The same markup works in dev and production because both serve `/emails/assets/` from the same dist directory |
| 54 | + |
| 55 | +## Examples |
| 56 | + |
| 57 | +### Example 1 - Minimal Localized Template |
| 58 | + |
| 59 | +```tsx |
| 60 | +// application/account/WebApp/emails/templates/Welcome.tsx |
| 61 | +import { Trans } from "@lingui/react/macro"; |
| 62 | +import { Text } from "@react-email/components"; |
| 63 | + |
| 64 | +import { Heading } from "@repo/emails/components/Heading"; |
| 65 | +import { Subject } from "@repo/emails/components/Subject"; |
| 66 | +import { TransactionalEmail } from "@repo/emails/components/TransactionalEmail"; |
| 67 | +import { Value } from "@repo/emails/helpers/Value"; |
| 68 | + |
| 69 | +export default function Welcome({ locale }: { locale: string }) { |
| 70 | + return ( |
| 71 | + <TransactionalEmail locale={locale} preview="Welcome to PlatformPlatform"> |
| 72 | + <Subject> |
| 73 | + <Trans>Welcome to PlatformPlatform</Trans> |
| 74 | + </Subject> |
| 75 | + <Heading level={1}> |
| 76 | + <Trans>Hi <Value path="user.firstName" sample="Alex" /></Trans> |
| 77 | + </Heading> |
| 78 | + <Text> |
| 79 | + <Trans>Thanks for signing up. Your account is ready.</Trans> |
| 80 | + </Text> |
| 81 | + </TransactionalEmail> |
| 82 | + ); |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +### Example 2 - Helpers and Conditional Branches |
| 87 | + |
| 88 | +```tsx |
| 89 | +// ✅ DO: Use the macro form, prefix loop fields with `item.`, and pipe values through helpers |
| 90 | +<Loop path="invoices" sample={[{ id: "1042" }, { id: "1043" }]}> |
| 91 | + {() => ( |
| 92 | + <div> |
| 93 | + <Trans>Invoice <Value path="item.id" sample="" /></Trans> |
| 94 | + </div> |
| 95 | + )} |
| 96 | +</Loop> |
| 97 | + |
| 98 | +<If path="hasBalance" sample={true}> |
| 99 | + <Trans> |
| 100 | + Outstanding balance:{" "} |
| 101 | + <Value path={'balance | format_currency "USD" "en-US"'} sample="$129.00" /> |
| 102 | + </Trans> |
| 103 | + <Else> |
| 104 | + <Trans>You are all caught up.</Trans> |
| 105 | + </Else> |
| 106 | +</If> |
| 107 | + |
| 108 | +// ❌ DON'T: Hand-write Scriban in JSX strings or invent opaque ids |
| 109 | +<div>{"{{ for invoice in invoices }}{{ invoice.id }}{{ end }}"}</div> |
| 110 | +<Trans id="email.invoice.line">Invoice <0/></Trans> |
| 111 | + |
| 112 | +// ❌ DON'T: Reference loop fields without the `item.` prefix — Scriban needs explicit binding |
| 113 | +<Loop path="invoices" sample={[{ id: "1042" }]}> |
| 114 | + {() => <div><Value path="id" sample="" /></div>} |
| 115 | +</Loop> |
| 116 | +``` |
| 117 | + |
| 118 | +### Example 3 - OTP Autofill |
| 119 | + |
| 120 | +```tsx |
| 121 | +// ✅ DO: Place OtpAutofill at the end of the email body so the iOS suffix is the last line of plaintext |
| 122 | +<TransactionalEmail locale={locale} preview="Your verification code"> |
| 123 | + <Subject> |
| 124 | + <Trans>Your verification code</Trans> |
| 125 | + </Subject> |
| 126 | + <Heading> |
| 127 | + <Value path="otpCode" sample="ABC123" /> |
| 128 | + </Heading> |
| 129 | + <OtpAutofill code="otpCode" domain="domain" /> |
| 130 | +</TransactionalEmail> |
| 131 | +``` |
0 commit comments