PVR Tech Studio

Email Templates

Twelve production-ready, email-client-safe HTML templates (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.

11 min read
Updated August 13, 2026

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 (documented in each module's header comment): 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 block, hover states — so the email still renders correctly if a client strips it.

  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

Bundle split by design. meta.ts holds descriptors only — no HTML — and is the side that route registration reads; the raw HTML strings are aggregated by src/data/emailTemplates/index.ts, which is imported only by EmailTemplatePage — so the full emails ship in that page's chunk, never in the main bundle.

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 (scheme + device toggles, view source, download, copy) 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 to their /email/* paths (selection = route); the active row swaps to the solid accent tile. Staggered entrance via the motion tokens.
src/components/email/EmailSourceModal.tsxRaw-HTML viewer: an editor-style CodeBlock (<slug>.html tab, line-wrap toggle) 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 (slug, path, nav:/email: keys, icon, accent, group). Read by route registration — deliberately 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 (e.g. WELCOME_EMAIL) with the shared engineering contract documented in the header comment.
src/locales/en/email.jsonThe email namespace: list hint, group headings, toolbar/modal chrome, per-template descriptions (*Desc), and the displayed subject lines (subject*).

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 come from the metadata, so EMAIL_TEMPLATES is the only place the list is maintained.

One page.tsx per template is generated from that array, each passing its own slug to the shared page:

// src/app/[locale]/(app)/email/welcome/page.tsx — generated
export default async function Page({params}: {params: Promise<{locale: string}>}) {
    const {locale} = await params
    setRequestLocale(locale)
    return <EmailTemplatePage slug="welcome" />
}

So adding a descriptor to EMAIL_TEMPLATES and re-running npm run codegen is what creates the route — you never hand-write one of these files.

The matching sidebar leaves live under Pages → Email Templates in src/data/menu.ts (12 leaves, paths matching EMAIL_TEMPLATES exactly), 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'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 (navigator.clipboard.writeText) 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 — the page resolves everything from its slug:

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 — no external CSS, no scripts, no app dependencies.

API / Props

EmailTemplateMeta (src/data/emailTemplates/meta.ts)

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 (groupAccount / groupBilling / groupMarketing / groupTables / groupOccasions).

EmailTemplatePageProps (EmailTemplatePage.tsx)

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

EmailSourceModalProps (EmailSourceModal.tsx)

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, shared with its toolbar).

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

Four spots, mirroring the add-a-page recipe:

  1. HTML module — create src/data/emailTemplates/myTemplate.ts exporting one HTML string. Follow the shared contract (copy an existing module as the starting point): 600px table layout, inline base styles, <!--[if mso]> ghost table + VML CTA, one prefers-color-scheme: dark block (the preview's scheme toggle depends on exactly one such block), hidden preheader. Register it in src/data/emailTemplates/index.ts (EMAIL_TEMPLATE_HTML).
  2. Descriptor — add the slug to the EmailTemplateSlug union and an entry to EMAIL_TEMPLATES in meta.ts (path, navKey, subjectKey, descKey, icon, accent, group). The route, list row, and mobile switcher all derive from this entry — no page or route code to touch.
  3. Menu leaf — add the matching item under Pages → Email Templates in src/data/menu.ts.
  4. i18n — add the nav:<navKey> label plus email:<subjectKey> and email:<descKey> to the locale files.

Add a list section

Append a key to EMAIL_TEMPLATE_GROUPS in meta.ts and add the email: heading to the locale files; empty groups are skipped automatically.

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; the exact palette is documented in each module's header comment). 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.

Preview widths

PREVIEW_WIDTH in EmailTemplatePage.tsx maps the device toggle to canvas widths (desktop: 640, mobile: 375). Adjust or extend it if your templates use a different container width — keep mobile below each template's stacking breakpoint (620px in the shipped set).

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,
})

Render a mini preview elsewhere in the app

import {EMAIL_TEMPLATE_HTML} from '@/data/emailTemplates'
 
;<iframe title="Receipt preview" srcDoc={EMAIL_TEMPLATE_HTML['small-table']} sandbox="" className="h-96 w-full rounded-xl border border-border" />

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 (the <style> block must remain optional), 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 — both are one click in the toolbar.
  • Store keys for chrome, literals for the artifact. Anything rendered by the app (names, subjects in the list, descriptions, toolbar labels) is an i18n key; anything inside the HTML string stays literal English on purpose.

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 toolbar's Sun/Moon toggle to force either scheme.
Scheme toggle does nothing on a custom templateforceScheme rewrites the @media (prefers-color-scheme: dark) string. Your template must contain exactly that query (once) for the toggle to find it.
Copied HTML renders light-only / dark-onlyIt doesn't — the force-scheme rewrite is preview-only. Copy/Download always emit the original string with the OS-driven media query intact.
Template looks unstyled in an email clientThe client stripped the <style> block, which is expected — but base styles must be inline. If you added styles only to the <style> block, duplicate them 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 EmailTemplatePage may import it; eager code uses meta.ts.
Copy button silently does nothingnavigator.clipboard is unavailable (insecure context). Use Download instead, 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. The email carries its own <style> block, resets, and document structure; srcDoc in a sandboxed iframe keeps that CSS from colliding with Tailwind's preflight (and vice versa), and renders the email exactly as a standalone document — which is what a client will see.

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 — nothing is written to localStorage.

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.

Notes for designers & content editors

  • Template copy, subjects, and colors inside the emails live in the src/data/emailTemplates/*.ts modules as literal HTML — edit them there. They are intentionally independent of the app's tokens, skins, and dark mode.
  • Browser chrome (list hint, group headings, displayed subjects, descriptions, toolbar labels, toasts) lives in src/locales/<lng>/email.json; template display names live in the nav namespace. Never edit these in the components.
  • List accents are semantic (primary/success/warning/danger/info via src/lib/accent.ts), so the list re-skins with the app even though the emails don't.
  • Preheaders matter: each template hides a preheader <div> right after <body> — it's the inbox preview line, so keep it updated when the headline changes.
  • The emails are asset-free (text wordmark, no hosted images), so previews and copies never break on missing resources.

Was this page helpful?