Transactional emails
Six transactional templates — welcome, password reset, order confirmation, shipping notification, invoice and a generic notification — ship as inlined HTML plus a plain-text alternative, in four locales, generated by tools/emails/build.mjs. This page covers what ships, how to preview and render a template, dark mode, and what has been verified so far.
What ships
Six transactional templates — welcome, password reset, order confirmation,
shipping notification, invoice, and a generic notification — each in four
locales (en, es, pt-BR, ar — right-to-left), as a table-based,
fully inlined .html file and a plain-text .txt alternative. No external
stylesheet, no web font, no script. manifest.json lists every template,
locale, subject and file path; assets/wordmark.png is the logo raster.
Choosing a locale and reading the manifest
Pick the locale from the recipient's own customer.locale. Each
manifest.json entry under templates.<name>.files.<locale> names the
subject, html and text file, and manifest.json.locales.<locale>.dir
is "rtl" for ar, "ltr" otherwise — set that as the outer document's
dir if you re-wrap the HTML.
Previewing
Open a template's .html file in a browser to see it laid out — emails/en/welcome.html, for example. Every {{token}} a sender fills at send time shows up unfilled, exactly as written. To see a template with real values instead, render it first, as in the next section.
Rendering
Every {{token}} is Mustache syntax and is proven, by
tools/emails/check.mjs, to render byte-identically through
mustache.js (https://github.com/janl/mustache.js) and through
Handlebars (https://handlebarsjs.com/) with its default options (no
compat). The .html files render with HTML escaping on; the .txt files
must render with escaping off (Mustache.escape = (s) => s, or Handlebars'
compile(src, { noEscape: true })) — they carry no markup of their own, and
escaping them would turn & into & in a plain-text email. Never use
triple-stash ({{{…}}}) or {{&…}}: none of the source templates do, and a
sender's own values are expected to go through the normal, escaped tag.
manifest.json's subject strings render the same way as the .txt
file — escaping off — since they go into a mail header, not markup: with
default escaping on, an apostrophe or an ampersand in {{brand.name}} or in
a sender's own {{notification.subject}} would reach the inbox as
'/& in the subject line.
Send the rendered pair as a multipart/alternative message: text/plain
from the .txt file, text/html from the .html file.
The placeholder contract
- A token inside an object-shaped optional section (
{{#order.discount}}…,{{#action}}…) is referenced relative to that section inside it —{{amount}}, not{{order.discount.amount}}— because entering the section changes the template's current data context in both engines. Render it as an object ({ amount: "-€5.00" }) when present, and omit the key (or set it tofalse) when absent. - A token inside a list section (
{{#order.items}}…) is relative to each item the same way:{{name}},{{quantity}}, one row per array entry. - A boolean flag (
{{#invoice.isPaid}}…{{^invoice.isPaid}}…) does not change the context — everything inside it still uses full, top-level paths — so pass a realtrue/false, never0or""standing in for one. - Every other token is a plain string the sender preformats: money, dates and enum labels already rendered in the recipient's locale. The templates never format a number or a date themselves.
Right-to-left (ar)
dir="rtl" sits on <html>, the repeated outer wrapper table (Gmail strips
attributes off <html>) and on text-bearing cells — never on a purely
structural layout table. LTR-typed values embedded inside an Arabic sentence
(order numbers, tracking numbers, email addresses, dates) are wrapped in
Unicode isolate characters (U+2066…U+2069) so the surrounding right-to-left
run does not reorder them; free-text values that may be in another script
(a name, the brand) use the "first strong" isolate instead. Table cells that
hold an amount, an id or a URL by themselves carry an explicit dir="ltr"
and align="left", the same convention shared/styles/billing.css uses for
the product's own RTL screens — never a logical CSS property.
Dark mode
color-scheme: light dark and a @media (prefers-color-scheme: dark) block
repaint every role to its dark-ink hex — honoured by Apple Mail and
Outlook.com/macOS/iOS, ignored by Gmail and classic Windows Outlook, which
simply keep the light design
(caniemail (https://www.caniemail.com/features/css-at-media-prefers-color-scheme/)).
tools/emails/render.mjs additionally simulates, mathematically, two
smart-invert heuristics some clients apply to a light-only message it decides
to darken:
- full inversion — every colour passed through
invert(1) hue-rotate(180deg)(the CSS Filter Effects matrix), fg and bg alike; - partial inversion — a background lighter than mid-grey is inverted, a darker one is kept; a foreground darker than mid-grey is inverted, a lighter one is kept — approximating a client that leaves already-dark chrome alone and only re-colours light surfaces.
These are simulations of undocumented client heuristics, not a specification either client publishes, and are documented here as exactly that. Under the partial model, an element that is a background colour on both sides (the CTA's ink fill sitting on the card) can end up the same darkness as its inverted-to-dark container even though the button's label text stays legible in every mode — the render check treats that as a known, reported limitation of the simulation rather than a failure — only an inbox running on an actual phone or desktop, not this simulation, can settle it.
What was verified, and what is pending
Real mail clients — Outlook (classic and new), Gmail (web and app), Apple Mail, iOS Mail, and an Android client — have not been tested against these templates. That verification is follow-up work, not yet done.
node tools/emails/build.mjs --check proves the 48 rendered files, the manifest, emails/README.md's generated blocks and the logo asset match the builder exactly. node tools/emails/check.mjs runs every static rule: the caniemail-safe CSS lint, the message-catalogue lint, placeholder coverage, and Mustache/Handlebars engine parity, across sample data that exercises every optional section and flag, both present and absent. node tools/emails/render.mjs renders a Playwright Chromium and WebKit matrix at 320px and 600px — images blocked, forced dark mode, the two simulated dark-mode inversions above, the call-to-action's tap target, and computed contrast on every visible text node.
Rebuilding
emails/ is a generated folder. See generated folders for the exact command and how to recognise it as generated.