Email Templates
Twelve production-ready, email-client-safe HTML templates plus a two-pane in-app browser to preview, force-test, view source, and copy or download the raw markup.
The Email Templates module ships twelve standalone HTML emails (table layout, inline styles,
Outlook-proof) plus a two-pane in-app browser at /email/* — preview each template in a sandboxed
iframe, force-test its dark mode and mobile breakpoint, view the source, and copy or download the raw
.html for your sending platform.
Overview
The module has two halves:
-
The templates themselves — 12 standalone HTML emails in
src/data/emailTemplates/, each a raw string export. The string is the product: it is a complete, self-contained document you copy into an ESP (Mailchimp, SendGrid, Postmark, Braze, …) and edit there. Every template follows the same engineering contract: table-based layout (role="presentation"), a 600px container centered in a full-widthbgcolorwrapper, an<!--[if mso]>ghost table for Outlook, all base styles inline (+bgcolorattributes), a bulletproof VMLv:roundrectCTA for Outlook, web-safe font stacks, a hidden preheader, andcolor-schememetas. The<style>block is progressive enhancement only — mobile stacking, aprefers-color-scheme: darkoverride, hover states. -
The browser — one shared two-pane page (
EmailTemplatePage) mounted on all 12/email/*routes. The left pane is the template list; its rows areNavLinks, so selection is the route (no selection state). The right pane renders the raw template in a sandboxed<iframe srcDoc>— the email's own CSS never meets Tailwind or the app's preflight — with a toolbar for scheme/device testing, source view, copy, and download.
The 12 templates, grouped as in the list:
| Group | Templates (slug → route) |
|---|---|
| Account | welcome → /email/welcome · forgot-password → /email/forgot-password · activate → /email/activate |
| Billing | order-confirmation → /email/order-confirmation · payment-due → /email/payment-due |
| Marketing | product-drop → /email/product-drop · wrapped → /email/wrapped · winback → /email/winback · preferences → /email/preferences |
| Tables | large-table → /email/large-table · small-table → /email/small-table |
| Occasions | birthday → /email/birthday |
Bundle split by design
meta.ts (descriptors only — no HTML) is the eager side imported by src/routes.tsx; the raw HTML
strings are aggregated by src/data/emailTemplates/index.ts, which is imported only by the lazy
EmailTemplatePage — so the full emails ship in that page's chunk, never in the main bundle.
Architecture & files
| File | Responsibility |
|---|---|
src/pages/email/EmailTemplatePage.tsx | The shared two-pane browser (lazy, full-bleed). Resolves the template from its slug prop, renders the preview toolbar and the sandboxed <iframe srcDoc>, and owns the forceScheme preview rewrite. |
src/components/email/EmailTemplateList.tsx | Left pane: the templates grouped into sections, each row an accent-tinted icon tile + name + subject line. Rows are NavLinks (selection = route). |
src/components/email/EmailSourceModal.tsx | Raw-HTML viewer: an editor-style CodeBlock inside a size="xl" Modal, with KB/line-count stats and Copy / Download footer actions. |
src/data/emailTemplates/meta.ts | The eager descriptor side: EmailTemplateSlug, EmailTemplateMeta, EMAIL_TEMPLATE_GROUPS, and EMAIL_TEMPLATES. Imported by routes.tsx — contains no template HTML. |
src/data/emailTemplates/index.ts | The heavy side: EMAIL_TEMPLATE_HTML, a Record<EmailTemplateSlug, string> aggregating the 12 raw templates. Imported only by the lazy page. |
src/data/emailTemplates/welcome.ts (+ 11 siblings) | One template per file — a single exported HTML string with the shared engineering contract in the header comment. |
src/locales/en/email.json | The email namespace: list hint, group headings, toolbar/modal chrome, per-template descriptions, and the displayed subject lines. |
The template modules are: welcome.ts, orderConfirmation.ts, paymentDue.ts, forgotPassword.ts,
activateAccount.ts, birthdayWish.ts, largeTable.ts, smallTable.ts, productDrop.ts,
wrapped.ts, winback.ts, preferences.ts.
Routing
All 12 routes are generated from the metadata in one spread — src/routes.tsx folds EMAIL_TEMPLATES
into appRoutes as lazy, full-bleed entries pointing at the shared page:
...EMAIL_TEMPLATES.map((t): AppRoute => ({
path: t.path,
element: <EmailTemplatePage slug={t.slug} />,
fullBleed: true,
})),The matching sidebar leaves live under Pages → Email Templates in src/data/menu.ts (12 leaves), with
labels in the nav namespace (emailWelcome … emailPreferences).
Preview rendering
The preview is a sandboxed iframe, keyed by slug so switching templates remounts it cleanly:
<iframe key={template.slug} title={name} srcDoc={previewHtml} sandbox="" … />sandbox=""(no tokens) is safe because the emails contain no scripts.- The canvas width follows the device toggle —
PREVIEW_WIDTH = {desktop: 640, mobile: 375}(640 shows the 600px email plus its own gutter; 375 triggers the template'smax-width: 620pxstacking breakpoint). - The app's dark theme does not restyle the preview. Each template carries exactly one
prefers-color-scheme: darkblock, and the toolbar's Sun/Moon toggle force-tests it viaforceScheme, which rewrites that media query to@media all(always matches) or@media not all(never matches). This is preview-only — the copied/downloaded HTML keeps the original OS-driven media query.
Copy / download
- Copy HTML writes the untouched template string to the clipboard and fires a success toast.
- Download builds a
Blobof typetext/html;charset=utf-8and saves it as<slug>.html. - View source opens
EmailSourceModal— the same string in aCodeBlockwith size stats (KB · lines) and the same Copy/Download actions.
Usage
Navigate to any leaf under Pages → Email Templates in the sidebar (e.g. /email/welcome). Because
the routes are generated from EMAIL_TEMPLATES, no per-template wiring exists:
import {EmailTemplatePage} from '@/pages/email/EmailTemplatePage'
;<EmailTemplatePage slug="order-confirmation" />To use a template's HTML directly in code (a mail-send integration, a preview elsewhere):
import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
const html = EMAIL_TEMPLATE_HTML['forgot-password']Warning
Import EMAIL_TEMPLATE_HTML only from lazy-loaded code. routes.tsx and anything eager should import
from @/data/emailTemplates/meta (descriptors only) to keep the raw emails out of the main bundle.
To ship a template to your ESP: open it, click Copy HTML (or Download .html), and paste the markup into your platform's custom-HTML editor. The artifact is fully standalone.
API / Props
EmailTemplateMeta
| Field | Type | Description |
|---|---|---|
slug | EmailTemplateSlug | Stable id; also names the downloaded file (<slug>.html) and keys EMAIL_TEMPLATE_HTML. |
path | string | Absolute route path — must match the menu.ts Email Templates leaf exactly. |
navKey | string | nav: label key (the template's display name). |
subjectKey | string | email: key for the subject line shown in the list row (the HTML artifact keeps its EN subject). |
descKey | string | email: description key for the preview toolbar. |
icon | LucideIcon | List-row / mobile-switcher icon. |
accent | Accent | Icon-tile tone, resolved through accentTile / accentTileSolid in src/lib/accent.ts. |
groupKey | (typeof EMAIL_TEMPLATE_GROUPS)[number] | email: heading key of the list section. |
EmailTemplatePageProps
| Prop | Type | Description |
|---|---|---|
slug | EmailTemplateSlug | Which template to show (set per-route in routes.tsx). Unknown slugs fall back to the first template. |
EmailSourceModalProps
| Prop | Type | Description |
|---|---|---|
open | boolean | Modal visibility. |
onClose | () => void | Close handler. |
name | string | Template display name (already translated). |
slug | string | Names the editor tab (<slug>.html). |
html | string | The raw template source. |
onDownload | () => void | Saves the template as a .html file (owned by the page). |
Data exports
| Export | From | Description |
|---|---|---|
EMAIL_TEMPLATES | @/data/emailTemplates/meta | The 12 descriptors, in list order (eager-safe). |
EMAIL_TEMPLATE_GROUPS | @/data/emailTemplates/meta | Section order for the list (5 email: heading keys). |
EMAIL_TEMPLATE_HTML | @/data/emailTemplates | Record<EmailTemplateSlug, string> of raw HTML (lazy side only). |
EmailTemplateList takes no props — it reads EMAIL_TEMPLATES directly and navigates via NavLink.
Configuration & customization
Add a new template
HTML module
Create src/data/emailTemplates/myTemplate.ts exporting one HTML string. Follow the shared
contract (copy an existing module): 600px table layout, inline base styles, <!--[if mso]> ghost
table + VML CTA, one prefers-color-scheme: dark block, hidden preheader. Register it in
src/data/emailTemplates/index.ts.
Descriptor
Add the slug to the EmailTemplateSlug union and an entry to EMAIL_TEMPLATES in meta.ts. The
route, list row, and mobile switcher all derive from this entry.
Menu leaf
Add the matching item under Pages → Email Templates in src/data/menu.ts.
i18n
Add the nav:<navKey> label plus email:<subjectKey> and email:<descKey> to the locale files.
Rebrand the emails
The templates deliberately hardcode hex colors — email clients don't support CSS variables —
sampled from the app's default light/dark tokens (e.g. primary #0067ff, canvas #f6f7f9, dark canvas
#0b1120). To rebrand, find-and-replace those hex values inside the template strings; the app's skins
and dark mode never touch them. The wordmark is a text masthead (asset-free), so no logo image needs
hosting.
Examples
Send a template through your own backend
import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
import {api} from '@/lib/api'
await api.post('/notifications/email', {
to: 'customer@example.com',
subject: 'Welcome aboard',
html: EMAIL_TEMPLATE_HTML.welcome,
})Build a template picker from the metadata
import {EMAIL_TEMPLATES} from '@/data/emailTemplates/meta'
import {useTranslation} from '@/platform/i18n'
function TemplatePicker({onPick}: {onPick: (slug: string) => void}) {
const {t} = useTranslation('nav')
return EMAIL_TEMPLATES.map((tpl) => (
<button key={tpl.slug} onClick={() => onPick(tpl.slug)}>
<tpl.icon /> {t(tpl.navKey)}
</button>
))
}Best practices
- Keep the eager/lazy split. Never import
@/data/emailTemplates(the HTML aggregate) from eager code — descriptors come from@/data/emailTemplates/meta. Breaking this ships every email in the main bundle. - Treat the string as the artifact. Edit copy and colors inside the template module; don't try to theme it with app tokens or Tailwind classes — email clients see none of that.
- Preserve the contract when editing. Keep base styles inline, keep the MSO conditional wrappers,
and keep exactly one
prefers-color-scheme: darkblock so the preview's scheme toggle keeps working. - Test both toggles before shipping copy changes. The mobile width exercises the stacking breakpoint; the Moon toggle exercises the dark overrides.
- Store keys for chrome, literals for the artifact.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Preview ignores the app's dark theme | By design — the iframe is isolated, and the email's own prefers-color-scheme block decides. Use the Sun/Moon toggle. |
| Scheme toggle does nothing on a custom template | forceScheme rewrites the @media (prefers-color-scheme: dark) string. Your template must contain exactly that query (once). |
| Copied HTML renders light-only / dark-only | It doesn't — the force-scheme rewrite is preview-only. Copy/Download always emit the original string. |
| Template looks unstyled in an email client | The client stripped the <style> block (expected) — base styles must be inline. Duplicate <style>-only styles inline. |
| New template's route 404s / renders a placeholder | The route is generated from EMAIL_TEMPLATES — check the meta.ts entry exists and its path matches the menu.ts leaf exactly. |
| Raw emails appear in the main JS bundle | Something eager imports @/data/emailTemplates (the index.ts aggregate). Only the lazy page may import it; eager code uses meta.ts. |
| Copy button silently does nothing | navigator.clipboard is unavailable (insecure context). Use Download, or serve over HTTPS/localhost. |
FAQ
Are the templates translated? No — deliberately. The HTML is a shippable artifact buyers copy out
and edit, and running markup-laden strings through the flat-JSON translate pipeline would mangle them.
Only the browser chrome (including the subject line displayed in the list, via subjectKey) is
translated through the email namespace.
Why an iframe instead of dangerouslySetInnerHTML? Isolation. srcDoc in a sandboxed iframe keeps
the email's CSS from colliding with Tailwind's preflight, and renders the email exactly as a standalone
document.
Do the templates work in Outlook? Yes — that's what the <!--[if mso]> ghost tables, the VML
v:roundrect CTA, and mso-line-height-rule: exactly are for.
Is there any persistence? No. The browser is stateless (device/scheme toggles reset per visit), and selection is just the route.
Can I use these with a templating engine? Yes — the strings are plain HTML; add your engine's
placeholders ({{first_name}}, *|ORDER_ID|*, …) after copying into your ESP.
Related
Architecture & Routing
How EMAIL_TEMPLATES folds into appRoutes, lazy chunks, and full-bleed presentation.
Layout System
Full-bleed sizing and the shell the browser sits in.
Components
The Modal and Dropdown primitives, Button, Badge, Tooltip, Toast.
Chat
A sibling full-bleed feature sharing the viewport-sizing idiom.
Internationalization
The email namespace and the keys-not-copy convention.
Was this page helpful?
