PVR Tech Studio
Email templates

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.

9 min read
Updated July 15, 2026

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:

  1. 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-width bgcolor wrapper, an <!--[if mso]> ghost table for Outlook, all base styles inline (+ bgcolor attributes), a bulletproof VML v:roundrect CTA for Outlook, web-safe font stacks, a hidden preheader, and color-scheme metas. The <style> block is progressive enhancement only — mobile stacking, a prefers-color-scheme: dark override, hover states.

  2. The browser — one shared two-pane page (EmailTemplatePage) mounted on all 12 /email/* routes. The left pane is the template list; its rows are NavLinks, 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:

GroupTemplates (slug → route)
Accountwelcome/email/welcome · forgot-password/email/forgot-password · activate/email/activate
Billingorder-confirmation/email/order-confirmation · payment-due/email/payment-due
Marketingproduct-drop/email/product-drop · wrapped/email/wrapped · winback/email/winback · preferences/email/preferences
Tableslarge-table/email/large-table · small-table/email/small-table
Occasionsbirthday/email/birthday

Architecture & files

FileResponsibility
src/pages/email/EmailTemplatePage.tsxThe 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.tsxLeft 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.tsxRaw-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.tsThe eager descriptor side: EmailTemplateSlug, EmailTemplateMeta, EMAIL_TEMPLATE_GROUPS, and EMAIL_TEMPLATES. Imported by routes.tsx — contains no template HTML.
src/data/emailTemplates/index.tsThe 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.jsonThe 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 (emailWelcomeemailPreferences).

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's max-width: 620px stacking breakpoint).
  • The app's dark theme does not restyle the preview. Each template carries exactly one prefers-color-scheme: dark block, and the toolbar's Sun/Moon toggle force-tests it via forceScheme, 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 Blob of type text/html;charset=utf-8 and saves it as <slug>.html.
  • View source opens EmailSourceModal — the same string in a CodeBlock with 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']

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

FieldTypeDescription
slugEmailTemplateSlugStable id; also names the downloaded file (<slug>.html) and keys EMAIL_TEMPLATE_HTML.
pathstringAbsolute route path — must match the menu.ts Email Templates leaf exactly.
navKeystringnav: label key (the template's display name).
subjectKeystringemail: key for the subject line shown in the list row (the HTML artifact keeps its EN subject).
descKeystringemail: description key for the preview toolbar.
iconLucideIconList-row / mobile-switcher icon.
accentAccentIcon-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

PropTypeDescription
slugEmailTemplateSlugWhich template to show (set per-route in routes.tsx). Unknown slugs fall back to the first template.

EmailSourceModalProps

PropTypeDescription
openbooleanModal visibility.
onClose() => voidClose handler.
namestringTemplate display name (already translated).
slugstringNames the editor tab (<slug>.html).
htmlstringThe raw template source.
onDownload() => voidSaves the template as a .html file (owned by the page).

Data exports

ExportFromDescription
EMAIL_TEMPLATES@/data/emailTemplates/metaThe 12 descriptors, in list order (eager-safe).
EMAIL_TEMPLATE_GROUPS@/data/emailTemplates/metaSection order for the list (5 email: heading keys).
EMAIL_TEMPLATE_HTML@/data/emailTemplatesRecord<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: dark block 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

SymptomCause / fix
Preview ignores the app's dark themeBy 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 templateforceScheme rewrites the @media (prefers-color-scheme: dark) string. Your template must contain exactly that query (once).
Copied HTML renders light-only / dark-onlyIt doesn't — the force-scheme rewrite is preview-only. Copy/Download always emit the original string.
Template looks unstyled in an email clientThe client stripped the <style> block (expected) — base styles must be inline. Duplicate <style>-only styles inline.
New template's route 404s / renders a placeholderThe 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 bundleSomething eager imports @/data/emailTemplates (the index.ts aggregate). Only the lazy page may import it; eager code uses meta.ts.
Copy button silently does nothingnavigator.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.

Was this page helpful?