HTML output
The HTML part is built by github.com/sulv-io/lttr/render/html from the laid-out document. It is table-based markup of the kind every mail client renders, including the Word engine inside desktop Outlook. You do not write any of it: templates describe content, and the renderer owns every tag, style and size.
The partials are html/template files embedded in the package and parsed once into an immutable set. Everything that reaches them is decided first: sizes from constants, colours from the validated theme, URLs from CheckURL.
Read the document skeleton
Every email has the same outer structure, in this order:
<!DOCTYPE html>.<html lang="en">with thexmlns,xmlns:vandxmlns:onamespaces Outlook needs for VML.- The head:
charset,viewport,x-apple-disable-message-reformatting,format-detection(telephone, date, address, email and url all off),color-schemeandsupported-color-schemesset tolight dark; a<title>holding the subject; the OutlookOfficeDocumentSettingsblock (AllowPNG,PixelsPerInch96); and one<style>element. <body id="body" class="dk-canvas">.- The hidden preheader: the preheader text followed by 60 repeats of
͏‌ , so the inbox preview does not run on into the email's first words. - An outer full-width table on the canvas colour, an Outlook-only 600px wrapper table, and the inner table capped at
max-width:600px. - The header rows, one row per section, and the footer row.
<body id="body" class="dk-canvas" style="margin:0;padding:0;width:100%;background-color:#FFFFFF">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#FFFFFF;opacity:0">EC$15 flat delivery, plus 15% off with HERITAGE15.͏‌ …</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" class="dk-canvas"><tr><td align="center">
<!--[if mso]><table role="presentation" width="600" align="center" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="dk-canvas" style="width:100%;max-width:600px;margin:0 auto;background-color:#FFFFFF">
<tr><td align="center" style="padding:36px 0 0 0"><img class="logo-lt" src="https://caribhubs.com/assets/email/logo.png" alt="CaribHubs" width="36" height="36" style="…"><!--[if !mso]><!--><div class="logo-dk" style="display:none;max-height:0;overflow:hidden"><img src="https://caribhubs.com/assets/email/logo-dark.png" alt="CaribHubs" width="36" height="36" style="…"></div><!--<![endif]--></td></tr>
<tr><td class="px" style="padding:40px 56px 36px 56px;text-align:center">…</td></tr>
…
Build with presentation tables
Layout is tables and nothing else:
- Every
<table>carriesrole="presentation" cellpadding="0" cellspacing="0" border="0", so screen readers do not announce layout tables as data. - There are no
div-based layouts, and noflex,grid,var(or@importanywhere. - Every section is a row:
<tr><td class="px" style="padding:{top}px 56px {bottom}px 56px;text-align:…">, with the top and bottom from Layout and spacing. Bands (images and maps) are rows with no padding. - Inline styles come from a typed style builder that accepts only token sizes, theme colours and fixed keywords, and reach the templates as
template.CSS. Data from your templates never flows into CSS or attribute names. - Paragraphs in hero modules (statement, figure, offer) and pull quotes are capped at 440px and centred, and wrapped in a 440px Outlook-only table because Outlook ignores
max-width.
Serve Outlook
html/template strips HTML comments, including the conditional comments Outlook depends on. They reach the output only through a few trusted helpers, built from constants and escaped values:
| Helper | Emits | Used for |
|---|---|---|
msoOpen N / msoClose | <!--[if mso]><table … width="N" align="center" …><tr><td><![endif]--> and its close | The 600px frame |
msoIf / msoEnd | <!--[if mso]> … <![endif]--> | The OfficeDocumentSettings block, 440px paragraph tables, the VML button |
notMsoOpen / notMsoClose | <!--[if !mso]><!--> … <!--<![endif]--> | Markup Outlook must not see: the dark logo, the HTML button link |
vmlPill | A v:roundrect | The button as Outlook draws it |
The button is written twice. Outlook gets a VML rounded rectangle, 48px tall with arcsize="50%", filled with Light.Action, its label in Light.PillText set in Arial,sans-serif. Its width is estimated at 9px per character plus 48px of padding, capped at 488px so a long label cannot widen the email. Every other client gets an <a> inside a bgcolor cell with border-radius:999px, hidden from Outlook by <!--[if !mso]> and mso-hide:all.
Support dark mode
The email declares color-scheme: light dark and carries a dark palette in its head <style>. The light palette is inline; the dark one only takes over in clients that support it:
- Clients that support
prefers-color-scheme, Apple Mail and iOS Mail among them, apply the@media (prefers-color-scheme:dark)block. Elements carrydk-*classes (dk-canvas,dk-ink,dk-body,dk-act,dk-pill, …), and each class maps to a dark palette colour with!important. See Themes for the whole list. - Outlook.com rewrites colours itself and marks what it changed with
data-ogsc(text) anddata-ogsb(backgrounds). The same rules are repeated as[data-ogsc] .dk-ink{…}for text and border colours and[data-ogsb] .dk-canvas{…}for backgrounds, so its result uses your dark palette rather than its own guesses. - The logo swaps:
.logo-ltis hidden and.logo-dk, which is collapsed withdisplay:none;max-height:0;overflow:hiddenin light mode, is shown. The dark logo sits inside<!--[if !mso]>, so Outlook never shows both. - Clients that read neither keep the light palette, unless they recolour the email themselves.
@media (prefers-color-scheme:dark){
.dk-canvas{background-color:#121615!important}
.dk-foot{background-color:#1A1F1D!important}
.dk-ink{color:#F2F2F0!important}
.dk-body{color:#A8ADAB!important}
.dk-act{color:#8BC9C3!important}
…
.logo-lt{display:none!important}
.logo-dk{display:block!important;max-height:none!important;overflow:visible!important}
}
[data-ogsc] .dk-ink{color:#F2F2F0!important}
…
[data-ogsb] .dk-canvas{background-color:#121615!important}
…
Adapt to phones
Below 480px, the rules in @media only screen and (max-width:480px) take over:
| Class | On a phone |
|---|---|
.px, .hr-px | Side padding 24px instead of 56px |
.h-1, .h-xl | Headings 30/34 |
.fig | The figure amount 46/46 |
.otp | The code module 34/34, letter spacing 7px |
.cta, .cta-cell, .cta-gap, .pill-t, .pill-a | An action row stacks: cells become full-width blocks, the gap is hidden, the button fills the width with 16px/24px padding |
.col, .col-2 | Meta columns stack, the second 20px below the first |
.g2, .g2-2, .g2-x | Story and product tiles stack with a hairline between them; the filler cell after an odd last tile is hidden |
.tile-img | Tile images stay 160px tall (D32) |
.hero, .hero-td | Image and map bands 240px tall, cropped with object-fit:cover |
.step-l | Progress labels 11px |
.fnav a | The F1 nav links wrap with 10px between rows |
Clients that ignore media queries show the 600px layout.
Give every image alt text
- Logos are
<img>withaltset toTheme.Brand.Name,widthandheightset toBrand.LogoSize, andborder:0. - Image and map bands are a
tdwith the tone ground asbgcolorand a fixed height, holding<img class="hero" width="600" style="width:100%;height:…;object-fit:cover;display:block;border:0">. Without a source the band is the coloured ground alone, which makes a clean placeholder. - A source without alt text is a render error:
image: alt is empty for src. The same holds for maps, story and product tiles and receipt thumbnails. Story and product tiles takealtand default it to the title or name (D11).
Check every URL
Every URL in the HTML goes through html.CheckURL before a template sees it: links in prose, buttons and :link actions, logos, images, tiles, thumbnails and the F1 nav.
func CheckURL(raw string, allowRelative bool) (template.URL, error)
| Input | Result |
|---|---|
https://…, http://… with a host | Allowed |
mailto:…, tel:… with an address | Allowed |
| A relative URL | Allowed only with Config.AllowRelativeURLs (previews) |
| Empty | url "": empty |
| Whitespace or a control character anywhere | url "…": contains whitespace or control character |
| Any other scheme | url "javascript:alert(1)": scheme "javascript" not allowed |
A failed check is a render error. The renderer never substitutes # for a bad URL. A passing URL is returned unchanged as template.URL, so tel: links are not rewritten by html/template. Merge values inside a URL are escaped before the check (D34, see Config and the renderer).
Stay under 100 KB
Gmail clips HTML over 102 KB. Render warns over 90 KB and fails at 100 KB (see the size limit). The Quiet examples weigh 9 to 16 KB.
Lint the structure
The repository's tests run every rendered example through a structural lint, internal/htmlcheck (an internal package, not importable). It reports:
- any
var(,@import,<scriptor{{in the document; flexorgridinside astyleattribute or<style>element;- a
<table>withoutrole="presentation" cellpadding="0" cellspacing="0" border="0"; - an
<img>withoutalt,widthorborder:0in its style; - a
<div>styleddisplay:flex; - a missing
<meta name="color-scheme" content="light dark">; - a document of 100 KB or more;
- unbalanced Outlook conditional comments.
Markup inside Outlook-only comments is linted too. See Goldens and testing.
See it in light, dark and on a phone
This is the Quiet campaign email at 600px and 375px. Switch the preview to dark to see the dark palette and the logo swap: