Skip to content
Transactional emails

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 &amp; 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 &#39;/&amp; 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 to false) 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 real true/false, never 0 or "" 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.