← All plugins Plugin II — HTML Email Auditing
No sinful template shall pass.
HTML email development under the watchful eye of the Witchfinder. Twelve doctrines. Three skills. From full structural audits to righteous generation from first principles — client compatibility, inline CSS, table structure, and responsive design all held to account.
/email-absolution:elder /email-absolution:visitation /email-absolution:scribe The skills
/email-absolution:elder Full Audit — Your First Port of Call
Elder is the full audit skill — config setup, template auditing, and routing to
Visitation or Scribe. Start here. Elder will offer to create
.email-absolution/config.yml via a setup
questionnaire on first invocation. From there it audits templates against all twelve
doctrines and produces a structured verdict.
Invocation modes
/email-absolution:elder Changed files — diff against base branch (default) /email-absolution:elder full All templates in configured email_paths /email-absolution:elder <file> Audit a single named template /email-absolution:elder doc Changed files + save structured markdown audit report /email-absolution:elder <file> doc Single template + save markdown audit report /email-absolution:elder interactive All templates — step through findings and fix one by one /email-absolution:elder <doctrine> Changed files, single doctrine only (e.g. rendering) /email-absolution:visitation Formal Inspection
Formal inspection of an existing email template against all doctrines. Use Visitation for a focused, targeted audit when you already know which template needs examination. Spot-checks — quick, precise, unsparing.
Invocation modes
/email-absolution:visitation Audit all email templates changed in this branch vs base /email-absolution:visitation <file> Spot-check a single named template /email-absolution:visitation pr <number> Audit changed templates in a specific PR /email-absolution:visitation interactive Step through findings — fix, explain, skip, or note as exception /email-absolution:scribe Righteous Generation
Generates new email templates correct by construction. Brief the Scribe, and it builds a template that adheres to all twelve doctrines from the ground up — no post-hoc remediation required. The righteous path from the outset.
Invocation modes
/email-absolution:scribe Interactive — Scribe asks for email type, ESP, variables, and brand constraints before generating /email-absolution:scribe <brief> Direct — pass the brief inline. Include email type, stack, and available variables for immediate generation Doctrine catalog
Core doctrines load for all skills. Tooling doctrines load based on your
stack.templating config.
Expand any doctrine to see its full rule set — purpose, rules, and detection patterns.
Core — loaded by all skills
RENDER Rendering
Guards against HTML and CSS patterns that cause broken or invisible content in major email clients. The primary adversary is Outlook 2007–2019 (Word renderer), …
RENDER-001 mortal Never use url() in inline style attributes Gmail desktop webmail removes the entire style attribute from any element containing a url() function. This strips ALL inline styles from that element — layout collapses, colours disappear, padding vanishes. Source: caniemail.com/features/css-background-image.
detect: regex` — pattern: `style="[^"]*url\s*\(
RENDER-002 mortal All <img> elements must have display: block in their inline style Images rendered inline (the default) introduce a 3–4px gap below them in table cells across all major clients. display: block eliminates this gap. Required on every <img> without exception.
detect: regex` — pattern: `<img(?![^>]*display:\s*block)[^>]*>
RENDER-003 mortal All <img> elements must have border="0" as an HTML attribute Outlook 2007–2019 and older WebKit-based clients render a visible blue border on linked images unless border="0" is explicitly set as an HTML attribute (not just CSS border: 0). Source: standard cross-client pattern.
detect: regex` — pattern: `<img(?![^>]*\bborder=)[^>]*>
RENDER-004 mortal Never use whitespace-syntax rgb() or rgba() Gmail strips entire CSS rules that use the CSS Color Level 4 whitespace syntax (rgb(0 128 0) or rgba(0 128 0 / 0.5)). Use comma syntax only: rgb(0, 128, 0) and rgba(0, 128, 0, 0.5). Source: caniemail.com/features/css-rgba; hteumeuleu/email-bugs #160.
detect: regex` — pattern: `rgba?\(\s*\d+\s+\d+\s+\d+
RENDER-005 mortal Use <table> for all structural layout. Never use <div>, float, or display: flex as primary layout primitives Outlook 2007–2019 (Word renderer) does not support <div>-based layouts, float, or flexbox. Tables are the only layout primitive guaranteed to work across all clients. Source: caniemail.com/features/css-display-flex.
detect: contextual` — check if structural layout (columns, wrappers, sections) uses non-table elements
RENDER-006 mortal All layout <table> elements must have border="0" cellpadding="0" cellspacing="0", and border-collapse must never be set to collapse on layout tables Without these attributes, browsers and Outlook apply default table borders, cell spacing, and padding that create phantom gaps and misalignments. These must be HTML attributes, not CSS. Additionally, border-collapse: collapse in a <style> block or inline style causes Outlook 2007–2019 to render double borders and cell spacing artefacts; always use border-collapse: separate or omit the property entirely.
detect: regex` — two patterns: (1) `<table(?![^>]*\bcellpadding=)[^>]*>` — flags any `<table>` missing `cellpadding`; (2) `border-collapse\s*:\s*collapse` — flags `collapse` value anywhere in styles
RENDER-007 mortal All layout <table> elements must have role="presentation" Screen readers announce table structure for data tables. Layout tables must declare role="presentation" to suppress this. This is both a rendering and accessibility requirement — its absence causes screen readers to announce "table, 3 columns, 5 rows" for visual layout scaffolding. Source: WCAG 2.1.
detect: regex` — pattern: `<table(?![^>]*\brole=)[^>]*>
RENDER-008 mortal Do not use min-height in inline styles for elements that must be visible in Outlook Outlook 2007–2019 ignores min-height entirely. Sections relying on min-height to push content down or create visual space will collapse to zero height. Use the height HTML attribute on <td> elements or empty spacer rows instead. Source: standard Outlook limitation.
detect: regex` — pattern: `style="[^"]*min-height\s*:
RENDER-009 mortal All image src and href attributes must use absolute HTTPS URLs Relative URLs are not resolved by email clients (there is no base URL context). HTTP URLs may be blocked by corporate security proxies and trigger security warnings in modern clients. Source: standard email rule; Gmail relative URL blocking.
detect: regex` — pattern: `(?:src|href)=["']/(?!/)
RENDER-010 mortal Keep total HTML under 102,400 bytes (102 KB) Gmail clips email HTML at exactly 102 KB and replaces remaining content with a "[Message clipped] View entire message" link. Content after the clip point is invisible unless the user clicks through. Transactional content (order details, CTAs) after the clip is effectively lost. Source: caniemail.com/features/html-style.
detect: contextual` — estimate HTML size from template; flag if approaching limit with inlined styles
RENDER-011 venial Do not use z-index in inline styles on elements targeting Outlook 2007–2019 Outlook 2007–2019 ignores z-index. Elements stacked with z-index for visual layering will not stack correctly in Outlook. Source: standard Outlook limitation.
detect: regex` — pattern: `style="[^"]*z-index\s*:
RENDER-012 venial Do not use rgba() colours without a hex fallback for Outlook 2007–2019 Outlook 2007–2019 does not support rgba(). Semi-transparent backgrounds, overlays, and tints using rgba() render as fully transparent (or opaque, depending on context). Always precede rgba() with a hex or rgb() fallback in a <style> block rule. Source: caniemail.com/features/css-rgba.
detect: regex` — pattern: `rgba\([^)]+\)` (check for hex fallback in same rule or preceding rule)
RENDER-013 venial Multi-column layouts must use the ghost table pattern with MSO conditional comments Outlook 2007–2019 cannot render display: inline-block multi-column layouts. The ghost table pattern wraps columns in <!--[if mso]><table><tr><td>...<![endif]--> for Outlook while using display: inline-block for modern clients. Source: Nicole Merlin "Hybrid Coding Technique"; Litmus "Ghost Tables".
detect: contextual` — check if multi-column inline-block layouts have ghost table MSO wrappers
RENDER-014 venial CTA buttons must include a VML bulletproof button for Outlook <a> link padding is not rendered in Outlook 2007–2019. A CSS button with padding on the <a> element appears as an unstyled link in Outlook. The VML bulletproof button technique (<v:roundrect> inside <!--[if mso]>) renders a real button. Source: Campaign Monitor "Bulletproof Email Buttons" (buttons.cm).
detect: contextual` — check if `<a>` styled as button has `<!--[if mso]>` VML fallback
RENDER-015 venial Do not use background-image in CSS without a VML fallback for Outlook Outlook 2007–2019 does not support background-image on <div> elements. On <td> elements it is partial. VML <v:rect> with <v:fill> is required for background images to render in Outlook. Source: caniemail.com/features/css-background-image.
detect: contextual` — check if background-image sections have VML fallback in MSO conditional
RENDER-016 venial Keep each <style> block under 16 KB Gmail limits individual <style> blocks to 16,384 bytes. Content exceeding this limit is silently truncated. Rules at the end of a large <style> block may be missing without any visible error. Source: caniemail.com/features/html-style.
detect: contextual` — estimate size of each `<style>` block
RENDER-017 venial Apply mso-table-lspace: 0pt; mso-table-rspace: 0pt to all tables Outlook 2007–2019 adds 1–3px of phantom spacing on either side of table cells. This causes pixel-perfect layouts to drift and can cause two-column layouts to wrap. These MSO-specific properties eliminate the phantom spacing. Source: Litmus Email Boilerplate.
detect: regex` — pattern: `<table(?![^>]*mso-table)[^>]*>` (check style attribute or style block rule)
RENDER-018 venial Apply max-width via inline CSS on <td> or wrapper <div>, not on <table> for Outlook compatibility Outlook 2007–2019 ignores max-width on <table> elements per the CSS 2.1 spec. Use the width HTML attribute on <table> to set the absolute width for Outlook, and max-width CSS on the containing <td> for fluid behaviour in modern clients. Source: caniemail.com/features/css-max-width.
detect: contextual` — check if layout tables use both width attribute and max-width CSS
RENDER-019 venial Apply color and text-decoration to <a> elements via inline style, not <style> block rules only Some clients (Outlook.com, older Yahoo) strip <a> colour rules from <style> blocks. Inline styles on <a> elements ensure link colours and underline removal render as intended.
detect: regex` — pattern: `<a\s[^>]*href=[^>]*>(?![^<]*style=)` (linked anchor without inline style)
RENDER-020 venial Do not use padding shorthand on <td> elements — use explicit directional properties Outlook 2007–2019 has inconsistent shorthand parsing for padding. Explicit properties (padding-top, padding-right, padding-bottom, padding-left) are more reliably applied. Additionally, Outlook applies the largest vertical padding value to all cells in the same row — use padding on only one <td> per row. Source: caniemail.com/features/css-padding.
detect: regex` — pattern: `<td[^>]*style="[^"]*padding\s*:\s*\d
RENDER-021 counsel Include <meta name="x-apple-disable-message-reformatting"> in the <head> Prevents iOS Mail from resizing and reformatting emails it detects as "too small". Without this meta tag, iOS Mail may zoom in and rescale the layout unexpectedly. Source: Email on Acid; Litmus boilerplate.
detect: regex` — pattern: `x-apple-disable-message-reformatting` (check for presence)
RENDER-022 counsel Include <meta name="color-scheme" content="light dark"> and <meta name="supported-color-schemes" content="light dark"> These meta tags signal to Apple Mail, iOS Mail, and other WebKit clients that the email has dark mode styles, preventing unwanted forced inversion on clients that respect these declarations. Source: Litmus "Dark Mode for Email".
detect: regex` — pattern: `color-scheme` (check for presence in head)
RENDER-023 counsel Set bgcolor HTML attribute in addition to CSS background-color on <td> and <table> elements Some older Outlook versions and webmail clients ignore CSS background-color but respect the deprecated bgcolor HTML attribute. Using both ensures background colours render everywhere.
detect: contextual` — check if primary background cells use both bgcolor and CSS background-color
RENDER-024 counsel Set mso-line-height-rule: exactly on all elements with an explicit line-height value Outlook 2007–2019 interprets line-height differently from browsers. Without mso-line-height-rule: exactly, Outlook may apply extra leading above text, pushing content down and causing layout drift in fixed-height cells. Source: standard Outlook typography pattern.
detect: regex` — pattern: `line-height\s*:\s*\d[^;]*;(?![^"]*mso-line-height-rule)
RENDER-025 counsel Animated GIFs must not convey critical transactional information Outlook 2007–2019 shows only the first frame of an animated GIF. If the animation cycles through states (e.g. a progress animation), the first frame must be meaningful and the email must be fully comprehensible with only the first frame visible. Source: caniemail.com.
detect: contextual` — check if animated GIFs are used and if first frame is a meaningful static state
HTML HTML & CSS Practices
Guards against HTML structure and CSS usage patterns that produce broken, unstyled, or mis-spaced output in email clients. Where the rendering doctrine covers *…
HTML-001 mortal Never use <p> tags for spacing or layout Outlook 2007–2019 applies its own default margins to <p> elements that vary by Word version and cannot be fully reset via CSS. This causes inconsistent spacing between versions and breaks pixel-level layouts. Use table cell padding for spacing instead. If <p> is used for paragraph text, always include style="margin: 0; padding: 0;" explicitly.
detect: regex` — pattern: `<p(?![^>]*style="[^"]*margin:\s*0)[^>]*>
HTML-002 mortal Headings (<h1>–<h6>) must have margin: 0 set inline Outlook and most clients apply browser-default margins to headings. Without an inline margin: 0, headings introduce unexpected vertical gaps above and below them that compound in multi-section layouts.
detect: regex` — pattern: `<h[1-6](?![^>]*style="[^"]*margin)[^>]*>
HTML-003 mortal display: none on any element must be accompanied by mso-hide: all Outlook 2007–2019 ignores display: none for some element types, rendering hidden content (preheaders, mobile-only blocks, dark mode swaps) visibly. mso-hide: all is the MSO-specific equivalent and must accompany every display: none declaration. Source: standard Outlook workaround pattern.
detect: regex` — pattern: `display:\s*none(?![^"]{0,120}mso-hide)
HTML-004 mortal Do not nest <table> elements more than 3–4 levels deep Deep table nesting causes rendering performance issues and layout glitches in older Outlook and Yahoo clients. Heavily nested tables also become unmaintainable. Flatten layout where possible; use padding and spacer rows for spacing rather than nested tables.
detect: contextual` — count table nesting depth; flag structures exceeding 4 levels
HTML-005 mortal font-family declarations must include at least one web-safe fallback Custom fonts (@font-face) are not supported in Gmail, Yahoo, or Outlook 2007–2019. If a custom font is declared without a web-safe fallback (e.g., font-family: 'MyFont'), these clients render the browser default (usually Times New Roman), which is almost never acceptable for production email. Source: caniemail.com.
detect: regex` — pattern: `font-family\s*:\s*['"]?[A-Za-z][^;'"]*['"]?\s*[;"]` (check for single font — no comma following)
HTML-006 mortal <a> elements with custom colours must have color and text-decoration set via inline style Outlook.com, older Yahoo, and Gmail may strip <a> colour rules from <style> blocks. Without inline styles on the <a> element itself, link colours revert to the client's default blue underlined style, which breaks branded button colours and link styling in footers and body text.
detect: regex` — pattern: `<a\s[^>]*href=[^>]*>` (check if each linked anchor has an inline style attribute)
HTML-007 venial Do not use <br> tags as spacing substitutes between content blocks <br> spacing is inconsistent across clients — some add extra padding, some collapse multiple <br> tags. Use table rows with a fixed-height <td> (height attribute + font-size: 0) for reliable vertical spacing between content blocks.
detect: contextual` — check for `<br>` elements used outside paragraph or heading context (i.e. as spacers between table rows)
HTML-008 venial Use padding-top, padding-right, padding-bottom, padding-left instead of padding shorthand on <td> elements Outlook 2007–2019 has inconsistent shorthand padding parsing. Explicit directional properties are more reliably applied. Also, Outlook's vertical padding row bug — all cells in a row inherit the largest vertical padding value — means vertical padding should be applied to at most one <td> per row. Source: caniemail.com/features/css-padding.
detect: regex` — pattern: `<td[^>]*style="[^"]*\bpadding\s*:\s*\d
HTML-009 venial Inline all layout-critical CSS directly on elements Gmail strips <head> <style> blocks in some rendering contexts. Styles critical to layout — background-color, color, font-family, font-size, line-height, padding-*, width, border — must be inlined. <style> block rules may be used as a progressive enhancement layer (dark mode, hover states, responsive breakpoints) but cannot be the sole source of layout-critical styles.
detect: contextual` — check if structural elements (outer wrapper, content cell, text) have inline styles for the properties listed above
HTML-010 venial Do not use float or position: absolute/relative for structural layout Both are unreliable across email clients. float is partially supported but collapses unpredictably in Outlook and some webmail. position: absolute/relative is unsupported in most webmail clients. Use table-based layout for all structural positioning.
detect: regex` — pattern: `style="[^"]*(?:float\s*:|position\s*:\s*(?:absolute|relative|fixed))
HTML-011 venial Do not use border-radius as the sole method for rounded buttons in cross-client emails Outlook 2007–2019 does not support border-radius. Buttons with rounded corners will appear as square-cornered boxes in Outlook unless a VML <v:roundrect> fallback is present. The CSS border-radius may remain for modern clients but must not be the only implementation. Source: caniemail.com.
detect: contextual` — check if elements with `border-radius` inside CTA sections have an accompanying VML fallback
HTML-012 venial <img> elements inside <a> elements must have border="0" and style="text-decoration: none;" on the wrapping <a> Some clients render a blue underline or border around linked images. border="0" on the <img> and text-decoration: none on the <a> prevent both. The HTML attribute border="0" is required in addition to CSS border: 0.
detect: regex` — pattern: `<a[^>]*>\s*<img(?![^>]*\bborder=)[^>]*>
HTML-013 venial All images must have explicit width and height HTML attributes Without explicit dimensions, images that are blocked or slow to load cause the email layout to reflow or collapse. Explicit dimensions preserve layout structure even when images are not shown. max-width: 100% may be used in CSS to allow images to shrink on narrow viewports, but the HTML attribute dimensions remain required.
detect: regex` — pattern: `<img(?![^>]*\bwidth=)[^>]*>` or `<img(?![^>]*\bheight=)[^>]*>
HTML-014 venial Do not use margin: auto for centering content Outlook 2007–2019 does not support margin: auto. Centre-align content using align="center" on the <td> containing the content or text-align: center on the <td>. For the content table itself, wrap it in a 100%-wide outer table with align="center" on its cell.
detect: regex` — pattern: `style="[^"]*margin(?:-left|-right)?\s*:\s*auto
HTML-015 venial Do not use <span> for layout or spacing purposes margin on <span> is not supported in Outlook 2007–2019. <span> is appropriate for inline text styling (colour, font-weight) but must not be relied upon for layout spacing or structural positioning.
detect: contextual` — check for `<span>` elements with margin or padding used in structural contexts
HTML-016 counsel Use @media queries to stack columns on mobile, but never rely on them as the only path to mobile usability Gmail Android and some webmail clients do not support @media queries. The hybrid layout pattern (table with display: inline-block columns) provides fluid mobile behaviour without media queries. Use @media queries as an enhancement layer on top of a hybrid base that already works without them.
detect: contextual` — check if any multi-column layout depends solely on @media stacking without a hybrid fallback
HTML-017 counsel Declare color-scheme: light dark on the :root element in the <style> block alongside the equivalent <meta> tags Declaring color-scheme via CSS in addition to the <meta> tag provides broader coverage across WebKit-based clients that look for either signal. Without this, some clients apply forced colour inversion instead of using the email's own dark mode styles. Source: Litmus "Dark Mode Email Design Guide".
detect: regex` — pattern: `color-scheme` (check for both meta tag and CSS declaration)
HTML-018 counsel Dark mode overrides in @media (prefers-color-scheme: dark) must use !important on all declarations Inline styles have higher specificity than <style> block rules. Dark mode overrides that target inline-styled elements (the default for email) must include !important to win the specificity battle. Without !important, dark mode overrides have no effect on inlined colour properties.
detect: regex` — pattern: `prefers-color-scheme:\s*dark` (check that rules inside use `!important`)
HTML-019 counsel Yahoo/AOL/Fastmail/HEY users always see the light mode design Yahoo Mail and AOL transform @media (prefers-color-scheme) into a non-matching rule. Fastmail renders it as @media none. HEY renders it as @media (false). These clients silently discard dark mode overrides — do not design light mode as an afterthought.
detect: contextual` — advisory note; no code pattern to check
HTML-020 counsel Use bgcolor HTML attribute alongside CSS background-color on <td> and <table> elements Some Outlook builds and older clients ignore CSS background-color but respect the deprecated bgcolor attribute. Using both ensures background colours render everywhere. The bgcolor value must be a hex colour (no rgba, no named colours).
detect: contextual` — check primary background cells for both bgcolor attribute and CSS background-color
HTML-021 counsel Do not nest <a> elements Nested anchor elements are invalid HTML and produce unpredictable rendering across email clients. Some clients select the inner <a> and ignore the outer; others produce completely broken output. This situation typically arises in frameworks that auto-wrap linked images inside a second link.
detect: regex` — pattern: `<a[^>]*>(?:[^<]|<(?!a[^>]*/?>))*<a` (nested anchor)
HTML-022 counsel Provide a dark-mode image variant for logos and icons using class-based swap Logos designed for light backgrounds can become invisible or visually poor in forced-dark contexts. Provide a light-only version hidden in dark mode and a dark-only version hidden in light mode, swapped via display: none !important / display: block !important in the @media (prefers-color-scheme: dark) block.
detect: contextual` — check if logo `<img>` elements have a dark-mode alternative
HTML-023 venial Do not rely on @font-face in <style> blocks as the sole web font loading mechanism in email Gmail and Yahoo strip <head> <style> blocks in some rendering contexts, silently discarding any @font-face declarations. Outlook 2007–2019 does not support @font-face at all. Apple Mail and iOS Mail do support it. This means the custom font loads in roughly 20–30% of clients and is silently lost in the rest. Accept this trade-off explicitly and always declare a complete web-safe fallback stack in every font-family declaration (see HTML-005). Using @font-face without a fallback produces the client default (usually Times New Roman) in Gmail, Yahoo, and Outlook.
detect: regex` — part 1 (presence flag): pattern `@font-face\s*\{` — if found, confirms custom font is in use and this rule applies; part 2 (fallback check): contextual — for each custom font name found in `@font-face`, verify every `font-family` declaration using that name also includes a named web-safe fallback (not just the generic `sans-serif`)
ACCESS Accessibility
Guards against HTML patterns that exclude users with visual, motor, or cognitive disabilities from transactional email content. The European Accessibility Act (…
ACCESS-001 mortal All <img> elements must have an alt attribute Screen readers announce the filename when alt is absent ("one-pixel-spacer-dot-gif"). Omitting alt entirely is never correct. Decorative images use alt="". Informative images use descriptive text. Source: WCAG 2.1 SC 1.1.1 — Non-text Content (Level A).
detect: regex` — pattern: `<img(?![^>]*\balt=)[^>]*>
ACCESS-002 mortal Decorative images must use alt="" (empty string, not omitted, not a space) alt=" " (a space) is not treated as empty by all screen readers — some announce it as an unlabelled image or pause. alt omitted causes filename announcement. Empty string alt="" is the correct signal for decorative content. Source: WebAIM "Alternative Text"; WCAG 2.1 SC 1.1.1.
detect: regex` — pattern: `<img[^>]*\balt=["']\s+["'][^>]*>` (alt with only whitespace)
ACCESS-003 mortal All layout <table> elements must have role="presentation" Without role="presentation", screen readers announce table structure ("table, 3 columns, 5 rows") for every visual layout table, creating noise that obscures actual content. Layout tables must be marked as presentational. Source: WCAG 2.1 SC 1.3.1 — Info and Relationships (Level A); caniemail.com/features/html-role/ (~73% support).
detect: regex` — pattern: `<table(?![^>]*\brole=)[^>]*>
ACCESS-004 mortal <html> element must have lang attribute set to the primary language of the email Screen readers use the lang attribute to select the correct pronunciation engine and language rules. Without it, JAWS and NVDA fall back to the system default, mispronouncing non-English content. Source: WCAG 2.1 SC 3.1.1 — Language of Page (Level A).
detect: regex` — pattern: `<html(?![^>]*\blang=)[^>]*>
ACCESS-005 mortal Body text must meet WCAG 2.1 AA contrast ratio of 4.5:1 against its background Users with low vision or colour vision deficiency cannot read insufficient-contrast text. The WCAG 2.1 AA minimum is 4.5:1 for normal text (under 18px regular or 14px bold). Common failure: #999999 on white is only 2.9:1 — use #767676 as the minimum grey on white. Source: WCAG 2.1 SC 1.4.3 — Contrast (Minimum, Level AA).
detect: contextual` — verify text colour/background colour combinations against 4.5:1 threshold
ACCESS-006 mortal CTA buttons and interactive elements must meet 3:1 contrast ratio against adjacent colours UI component contrast (buttons, links as distinct from body) requires a minimum 3:1 ratio between the component and adjacent colours per WCAG 2.1. A blue button (#0066cc) on white achieves 4.6:1 — compliant. Source: WCAG 2.1 SC 1.4.11 — Non-text Contrast (Level AA).
detect: contextual` — check button background vs surrounding background colour ratio
ACCESS-007 mortal Link text must be descriptive without relying on surrounding context "Click here", "read more", and "learn more" are meaningless when a screen reader announces them in isolation (e.g. via link list navigation). Use descriptive text: "Track your order", "Download invoice", "Confirm your email". Source: WCAG 2.1 SC 2.4.6 — Headings and Labels (Level AA); WebAIM "Links and Hypertext".
detect: regex` — pattern (case insensitive): `<a\b[^>]*>\s*(?:click here|read more|learn more|view more|see more|here|click)\s*</a>
ACCESS-008 mortal Email must have a meaningful <title> element in <head> Screen readers announce the <title> when the email is opened. An absent or generic <title> (e.g. "Email") provides no context. Use the email subject or a descriptive title: "Order #12345 Confirmed — Acme". Source: WCAG 2.1 SC 2.4.2 — Page Titled (Level A).
detect: regex` — pattern: `<title\s*>(\s*|email\s*|untitled\s*)</title>` (absent or generic title)
ACCESS-009 venial Heading hierarchy must be logical: one <h1>, followed by <h2>, <h3> with no skipped levels Screen reader users navigate by headings. A heading structure that jumps from <h1> to <h3> or uses headings purely for visual sizing disrupts this navigation pattern. Every email should have exactly one <h1>. Source: WCAG 2.1 SC 1.3.1 — Info and Relationships.
detect: contextual` — check heading sequence for skipped levels and multiple h1 elements
ACCESS-010 venial Lists must use semantic <ul> or <ol> markup — not manually formatted with bullets or numbers in <p> tags Screen readers announce "list, 3 items" for <ul>, giving structural context. A visually identical list created with <p>• Item one</p> receives no structural announcement. Outlook 2007–2019 adds unwanted margins to <ul>/<ol> — correct with MSO styles rather than removing semantic markup. Source: WCAG 2.1 SC 1.3.1.
detect: regex` — patterns: `<p[^>]*>\s*[•\*\-–▸▪►]\s` (symbol bullet); `<p[^>]*>\s*\d+[.)]\s` (numbered list in paragraph)
ACCESS-011 venial Data tables (order summaries, line items) must use <th scope="col"> or <th scope="row"> for header cells Without scope attributes, screen readers cannot associate data cells with their headers, making order summaries and pricing tables inaccessible. Layout tables use role="presentation" (ACCESS-003); data tables use <th> with scope. Source: WCAG 2.1 SC 1.3.1; WebAIM "Tables".
detect: contextual` — check if tables containing price/item/quantity data have `<th>` with scope attributes
ACCESS-012 venial Minimum font size for body text is 14px. 16px is preferred iOS Mail auto-inflates fonts below 13px, potentially breaking layouts. Users with low vision rely on adequate base font sizes. pt units render inconsistently across email clients — use px exclusively. Source: Email on Acid "Mobile Email Rendering" (2022); WCAG 2.1 SC 1.4.4 — Resize Text.
detect: regex` — pattern: `font-size\s*:\s*([0-9]+)px` (flag values below 14, excluding footer/legal text)
ACCESS-013 venial Body text must have line-height of at least 1.4 (1.5 preferred) WCAG 2.1 SC 1.4.12 (Text Spacing, Level AA) specifies that content must remain accessible when line height is set to 1.5× font size. Compact line spacing reduces readability for users with dyslexia, cognitive disabilities, and low vision. Source: WCAG 2.1 SC 1.4.12 — Text Spacing (Level AA).
detect: contextual` — check line-height on primary body text paragraphs
ACCESS-014 venial Tap targets must be at least 44×44px Apple Human Interface Guidelines require 44×44px tap targets on iOS. Google Material Design specifies 48×48dp. Email buttons must have sufficient padding on the <a> element to meet this size. A CTA with line-height: 44px and horizontal padding creates the correct target. Source: Apple HIG; Google Material Design.
detect: contextual` — check button `<a>` or `<td>` computed height from line-height and padding
ACCESS-015 venial Colour must not be the sole means of conveying information Users with colour vision deficiency cannot distinguish colour-only signals. Red error text must have an icon or label. Status indicators (active/paused) need text labels, not just colour differences. Link text must be distinguishable from body text via underline or weight, not colour alone. Source: WCAG 2.1 SC 1.4.1 — Use of Colour (Level A).
detect: contextual` — check if error states, status badges, and links are distinguishable without colour
ACCESS-016 venial aria-label on links and buttons must provide meaningful context in supporting clients aria-label overrides the accessible name of an element for screen readers in ~58% of clients. However, Outlook 2007–2019 strips aria-label entirely. Visible link text must always be descriptive without relying solely on aria-label — it is an enhancement layer, not the primary accessibility mechanism. Source: caniemail.com/features/html-aria-label/ (~58.5% support).
detect: contextual` — check if any links rely solely on `aria-label` for their accessible name with no visible descriptive text
ACCESS-017 venial Do not use aria-describedby or aria-labelledby as the primary accessibility mechanism for Gmail, Outlook.com, or Fastmail audiences Gmail, Fastmail, and Outlook.com prefix element id values but do not update aria-describedby/aria-labelledby references, breaking the reference silently. Outlook 2007–2019 strips id attributes entirely. Use aria-label directly instead of reference-based ARIA where broad client support is needed. Source: caniemail.com/features/html-aria-describedby/ (~41% support).
detect: regex` — pattern: `aria-(?:describedby|labelledby)=
ACCESS-018 counsel Plain-text MIME version must be a complete, coherent rendering of the HTML content Screen reader users and accessibility tools on corporate mail gateways sometimes default to plain text. A stub ("View this in HTML") fails these users. CAN-SPAM also requires that required content (physical address, opt-out) appears in plain text. Source: WCAG 2.1; CAN-SPAM Act.
detect: contextual` — check if email config indicates plain-text version is present and complete
ACCESS-019 counsel Linked images alongside visible text should use alt="" to prevent double-announcing When an image is linked alongside visible text (e.g. a logo beside the company name), screen readers will announce both the alt text and the visible text. Use alt="" on the image when visible text already provides the link's context. Source: WCAG 2.1 SC 1.1.1; WebAIM "Alternative Text".
detect: contextual` — check for linked images with non-empty alt that appear adjacent to link text conveying the same information
ACCESS-020 counsel Outlook 2007–2019 list margin fix should be included when <ul> or <ol> is present Outlook adds large unwanted margins to lists. The MSO-specific conditional comment fix prevents lists from appearing indented off-screen in some Outlook configurations. Source: standard Outlook pattern.
detect: regex` — pattern: `<[uo]l(?![^>]*mso)[^>]*>` (check if MSO list margin fix is present elsewhere in template)
DELIV Deliverability
Guards against sending infrastructure failures, authentication gaps, and content patterns that route transactional email to spam or cause outright rejection. Si…
DELIV-001 mortal SPF must be published for the sending domain SPF (RFC 7208) allows receiving MTAs to verify that the sending IP is authorised to send on behalf of your domain. Missing SPF causes messages to fail authentication checks. Google and Yahoo (2024) require valid SPF alignment for all senders. Source: RFC 7208; Google Sender Guidelines 2024.
detect: contextual` — check that `stack.esp` config implies SPF is configured; flag as requiring infrastructure verification
DELIV-002 mortal DKIM must be configured with a minimum 2048-bit RSA key, signing at least the from, to, subject, date, and message-id headers DKIM (RFC 6376) provides cryptographic proof that the message was authorised by the signing domain. RSA-1024 keys are deprecated and rejected by Gmail. The h= header list must include from for DMARC alignment. Google and Yahoo (2024) require passing DKIM alignment. Source: RFC 6376; Google Sender Guidelines 2024.
detect: contextual` — check stack.esp config implies DKIM is configured; flag key size and signed headers as requiring infrastructure verification
DELIV-003 mortal DMARC must be published at minimum p=none with a valid rua= reporting address DMARC (RFC 7489) ties SPF and DKIM together and requires identifier alignment — the authenticated domain must match the RFC5322 From: domain. Google and Yahoo (2024) require DMARC published for bulk senders. p=none is the minimum; progression to p=quarantine then p=reject is required for full protection. Source: RFC 7489; Google Sender Guidelines 2024.
detect: contextual` — infrastructure verification required; flag absence of DMARC intent in project config
DELIV-004 mortal All image URLs must use HTTPS. HTTP image URLs are blocked by default in most modern clients and reduce trust scores HTTP image URLs trigger security warnings in Gmail, iOS Mail, and Outlook. Many corporate security proxies block HTTP content entirely. Serving images over HTTP also reduces the sender's technical hygiene score with spam filters. Source: Campaign Monitor; Litmus Email Design Guide.
detect: regex` — pattern: `(?:src|href)=["']http://
DELIV-005 mortal Total HTML must remain under 102 KB (102,400 bytes) Gmail clips email HTML at exactly 102 KB. Content beyond this limit is hidden behind a "[Message clipped] View entire message" link. Transactional content (order details, CTAs) placed after the clip is effectively invisible to users who don't click through. Source: caniemail.com/features/html-style.
detect: contextual` — estimate compiled HTML size; flag templates approaching or exceeding the limit
DELIV-006 mortal MIME structure must be multipart/alternative with text/plain before text/html RFC 2046 requires text/plain to appear before text/html in multipart/alternative (parts listed in increasing order of preference; the last supported part renders). Inverting this causes plain-text-only clients to display raw HTML source. Missing plain-text parts raise spam scores on Barracuda and Proofpoint filters. Source: RFC 2046 §5.1.4.
detect: contextual` — check email.config.yml for MIME structure configuration or flag for manual verification
DELIV-007 mortal Plain-text version must be a complete, coherent prose rendering — not a stub Stub plain-text bodies ("Please view the HTML version") raise spam scores and fail CAN-SPAM's requirement that required content (physical address, opt-out) be present and readable in plain text. Some corporate mail gateways default to plain text entirely. Source: CAN-SPAM Act (15 U.S.C. §7704); Postmark "Plain-Text Emails".
detect: contextual` — check if email.config.yml or template tooling generates a genuine plain-text version
DELIV-008 mortal Do not use consumer URL shorteners (bit.ly, tinyurl, t.co) in email links Consumer shortener domains accumulate spam reputation and are permanently blocklisted in SURBL and URIBL. The obscured destination is itself a spam signal. If a shortener service has an outage, all links in sent messages break. Use a dedicated tracking subdomain instead (click.example.com/c/[token]). Source: Postmark: URL Shorteners and Deliverability.
detect: regex` — pattern: `href=["']https?://(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly)/
DELIV-009 mortal Do not use ALL-CAPS words in subject lines or excessive capitalisation in body text SpamAssassin's UPPERCASE_25_50 and higher rules fire when 25–75%+ of body words are capitalised. ALL-CAPS subject lines are one of the oldest and most reliable spam signals. Source: Apache SpamAssassin HTML rules.
detect: contextual` — check subject field in email.config.yml and primary body text for excessive capitalisation
DELIV-010 mortal Marketing-style spam trigger words must not appear in transactional email subject lines Phrases like "Act now", "Limited time offer", "Free gift", "You have been selected", and financial urgency language raise composite spam scores. Transactional emails should use purely functional, transactional language. Source: HubSpot "Spam Trigger Words"; Mailchimp "Spam Filters".
detect: contextual` — check subject line in email.config.yml for promotional/urgency language
DELIV-011 venial List-Unsubscribe and List-Unsubscribe-Post headers must be present for subscribed/marketing mail sent at ≥ 5,000 messages/day to Gmail or Yahoo Google and Yahoo (2024) require one-click unsubscribe (RFC 8058) for bulk senders. The List-Unsubscribe-Post: List-Unsubscribe=One-Click header enables Gmail's UI "Unsubscribe" button. The HTTPS endpoint must accept POST requests without redirects, remove the subscriber within 2 days, and not require session state or cookies. Source: RFC 8058; Google Sender Guidelines 2024.
detect: contextual` — check email.config.yml `unsubscribe: true` flag; if marketing email, verify header is configured in ESP settings
DELIV-012 venial Physical mailing address must appear in the email footer CAN-SPAM (US) requires a physical postal address in every commercial email. CASL (Canada) requires sender identification. This applies to transactional emails that contain any promotional content. Purely transactional messages (order confirmation, password reset) are exempt under CAN-SPAM's transactional exception (§7702(17)) but including the address is best practice regardless. Source: CAN-SPAM Act (15 U.S.C. §7704).
detect: contextual` — check if footer section contains a physical address
DELIV-013 venial Do not use raw IP addresses as link destinations Links using raw IP addresses (e.g., http://192.0.2.1/track) are a strong spam signal and are scored by SpamAssassin URI rules. All tracking and redirect links must use proper domain names. Source: SpamAssassin URI rules; Postmark "Why Emails Go to Spam".
detect: regex` — pattern: `href=["']https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}
DELIV-014 venial Images must not have invisible or hidden alt text to evade spam filters Font colour identical to background, font-size: 0, display: none on large text blocks, and overflow: hidden on zero-height containers are all scored by spam filters as deceptive content padding. The preheader pattern (which does use display: none) is a known-good exception, but bulk hidden text is not. Source: SpamAssassin HTML rules.
detect: contextual` — check for large hidden text blocks that are not preheaders
DELIV-015 venial Email must include at least 500 characters of live (non-image) text for messages containing significant images Spam filters penalise high image-to-text ratios. Single-image emails with no text body (other than a footer) are high-risk. There is no universally applicable magic ratio, but ensuring substantial live text content defends against content-based filtering. Source: Campaign Monitor "Image-to-Text Ratio"; Litmus HTML Email Design Guide.
detect: contextual` — estimate live text content vs image content ratio
DELIV-016 venial DMARC should progress from p=none to p=quarantine then p=reject once authentication is stable p=none monitors but takes no enforcement action. p=quarantine routes failing mail to spam. p=reject causes receiving MTAs to discard failing messages at SMTP time. Google's stated roadmap indicates p=none will eventually be insufficient. Progressive tightening is required. Source: RFC 7489; Google Sender Guidelines 2024.
detect: contextual` — advisory; check project documentation for DMARC policy posture
DELIV-017 venial Spam complaint rate must remain below 0.10% for Gmail; below 0.30% triggers delivery rejection Google Postmaster Tools reports spam complaint rates. Rates above 0.08% trigger warnings. Above 0.10% triggers enforcement action. Above 0.30% causes delivery rejection. Complaint rates are driven by unsubscribe friction, unexpected email content, and poor list hygiene. Source: Google Postmaster Tools.
detect: contextual` — operational concern; flag in config review if tracking is not configured
DELIV-018 counsel Hard bounces must be suppressed immediately and permanently Sending to hard-bounced addresses (permanent delivery failures — address does not exist) is a major blocklist trigger. Repeated attempts to non-existent addresses raise the sender's bounce rate, damaging IP reputation. Source: RFC 5321 §4.2; Postmark "Bounce Handling".
detect: contextual` — advisory; flag if email config indicates bounce handling is not configured at ESP level
DELIV-019 counsel Tracking pixels should be hosted on a dedicated subdomain with proper Content-Type headers Apple Mail Privacy Protection (iOS 15+) pre-fetches all remote content through Apple's proxy servers, inflating open rates. Gmail's image proxy serves cached copies. Mixing tracking pixel domains with main website domains conflates web-browsing reputation with mail reputation. Use a dedicated subdomain (track.example.com). Source: Apple Mail Privacy Protection; Litmus "Email Tracking Pixels".
detect: contextual` — check tracking configuration in email.config.yml
DELIV-020 counsel SPF record must not exceed 10 DNS lookups RFC 7208 §4.6.4 specifies that SPF evaluation must not require more than 10 DNS lookups. Exceeding this returns permerror, which many receivers treat as fail. Monitor with MXToolbox or dmarcian. Source: RFC 7208 §4.6.4.
detect: contextual` — advisory; flag for infrastructure review
GOTCHA Gotchas & Edge Cases
Documents the non-obvious traps, client-specific regressions, and silent failure modes that survive rendering and deliverability tests but bite in production. T…
GOTCHA-001 mortal Do not use url() in inline style attributes Gmail desktop webmail strips the entire style attribute from any element that contains a url() function — including all other properties on that element. background-image: url(hero.jpg) causes every inline style on that <td> (padding, colour, font-size) to vanish. Apply background images via <style> block classes only. Source: caniemail.com/features/css-background-image/ (verified 2026-03-17).
detect: regex` — pattern: `style="[^"]*url\(
GOTCHA-002 mortal Total compiled HTML must stay under 80 KB as a safe margin against the 102 KB Gmail clip Gmail clips the HTML body at exactly 102,400 bytes and replaces the remainder with a "View entire message" link. The clip occurs mid-document — transactional CTAs and order details placed after the clip boundary are effectively invisible. Inline CSS is the primary cause of size inflation. Source: Litmus "Gmail Clipping"; caniemail.com.
detect: contextual` — estimate compiled HTML byte count; flag at 80KB warning, error at 102KB
GOTCHA-003 mortal Gmail strips <style> block content above approximately 16 KB This is distinct from the 102 KB HTML clip. Gmail silently strips the <style> tag content when it exceeds ~16 KB, causing catastrophic layout failure without any visible error. Keep <style> blocks lean; inline critical layout properties if the block grows large. Source: caniemail.com/features/html-style/; hteumeuleu/email-bugs.
detect: contextual` — estimate `<style>` block byte count; flag if approaching 16KB
GOTCHA-004 mortal Do not use CSS Color Level 4 whitespace-separated colour syntax Gmail strips entire style rules containing rgb(51 51 51) or rgba(0 0 0 / 0.5) — the comma-free whitespace syntax introduced in CSS Color Level 4. Only the legacy comma-separated syntax (rgb(51, 51, 51), rgba(0, 0, 0, 0.5)) is safe. Source: hteumeuleu/email-bugs #160 (2025).
detect: regex` — pattern: `(?:rgb|rgba)\([^)]*\s[^),]*(?:/[^)]*)?(?:[^,)])\)
GOTCHA-005 venial Always version image URLs — Gmail caches images permanently by URL Gmail proxies images via googleusercontent.com and caches them indefinitely. The cache key is the original URL. Updating an image file at the same URL has no effect on already-delivered emails; Gmail continues serving the original version. Source: Litmus "Gmail Image Caching".
detect: contextual` — advisory; check if image URLs in templates include version markers (query string or versioned filename)
GOTCHA-006 mortal Do not use id-based references in email — Gmail and Outlook.com prefix id attributes Gmail and Outlook.com prefix id attribute values to prevent collisions with the webmail DOM. This silently breaks aria-labelledby, aria-describedby, <label for="...">, and fragment anchors. #section-name links never work in email. Use aria-label instead of aria-labelledby. Source: caniemail.com/features/html-aria-labelledby/; caniemail.com/features/html-aria-describedby/.
detect: regex` — pattern: `(?:aria-(?:labelledby|describedby)|for)=["'][^"']+["']
GOTCHA-007 mortal Do not use display: flex for structural layout in Gmail-targeted emails Gmail desktop webmail supports display: flex only for Google account users. Users accessing Gmail webmail with a non-Google account (a company account hosted elsewhere but using Gmail's interface) do not get flex support. Use table-based layout for all structural elements. Source: caniemail.com/features/css-display-flex/ (verified 2026-03-18).
detect: regex` — pattern: `display\s*:\s*flex
GOTCHA-008 venial Set explicit width attributes on images — Gmail mobile webmail ignores max-width: 100% Gmail's mobile webmail (responsive web view) does not honour max-width: 100% on <img> elements. Images wider than their container overflow. Use max-width: 100% only as progressive enhancement layered on top of an explicit width attribute. Source: hteumeuleu/email-bugs #152.
detect: contextual` — check that images have explicit `width` attribute in addition to `max-width` CSS
GOTCHA-009 mortal Outlook 2007–2019 ignores min-height Outlook Windows renders HTML using the Word engine, which has no concept of min-height. Containers collapse to their content height. Use transparent spacer images or VML to enforce minimum heights in Outlook. Source: Campaign Monitor CSS guide; Litmus Outlook notes.
detect: regex` — pattern: `min-height\s*:
GOTCHA-010 venial Set mso-line-height-rule: exactly when precise line heights are required in Outlook 2007–2019 Outlook Windows uses "at least" semantics for line-height by default — it adds extra spacing above the specified value. Without mso-line-height-rule: exactly, Outlook inflates line heights inconsistently. line-height set on <td> is not inherited by child text elements in Outlook. Source: Litmus "Outlook Line Height Bug".
detect: contextual` — check if `line-height` declarations include `mso-line-height-rule: exactly` on text-containing elements
GOTCHA-011 mortal Ghost table column dividers must have no whitespace between the closing </div> and the MSO conditional comment Inline-block elements have a 4px whitespace gap between them in all modern clients when there is any whitespace (newline, space, indent) between the elements. In ghost table multi-column layouts, the whitespace between column divs creates this gap in Gmail and Apple Mail. Source: Campaign Monitor "Responsive Email"; Litmus Boilerplate.
detect: contextual` — check multi-column layouts for whitespace between inline-block div columns and MSO conditional comments
GOTCHA-012 mortal Do not use float in Outlook 2007–2019 content — it crops text In Outlook Windows, placing a table with float inside a <td> with a background colour causes text content following the floated table to be cropped and not displayed. Use MSO conditional table columns for all multi-column layouts. Source: hteumeuleu/email-bugs #158.
detect: regex` — pattern: `float\s*:\s*(?:left|right)
GOTCHA-013 venial Account for auto-linking of phone numbers, dates, and addresses in Outlook and Apple Mail Outlook Windows and Apple Mail both automatically detect phone numbers, postal addresses, and dates and convert them to interactive links, overriding your colour and text-decoration styles. Use CSS resets (a[x-apple-data-detectors] for Apple, inline span overrides for Outlook) to control the visual treatment. Source: Email on Acid "Outlook Auto-Link Bug"; Litmus "Apple Data Detectors".
detect: contextual` — check if templates containing phone numbers/addresses/dates include data-detector style resets
GOTCHA-014 venial New Outlook for Windows (Edge renderer) applies forced dark mode colour inversion The new Outlook for Windows uses Edge/Chromium rendering. In dark mode, it inverts colours without honouring prefers-color-scheme CSS media queries. You cannot control dark mode appearance in the new Outlook via CSS. Using off-white (#fffffe) reduces some unwanted inversion but is not reliable. Source: hteumeuleu/email-bugs #146; Litmus "New Outlook for Windows".
detect: contextual` — note in audit report; no reliable workaround; document in project
GOTCHA-015 venial New Outlook for Windows background images may not render on initial load until zoom change Background images in the new Outlook for Windows sometimes fail to render until the user manually changes the zoom level — a known regression. Source: hteumeuleu/email-bugs #146.
detect: contextual` — advisory; document as known limitation when background images are used
GOTCHA-016 counsel Apple Mail Privacy Protection makes open-rate tracking unreliable — shift to click rates iOS 15+ Apple Mail pre-fetches all email content through Apple's proxy when email is downloaded, firing tracking pixels regardless of whether the user reads the email. Apple Mail represents 40–60% of email opens for consumer lists. Open rates are inflated and unreliable. There is no workaround. Source: Apple MPP (2021); Litmus "State of Email Privacy 2022".
detect: contextual` — advisory; verify in email.config.yml that tracking relies on click rates, not open rates
GOTCHA-017 venial Include a[x-apple-data-detectors] CSS reset to prevent Apple Mail from styling auto-detected data Apple Mail auto-wraps dates, phone numbers, and addresses in styled <a> tags that override your link colours and text-decoration. The a[x-apple-data-detectors] selector resets these back to your intended styles. Source: Litmus "Apple Data Detectors".
detect: contextual` — check if `<style>` block includes `a[x-apple-data-detectors]` reset when template contains contact information
GOTCHA-018 venial Apple Intelligence (iOS 18+) generates AI email summaries that may override preheader text Apple Intelligence generates AI-powered summaries from email body content and may display these in place of preheader text in the inbox notification. There is no metadata or header that suppresses this. Write the first full body sentence as the most important fact (e.g. "Your order #12345 has shipped") — this is what Apple Intelligence surfaces. Source: Apple iOS 18 release notes; Litmus coverage of Apple Intelligence (2024).
detect: contextual` — verify that email body opens with the most critical fact as the first sentence after the preheader
GOTCHA-019 venial Yahoo Mail rewrites @media (prefers-color-scheme: dark) — dark mode CSS is dead in Yahoo Yahoo Mail rewrites @media (prefers-color-scheme: dark) as @media (_filtered_a), which never matches. Dark mode CSS targeted via this media query does nothing in Yahoo Mail. Use [data-ogsb] attribute selectors for partial Yahoo dark mode coverage. Source: hteumeuleu.com/2021/understanding-email-dark-mode; Email on Acid.
detect: regex` — pattern: `@media[^{]*prefers-color-scheme
GOTCHA-020 mortal Do not use CSS comments in <style> blocks — Yahoo Mail desktop silently drops the rule after a comment In Yahoo Mail desktop webmail, a CSS rule immediately following a CSS comment in a <style> block is silently dropped. Commenting out one rule inadvertently removes the next rule too. Source: caniemail.com/features/html-style/ (partial support caveats, last tested July 2023).
detect: regex` — pattern: `/\*[^*]*\*+(?:[^/*][^*]*\*+)*/\s*\n\s*[a-zA-Z#.\[{]` (CSS comment followed by rule)
GOTCHA-021 venial Do not apply CSS classes directly to <img> elements — Yahoo/AOL strips them Yahoo Mail and AOL strip class attributes from <img> tags. CSS rules targeting img.my-class silently fail. Apply classes to a wrapper <td> or <div> instead. Source: hteumeuleu/email-bugs #157.
detect: regex` — pattern: `<img[^>]+\bclass=
GOTCHA-022 venial Yahoo Mail on Android removes the first <head> element — include a second <head> with styles Yahoo Mail on Android strips the first <head> element from email HTML, including any <style> blocks within it. The established workaround is a second <head> element containing the <style> block — non-standard HTML that Yahoo Android honours. Source: caniemail.com/features/html-style/.
detect: contextual` — check if template includes a duplicate `<head>` for Yahoo Android compatibility
GOTCHA-023 mortal Never use SVG in email — Gmail and Outlook 2007–2019 do not support it SVG images (<img src="image.svg"> and inline <svg>) fail silently in Gmail (all platforms) and Outlook 2007–2019 — which together represent the majority of email volume for most senders. Use PNG or GIF exports. Serve 2× PNG for retina displays. Source: caniemail.com (SVG feature data).
detect: regex` — pattern: `(?:src|href)=["'][^"']*\.svg["']|<svg[\s>]
GOTCHA-024 mortal Do not use CSS Custom Properties (var()) in email — Gmail and Outlook do not support them CSS Custom Properties (--colour: #333; color: var(--colour);) are unsupported in Outlook 2007–2019, all Gmail platforms, and Yahoo Mail. Styles using var() silently fall through to no value. Pre-process variables at build time using a CSS preprocessor or build step — email templates must always receive computed flat values. Source: caniemail.com (CSS custom properties feature data).
detect: regex` — pattern: `var\(--[^)]+\)
GOTCHA-025 mortal All src and href values must be absolute HTTPS URLs — relative URLs and <base> tags do not work Email clients strip or ignore <base href="..."> tags. <img src="/images/logo.png"> fails to load. <a href="/login"> points nowhere or to the client's own domain. Protocol-relative URLs (//example.com/image.jpg) behave unpredictably. Every asset reference must be an absolute https:// URL. Source: caniemail.com (no <base> support); Litmus "Email Image Best Practices".
detect: regex` — pattern: `(?:src|href)=["'](?!https?://|mailto:|tel:|#)[^"']+["']
GOTCHA-026 venial Web fonts must have a complete, well-ordered fallback stack — @font-face fails silently in Gmail and Outlook @font-face is unsupported in Gmail (all platforms), Outlook 2007–2019, and Yahoo Mail. When the custom font fails, the browser uses the next entry in font-family. A minimal fallback (sans-serif) resolves inconsistently across platforms — sans-serif maps to Times New Roman on some Windows configurations. Always provide explicit named fallbacks: 'Your Font', -apple-system, 'Segoe UI', Arial, Helvetica, sans-serif. Source: caniemail.com/features/css-at-font-face/; Litmus "Web Fonts in Email".
detect: contextual` — check `font-family` declarations that include a custom font for complete fallback stacks
GOTCHA-027 venial Style blocks are stripped when email is forwarded — inline critical styles When a recipient forwards an email via Gmail or Outlook.com webmail, the <head> section including <style> blocks is stripped. Only inline styles survive. Transactional emails that are commonly forwarded (receipts, tickets, order confirmations) must have critical layout styles inlined. Source: Litmus "Email Forwarding"; Campaign Monitor.
detect: contextual` — check if layout-critical styles are inlined for transactional email types likely to be forwarded
GOTCHA-028 venial Use the correct multi-property preheader hiding technique — display: none alone is unreliable display: none does not reliably hide preheader text in all Outlook configurations (some preview panes show it) and some accessibility tools announce it. The correct technique combines display: none, visibility: hidden, opacity: 0, max-height: 0, overflow: hidden, and mso-hide: all. Source: Litmus "Preheader Text"; Email on Acid.
detect: regex` — pattern: `display\s*:\s*none(?![^}]*mso-hide)` (display:none without mso-hide companion)
GOTCHA-029 venial Serve images at 2× resolution with width/height attributes set to display dimensions High-density (Retina/HiDPI) displays render images blurry if served at 1× resolution. Serve at 2× file resolution but set HTML width/height attributes to the intended display size. If width is set to the file width (1200) instead of the display width (600), the image overflows its container in all clients. Outlook 2007–2019 ignores max-width: 100% — the width attribute is the only size control in Outlook. Source: Campaign Monitor "Retina Images in Email"; email_rendering_compatibility.md.
detect: contextual` — check if high-resolution image assets have correct `width`/`height` attributes matching display dimensions
GOTCHA-030 counsel Test both Gmail webmail and Gmail app — they are distinct rendering environments Gmail desktop webmail (browser) and Gmail iOS/Android apps have different CSS support profiles. The app uses a native WebView renderer with differences in @media query handling, @supports, and dark mode behaviour. Screenshot testing in Litmus/Email on Acid captures webmail rendering, not app rendering. Source: Litmus "Gmail App vs Webmail"; Email on Acid testing guides.
detect: contextual` — advisory; verify QA matrix includes both Gmail webmail and Gmail iOS/Android app as separate test targets
UX Content & UX
Guards against content, copy, and structural UX patterns that undermine the effectiveness, trustworthiness, or legibility of transactional and marketing emails.…
UX-001 mortal Subject line must be 40–50 characters for mobile-safe display Mobile clients truncate subject lines at 33–41 characters depending on screen width and OS font scaling. The 40–50 character range is the safest cross-client target. Subjects over 60 characters are truncated on all mobile clients. Source: Campaign Monitor Benchmark Data 2023.
detect: contextual` — check `subject:` in email.config.yml; flag if over 60 characters; warn if over 50
UX-002 mortal Preheader text must be explicitly set and under 90 characters If no preheader is configured, mail clients pull the first visible body text — often a navigation link or "View in browser" notice. Gmail adjusts preheader width inversely to subject length; target 85–100 combined characters. Source: Litmus "Ultimate Guide to Email Preheader Text" 2022.
detect: contextual` — check `preheader:` field in email.config.yml; flag if absent or over 90 chars
UX-003 venial Preheader must extend or complement the subject line — never repeat it A preheader that restates the subject wastes the second-highest-value inbox real estate. "Your order is confirmed. Your order is confirmed." Source: Litmus 2022.
detect: contextual` — check if preheader and subject text are identical or near-identical
UX-004 mortal All template variables must have fallback values. A naked {{ first_name }} or {{firstName}} with no fallback sends "Hi , your order..." to thousands of recipients when data is incomplete Merge tag failures are silent — the variable renders as an empty string without errors. Always define fallbacks: {{ first_name | default: "Valued Customer" }} (Liquid), {{#if firstName}}{{firstName}}{{else}}Valued Customer{{/if}} (Handlebars). Source: Mailchimp Email Marketing Benchmarks 2023.
detect: regex` — pattern: `\{\{[\s]*[a-zA-Z_][a-zA-Z0-9_.]*[\s]*\}\}(?!\s*\|)` (output without filter/fallback — check per-engine syntax)
UX-005 mortal CTA button copy must not use generic phrases: "Click here", "Read more", "Learn more", "Submit", "Go now", "Find out more" Generic CTA copy is accessibility-hostile (screen readers announce it without context) and performs poorly vs. descriptive alternatives. "Click here" announces as meaningless in link-list navigation. Source: WebAIM "Links and Hypertext" 2023; Campaign Monitor CTA Guide 2022.
detect: regex` — pattern: `(?i)>(?:\s*)(click here|read more|learn more|submit|go now|find out more|click to|continue)(?:\s*)<
UX-006 venial CTA copy must be verb-first and 2–5 words "Download the report" beats "Report download" — the first word sets intent. Single words ("Submit", "Go") are too vague. First-person copy ("Get my guide") outperforms second-person ("Get your guide") by 7–14% CTR in published A/B tests. Source: Unbounce Conversion Benchmark Report 2022.
detect: contextual` — review CTA button text for verb-first pattern
UX-007 venial Transactional emails should have a single primary CTA Every additional CTA reduces the probability of any CTA being clicked — Hick's Law. When multiple CTAs are unavoidable (digest, weekly summary), use visual hierarchy: one button, remaining as smaller text links. Source: Campaign Monitor "Best Email CTA Strategies" 2022.
detect: contextual` — count distinct CTA button elements; flag if more than one has equal visual weight
UX-008 mortal Use bulletproof HTML/VML buttons — not image-based buttons Image buttons disappear when images are blocked, and approximately 43% of recipients have images blocked by default in some clients (Litmus "Email Client Market Share" 2023). The CTA is the most important element — it must render without images. Use <a> with inline styles + VML Outlook fallback (see RENDER-014).
detect: contextual` — check if CTAs use `<a>` or `<img>` as the interactive element
UX-009 venial At least one CTA must be visible above the fold without scrolling On mobile, the visible area is approximately the first 500–600px of rendered height. Recipients who do not scroll never see below-fold CTAs. Source: Litmus "Email Design Reference" 2023.
detect: contextual` — check that first CTA appears in the first third of template structure
UX-010 venial Transactional emails must have one purpose. Order confirmations confirm orders. Shipping notifications confirm shipping. Do not combine with upsell content Multi-purpose transactional emails increase cognitive load at a moment when the user's primary need is task confirmation. Mixing purposes also risks CAN-SPAM reclassification. Source: Litmus "Transactional Email Best Practices" 2023.
detect: contextual` — advisory; check template type in email.config.yml against content sections
UX-011 venial Lead with the key fact — inverted pyramid structure. State the main point in the first sentence Eye-tracking research (Nielsen Norman Group F-Pattern 2017) confirms that readers scan from the top. The first full sentence after the preheader/header is what Apple Intelligence and Gmail snippets will surface as the summary. Source: Nielsen Norman Group.
detect: contextual` — advisory; verify first body sentence contains the key transactional fact
UX-012 counsel Body copy paragraphs must be 3–4 sentences maximum Email is a low-attention medium. Dense paragraphs are read less thoroughly than identical content broken into smaller chunks. Source: Nielsen Norman Group "How Little Do Users Read?" 2020.
detect: contextual` — advisory
UX-013 venial Do not inject marketing urgency language into transactional emails at anxious moments "Your password was reset. WHILE YOU'RE HERE — CHECK OUT OUR SALE!" treats a security moment as a sales opportunity, erodes trust, and causes recipients to doubt the email's legitimacy. Urgency in transactional email must be factual: "This link expires in 60 minutes." Source: Litmus "Transactional Email Best Practices" 2023.
detect: contextual` — check template type against presence of promotional language patterns
UX-014 mortal Critical transactional content — order details, amounts, tracking numbers — must be live text, not embedded in images Images are blocked by default in many corporate email clients. If the order summary is an image, corporate users see a blank white area where their order details should be. Live text is searchable, accessible, and renders without images. Source: Litmus "Images Off in Outlook".
detect: contextual` — check that transactional data fields are in text nodes, not only in image assets
UX-015 venial Password reset emails must state the link expiry time explicitly "This link expires in 60 minutes" is security information, not urgency marketing. Users who do not act on the email need to know whether to request a new one. Source: OWASP Forgot Password Cheat Sheet 2023.
detect: contextual` — check password-reset template type for expiry time copy
UX-016 mortal Unsubscribe link must be clearly visible in the footer — not hidden, minimised, or rendered in low-contrast text Deliberately obscuring the unsubscribe mechanism is a dark pattern flagged by Gmail's spam classifier. It also violates GDPR/ICO requirements for easy unsubscribe access. Source: Google Postmaster Tools documentation 2023; ICO Direct Marketing Guidance 2020.
detect: contextual` — check footer for unsubscribe link presence and verify it is not rendered in text smaller than 10px or contrast below 3:1
UX-017 venial Transactional emails must include trust signals: company trading name, a reference number, and a support contact method These allow recipients to verify the email is legitimate. An order confirmation without a company name and order number looks like phishing. Source: Litmus "Transactional Email Best Practices" 2023.
detect: contextual` — check footer/header for company name and support contact; check template variables include order/reference number
UX-018 mortal Never include full card numbers, bank account numbers, or passwords in email body content Full card numbers are PCI DSS prohibited in email. Passwords must never be sent in plaintext. Show only partial identifiers: last 4 digits of card, masked account numbers. Source: PCI DSS v4.0; OWASP.
detect: regex` — pattern: `\b[3-9]\d{13,15}\b` (16-digit sequences suggesting unmasked card numbers)
UX-019 counsel Emoji in subject lines must not be the sole carrier of meaning, and must not exceed one per subject line Enterprise mail gateways strip non-ASCII characters. "🚀 Your shipment" becomes " Your shipment" after stripping. Multiple emoji read as spam to both algorithms and humans. Source: Campaign Monitor 2022.
detect: contextual` — check subject line in email.config.yml for emoji usage
UX-020 counsel Left-align body text. Reserve centred alignment for headings and short single-line CTAs only F-pattern scanning (Nielsen Norman Group 2017) relies on a clean left edge. Centred body copy breaks the scan pattern and reduces comprehension for anything over a single line.
detect: contextual` — check `text-align` on primary body copy containers
UX-021 venial Preheader text must not duplicate the primary <h1> heading of the email The preheader is inbox-preview real estate — read before opening. The <h1> is the first thing seen after opening. If both are identical the recipient sees the same words twice: once in the inbox list and once at the top of the opened email. This wastes the preheader slot entirely. Compare UX-003, which covers preheader vs subject-line duplication — this rule covers preheader vs in-email heading duplication, which is an equally common failure pattern.
detect: contextual` — compare preheader text against the first `<h1>` heading content; flag if they match or differ only in punctuation
Tooling — loaded by stack config
TOOL Tooling Overview
Selection guidance and cross-tool rules for email templating pipelines. This doctrine is the tooling overview — it covers when to use each engine, pipeline arch…
TOOL-001 mortal Declare the templating stack in .email-absolution/config.yml as stack.templating Without this declaration, skills cannot load the correct per-language doctrine. The scribe skill will refuse to generate code until a templating language is declared. Source: email-absolution plugin config schema.
detect: contextual` — check `.email-absolution/config.yml` for `stack.templating` field
TOOL-002 mortal Do not mix templating syntax from two different engines in one template file Handlebars {{#each}} inside a Liquid template causes partial renders without errors. The Liquid engine passes {{#each}} through as literal text; recipients see raw template syntax. The same applies to Mustache {{#section}} inside Handlebars, or Jinja2 {% for %} inside a Nunjucks file that uses a non-compatible Jinja2 filter. Source: engineering incident patterns.
detect: contextual` — check template files for mixed syntax markers from multiple engines
TOOL-003 mortal Compiled output (MJML dist/, Maizzle build/) must never be hand-edited The next compile overwrites any manual changes. Edits to compiled output create invisible drift between source and deployed HTML — the next CI build deploys source-derived output that silently discards the hand-edit.
detect: contextual` — check if dist/build files are committed with modifications not reflected in source
TOOL-004 mortal Pin email toolchain versions exactly in package.json or requirements.txt MJML patch releases have changed spacing and table structure. React Email patch releases have changed component HTML output. Maizzle v4 → v5 changed the build engine. Tailwind v3 → v4 is a breaking change. Floating semver (^, ~) causes undetected visual regressions on npm install or pip install.
detect: contextual` — check package.json / requirements.txt for caret/tilde ranges on email toolchain packages
TOOL-005 mortal MJML v5 (beta as of March 2025) must not be used in production without thorough visual regression testing Breaking changes in MJML v5: file includes disabled by default (security), minification backend replaced (htmlnano/cssnano replaces html-minifier/js-beautify), mj-body HTML structure changed, Node.js 16/18 dropped (requires 20, 22, or 24). Source: MJML v5.0.0-beta.1 changelog, March 2025.
detect: contextual` — check package.json for MJML version; flag beta/v5 references
TOOL-006 mortal Test the full rendering pipeline end-to-end, not only the template output Screenshot tools (Litmus, Email on Acid) do not test: dynamic rendering failures, broken ESP API calls, missing MIME parts, or truncation at the 102 KB Gmail clip. A screenshot test can pass while a production send fails silently. Source: Email on Acid "Limitations of Screenshot Testing".
detect: contextual` — advisory; verify test suite includes a real send-and-receive test
TOOL-007 venial Use the build-step compilation pattern for MJML and Maizzle: compile at CI time, inject data at runtime Compiling MJML at send time (per-request) introduces compile latency into the send path. Pre-compiled HTML is a static asset; data injection at send time is fast. Source: MJML Documentation "Use with Node.js".
detect: contextual` — advisory; check if MJML compilation happens at build/CI or at send time
TOOL-008 venial ESP-native templates (SendGrid Dynamic Templates, Postmark Templates, Mailchimp) create vendor lock-in. Document this trade-off explicitly Template source lives inside the ESP. Migrating ESPs requires rewriting all templates. Logic capabilities are limited to what the ESP exposes (SendGrid's Handlebars subset lacks custom helpers; Postmark is Mustache with no block helpers). Source: SendGrid and Postmark documentation.
detect: contextual` — if stack.esp is "sendgrid" or "postmark", flag that templates live in the ESP
TOOL-009 venial Maintain a plain-text version for every email template React Email, MJML, and Maizzle do not generate plain text automatically. Author plain text manually or use a library (html-to-text for Node.js, premailer for Python/Ruby). Plain-text parts are required for DELIV-007 compliance and are read by spam filters. Source: SpamAssassin rule documentation.
detect: contextual` — check if project has a plain-text generation strategy
TOOL-010 venial In the MJML + ESP hybrid pattern, verify that ESP placeholder syntax is preserved verbatim through MJML compilation MJML does not interpret non-MJML content. {{first_name}} (Handlebars) or {{ first_name }} (Liquid) inside <mj-text> compiles through to the output HTML unchanged. However, some preprocessors or minifiers may mangle double-brace syntax. Verify with a compilation smoke test. Source: MJML Documentation.
detect: contextual` — run compilation smoke test and grep for placeholder syntax in dist output
TOOL-011 counsel Use MJML when the team needs cross-client responsive layout without hand-writing table/VML markup and the template set is moderate in size MJML compiles .mjml XML to cross-client HTML with inlined CSS, MSO conditionals, and VML buttons automatically. Best for teams with a JavaScript build pipeline and 5–50 templates. Not suited to teams that need full control of generated HTML.
detect: contextual` — selection guidance
TOOL-012 counsel Use Handlebars when the team is in a Node.js/JavaScript stack and needs a familiar, logic-minimal templating language with custom helper support Handlebars is widely understood, has an excellent helper ecosystem, and integrates natively with SendGrid Dynamic Templates (Handlebars subset). Best for JavaScript-first teams. Note: SendGrid's Handlebars subset omits @index/@first/@last loop metadata and custom helpers — see HBS-003.
detect: contextual` — selection guidance
TOOL-013 counsel Use Liquid when sending via Klaviyo or another Liquid-native ESP, or when the team prioritises sandboxed rendering safety Liquid has no filesystem access and cannot execute arbitrary code — safe to evaluate user-influenced templates. It is the native language of Klaviyo's template engine. Ruby and JavaScript ports are production-grade. Source: Shopify Liquid open-source documentation.
detect: contextual` — selection guidance
TOOL-014 counsel Use React Email when the team is TypeScript-first and wants compile-time type checking of email data shapes React Email's primary advantage over text templating is typed component props. A data model change that renames order.id to order.orderId fails the TypeScript build rather than silently sending broken emails. Suited to Node.js stacks where application models can be shared with email component interfaces. Source: React Email Documentation.
detect: contextual` — selection guidance
TOOL-015 counsel Use Maizzle when the team is fluent in Tailwind CSS and prefers writing plain HTML rather than learning MJML's component model Maizzle applies Tailwind's utility workflow to email, compiling and inlining CSS at build time. It does not abstract table-based layout — developers write tables directly or use Maizzle starter templates. Best for Tailwind-fluent teams who want that workflow without MJML's DSL. Source: Maizzle Framework documentation.
detect: contextual` — selection guidance
MJML MJML
Rules and gotchas for engineers building HTML email templates with MJML. MJML v4.18.0 is the current stable release (March 2024). MJML v5.0.0-beta.1 was release…
.mjml XML source to cross-client HTML with inlined CSS, nested tables, and MSO conditional comments for Outlook. It does not handle dynamic data — a separate templating layer is always required.MJML-001 mortal Use <mj-preview> for the preheader text — do not manually code the hidden preheader div <mj-preview>Your order has shipped.</mj-preview> generates the correct multi-property hidden div: display:none; max-height:0; overflow:hidden; mso-hide:all. Hand-coded preheaders routinely omit mso-hide:all or use display:none alone, which is insufficient (see GOTCHA-028). Source: MJML Documentation.
detect: regex` — (1) absence check: flag if file contains `<mjml` but not `<mj-preview`; (2) contextual — flag if a hand-coded hidden div is present in source instead of `<mj-preview>
MJML-002 mortal Set global defaults in <mj-attributes> — do not repeat attributes on every component. Be aware that <mj-attributes> defaults and per-component overrides stack, not replace Repeating font-family="Arial, sans-serif" color="#333333" on every <mj-text> inflates source and causes drift when values change. Use <mj-attributes> in <mj-head> to set component-level defaults. Source: MJML Documentation — mj-attributes. Double-padding trap: when both a global default and a per-component override set padding, MJML applies both in the compiled output — they stack rather than one replacing the other. Example: <mj-section padding="0" /> in <mj-attributes> plus padding="20px" on a specific <mj-section> produces padding: 20px (override wins for the padding shorthand), but if <mj-column padding="16px" /> is set globally and a column also sets padding-top="32px", the column receives padding-top: 32px from the override and padding-right/bottom/left: 16px from the global — the per-side values compound from both layers. This is not a bug; it is the MJML attribute merge model. Verify the compiled <td> padding values in the HTML output when mixing global and per-component padding, particularly on <mj-section> and <mj-column>.
detect: contextual` — flag if the same attribute value appears on more than 3 of the same component type without a corresponding `<mj-attributes>` default; also flag if both `<mj-attributes>` and individual components set padding properties, and note the stacking behaviour
MJML-003 mortal All src and href values must be absolute HTTPS URLs MJML does not resolve relative URLs. <mj-image src="/logo.png"> compiles to <img src="/logo.png"> — which fails in every email client (see GOTCHA-025). Source: caniemail.com (no <base> support).
detect: regex` — pattern: `(?:src|href)=["'](?!https?://)[^"']+["']` in MJML source files
MJML-004 mortal Never hand-edit the compiled HTML output in dist/. All source changes must be made in .mjml files MJML compilation overwrites the output file entirely. Manual edits to compiled HTML create invisible drift between source and deployed template — the next CI build reverts them silently.
detect: contextual` — advisory; check if dist/ files are tracked separately from source in git
MJML-005 mortal Pin the MJML version exactly in package.json Use "mjml": "4.18.0" not "^4.18.0". MJML patch releases have changed spacing, table attributes, and conditional comment syntax. Floating semver causes undetected visual regressions on npm install.
detect: regex` — in `package.json`: pattern `"mjml"\s*:\s*"[\^~]
MJML-006 mortal Do not use MJML v5 in production. It is beta as of March 2025 MJML v5 breaking changes: file includes (<mj-include>) disabled by default as a security measure; minification backend replaced (htmlnano/cssnano); mj-body HTML structure changed; Node.js 16/18 dropped (requires 20, 22, or 24); migration helper tool removed. Source: MJML v5.0.0-beta.1 release notes.
detect: regex` — in `package.json`: pattern `"mjml"\s*:\s*"[^"]*(?:5\.\d|beta|alpha|rc\d|canary)
MJML-007 venial Declare <mj-breakpoint> explicitly in every template's <mj-head> The default breakpoint is 480px. Not declaring it makes the responsive behaviour implicit and surprising when MJML versions change. Explicit declaration makes the intent clear in code review.
detect: regex` — absence check: flag if file contains `<mjml` but not `<mj-breakpoint
MJML-008 venial Use <mj-font> in <mj-head> to load web fonts <mj-font name="Lato" href="https://fonts.googleapis.com/css?family=Lato:400,700"> generates a <link> in the compiled <head> and makes the font name available in font-family attributes. Do not write <link> tags inside <mj-raw>. Web fonts fail silently in Gmail and Outlook — always include a complete fallback stack in the font-family attribute (see HTML-005).
detect: contextual` — check if web fonts use `<mj-font>` vs `<mj-raw><link>` approach
MJML-009 venial Use <mj-section> and <mj-column> for all multi-column layouts — not <mj-raw> with hand-coded ghost tables MJML generates the correct Outlook ghost table (<!--[if mso]><table><tr><td>...<![endif]-->) from section/column structure automatically. Ghost tables hand-coded in <mj-raw> defeat MJML's cross-client guarantees and require manual maintenance of the whitespace-gap pattern (see GOTCHA-011). Source: MJML Documentation.
detect: contextual` — check for `<mj-raw>` blocks containing MSO table structures that duplicate section/column layout
MJML-010 venial <mj-raw> is an escape hatch for content MJML cannot express — MSO-specific meta tags, tracking pixels, <!--[if IE]> conditionals. Do not use it for layout or dynamic content injection Every <mj-raw> block is emitted verbatim into the compiled output without table wrapping or sanitisation. Using it for layout bypasses MJML's cross-client rendering model and produces untested HTML. Additionally, <mj-raw> blocks that contain template variable placeholders ({{variable}}, {{ variable }}, {%= var %}) are an injection risk when those variables contain user-generated content — MJML does not sanitise <mj-raw> content at compile time. Dynamic content inside <mj-raw> is effectively equivalent to triple-stache in Handlebars (see HBS-002).
detect: contextual` — flag `<mj-raw>` blocks containing `<table>` or layout-critical HTML; also flag `<mj-raw>` blocks containing template variable placeholders that may carry user-controlled content
MJML-011 venial Dynamic data placeholders survive MJML compilation verbatim MJML does not parse content inside its components. {{first_name}} (Handlebars), {{ first_name }} (Liquid), or {%= first_name %} inside <mj-text> compile through unchanged. This enables the hybrid pattern: write layout in MJML, leave placeholders in place, compile, then inject data at runtime. Source: MJML Documentation.
detect: contextual` — advisory; verify placeholders survive compilation in CI smoke test
MJML-012 venial Declare <mj-title> in <mj-head> for accessibility <mj-title>Order #12345 Confirmed — Acme</mj-title> generates the HTML <title> element. Screen readers announce this on open. A missing or generic title fails ACCESS-008. Source: WCAG 2.1 SC 2.4.2.
detect: regex` — absence check: flag if file contains `<mjml` but not `<mj-title
MJML-013 venial Use <mj-button> for CTA buttons, but understand that it does NOT generate VML rounded corners for Outlook <mj-button> compiles to a <table> + <td> structure with mso-padding-alt and bgcolor applied to the <td>, giving Outlook a solid rectangular button. It does NOT emit <v:roundrect> VML — Outlook 2007–2019 renders a flat rectangle regardless of the border-radius attribute (which is CSS-only and ignored by the Word rendering engine). For a true bulletproof VML button with rounded corners in Outlook, the <v:roundrect> pattern must be hand-coded in <mj-raw>. <mj-button> is still the correct default for rectangular CTA buttons; use <mj-raw> only when rounded corners in Outlook are a hard design requirement. Source: MJML Documentation; Stig Morten Myre "Bulletproof Buttons" (Campaign Monitor).
detect: contextual` — check if CTA buttons use `<mj-button>`; if `border-radius` is set and Outlook rounded corners are claimed, flag that the compiled output will be rectangular in Outlook 2007–2019
MJML-014 venial Set the correct display width on <mj-image> — not the file resolution width <mj-image width="600px"> compiles to <img width="600">. If the source image is 1200px wide (2× retina), the compiled width must be 600 (display size), not 1200 (file size). Setting width="1200px" causes the image to overflow its container in all clients (see GOTCHA-029). Source: Campaign Monitor "Retina Images in Email".
detect: contextual` — check that `<mj-image>` width attributes match the intended display dimensions
MJML-015 venial Add fluid-on-mobile="true" to <mj-section> for multi-column sections that should stack to single-column on mobile This generates the @media query that collapses multi-column sections on narrow viewports. Without it, two-column layouts stay two-column at mobile widths. Source: MJML Documentation — mj-section.
detect: contextual` — check if multi-column sections include `fluid-on-mobile="true"
MJML-016 counsel <mj-accordion> and <mj-carousel> fall back to always-visible static content in unsupported clients (Outlook 2007–2019, Gmail, Yahoo Mail). Use them only where the open/visible fallback is acceptable The fallback is not a broken component — it is the same content displayed statically. Acceptable for "show more details" patterns; not acceptable if the collapsed state is required for readability. Source: caniemail.com interactive email features.
detect: contextual` — advisory; verify fallback rendering is tested when interactive MJML components are used
MJML-017 counsel Test the compiled HTML in real email clients — not just the MJML online editor or the local MJML preview The MJML online editor renders in a modern browser. The compiled HTML is processed by email clients with their own rendering engines. Issues in Outlook's Word engine and Gmail's CSS parser are invisible in the online editor.
detect: contextual` — advisory; verify QA process includes testing compiled HTML in target clients
MJML-018 counsel In MJML v5, explicitly enable file includes in mjml.config.js if the project uses <mj-include> MJML v5 disables <mj-include> by default (security: prevents reading arbitrary files in server-side processing contexts). Build pipelines that use <mj-include> for shared headers/footers must enable includes explicitly. Source: MJML v5 security changelog.
detect: contextual` — if project uses `<mj-include>`, check that v5 config enables it
MJML-019 venial Do not set a web font as the primary value in <mj-all font-family> <mj-all font-family="'BrandFont', Arial, sans-serif"> inlines the full font-family stack into every compiled element — <table>, <td>, <a>, and text nodes — potentially hundreds of inline repetitions per template. This inflates compiled HTML toward Gmail's 102 KB clip limit (see DELIV-005). Additionally, clients that strip <link> tags (Gmail) will never load the web font declared in <mj-font>, silently falling back without warning. Set <mj-all font-family> to the web-safe stack only: font-family="Arial, 'Helvetica Neue', Helvetica, sans-serif". Apply the web font stack individually to <mj-text>, <mj-button>, and other specific components where it is needed.
detect: contextual` — flag if `<mj-all font-family>` value contains a non-system (web) font as its primary (first) entry
MJML-020 venial css-class attribute styles compile to a <style> block in <head>, not to inline styles — Gmail strips them MJML's css-class="my-class" attribute writes the corresponding CSS rules into a <style> block in the compiled HTML <head>. Gmail (and other clients that strip <head> <style> blocks) silently discard all styles applied via css-class. This means any visual treatment — font sizes, colours, spacing, display rules — that is applied only through css-class will be invisible in Gmail. Styles that must survive Gmail must be set as inline attributes directly on the MJML component (font-size, color, padding, etc.) or via <mj-attributes> defaults (which MJML inlines at compile time). Use css-class only for styles that are intentionally Gmail-optional (e.g. dark mode media query overrides, hover effects, print styles).
detect: contextual` — flag if `css-class` is used to apply styles that appear to be load-bearing (layout, typography, colour) rather than progressive-enhancement-only
MJML-021 venial background-size on <mj-section background-url> is ignored in Outlook 2007–2019 <mj-section background-url="..." background-size="cover"> compiles to two parallel rendering paths: (1) a <!--[if mso]> VML block using <v:rect> + <v:fill type="frame"> for Outlook, and (2) a CSS background-image + background-size inline style for all other clients. The VML <v:fill> element does not accept a size attribute equivalent to CSS background-size: cover — the VML path always stretches to fill the container without respecting the CSS sizing instruction. In Outlook 2007–2019 the background image is displayed but background-size: cover or contain has no effect. Design backgrounds with this constraint in mind: use images cropped to the correct aspect ratio, or accept that Outlook will not clip/fit the image to cover.
detect: regex` — pattern: `<mj-section[^>]*background-url[^>]*background-size|<mj-section[^>]*background-size[^>]*background-url` (both attributes on same element = flag)
HBS Handlebars
Rules and gotchas for engineers building transactional email templates with Handlebars.js, or the Handlebars subsets used by SendGrid Dynamic Templates and Mand…
HBS-001 mortal All template variables must have fallback values. No variable should render as an empty string silently {{firstName}} renders as empty string when the value is missing. "Hi , your order..." is sent to thousands of recipients when data is incomplete. Use conditional blocks or a registered defaultIfEmpty helper for every optional field. Source: production email incident patterns.
detect: contextual` — test every template with a payload where all optional string fields are `undefined
HBS-002 mortal Use {{variable}} (double-stache) for all user-provided content. Triple-stache {{{rawHtml}}} must only be used for pre-rendered, trusted HTML from your own system Triple-stache bypasses HTML escaping. If the value contains user-generated content, this is an XSS vector in email webview rendering and in-app browser contexts (opening links, CSP-exempted webviews). Source: OWASP XSS Prevention Cheat Sheet.
detect: regex` — pattern: `\{\{\{[^}]+\}\}\}
HBS-003 mortal Do not rely on @index, @first, or @last loop metadata in {{#each}} when templates run through SendGrid Dynamic Templates SendGrid's Handlebars subset does not document @index/@first/@last as supported. Templates that use these work in local Handlebars.js but silently fail in SendGrid — the metadata variables render as empty or cause unexpected output. Source: SendGrid Dynamic Templates documentation.
detect: regex` — pattern: `@(?:index|first|last)\b
HBS-004 mortal Postmark uses Mustache, not Handlebars. Do not use {{#each}}, {{#if condition}}, or custom helpers in Postmark templates Postmark's template engine is standard Mustache (RFC). Mustache uses {{#section}} for both conditionals (renders if truthy) and loops (renders once per array element). {{^section}} renders when the value is falsy or the array is empty. No block helpers, no custom helpers, no @index metadata. Source: Postmark developer documentation.
detect: contextual` — if `stack.esp` is "postmark", flag `{{#each}}`, `{{#if}}` comparisons, and `@`-variables
HBS-005 mortal Partials must be registered with Handlebars.registerPartial() before Handlebars.compile() is called. Unregistered partials throw at compile time — not at send time Missing partial registration causes an immediate error. More dangerous is the inverse: partial registration in one service instance not reflected in another (e.g., in a multi-process Node.js cluster). Use a centralised registration module loaded at startup.
detect: contextual` — verify all `{{> partial_name}}` references have corresponding `registerPartial()` calls in the initialisation module
HBS-006 mortal Never concatenate unescaped user data into the template source string before Handlebars.compile(). This is a server-side template injection (SSTI) vector Template source must come from trusted files. User data is passed as the context object to the compiled function. Any pattern that builds the template string from user input allows injection of Handlebars syntax — including {{#each}} loops that expose server-side data objects.
detect: contextual` — code review concern; flag any code path that builds a template string from request parameters
HBS-007 venial Register a formatCurrency helper for monetary values. Do not format currency inline Inline currency (${{price}}) outputs $9.9 for a 9.90 float. Currency formatting requires locale-aware handling of decimal places and symbol placement. Register once, use everywhere. Source: Handlebars.js helper documentation.
detect: contextual` — check for monetary variables used without a currency format helper
HBS-008 venial Register a formatDate helper for date values. Never render raw ISO 8601 strings into email copy 2026-03-18T14:00:00.000Z in email copy is unacceptable. A formatDate helper converts to locale-appropriate display: "Wednesday 18 March 2026". Source: Handlebars.js guide.
detect: regex` — pattern: `\{\{[^}]*[Dd]ate[^}]*\}\}(?![^{]*formatDate)` (date variable without format helper)
HBS-009 venial Pre-compile templates at build/deploy time using Handlebars.precompile(). Do not call Handlebars.compile() per send in a high-volume pipeline Handlebars.compile() parses and compiles the template source on every invocation. Pre-compiled templates are JavaScript functions — send-time rendering is orders of magnitude faster. Source: Handlebars.js API documentation.
detect: contextual` — check if the production send path calls `Handlebars.compile()` on the template source
HBS-010 venial Handlebars {{#if}} tests truthiness only — no comparison operators. Comparisons must be expressed as registered helpers {{#if user.tier === "vip"}} is not valid Handlebars — the === is syntax Handlebars does not parse. It silently evaluates to falsy. Register a helper: {{#ifEquals user.tier "vip"}}...{{/ifEquals}}. Source: Handlebars.js guide — built-in helpers.
detect: regex` — pattern: `\{\{#if[^}]*(?:===|!==|>=|<=|>|<)[^}]*\}\}
HBS-011 venial Use partials for shared email components (header, footer, CTA button, order row) Duplicating boilerplate across templates creates divergence — the footer in order-confirmation.hbs and shipping-notification.hbs slowly drift. Partials enforce a single source of truth for shared components.
detect: contextual` — check if multiple templates contain identical HTML blocks (>10 lines) without using a partial
HBS-012 venial {{else}} blocks must contain a complete, readable fallback — not empty <td> elements or whitespace Recipients who trigger the else path (no order items, no shipping address) must still see a meaningful email. An empty else block produces a broken layout with missing table cells or conspicuous blank spaces.
detect: contextual` — check `{{else}}` blocks for empty or whitespace-only content
HBS-013 venial Escape HTML entities in subject lines and preheaders — they are plain text fields &, <, > in subject lines render as literal HTML entity strings in email clients. If your template data is HTML-escaped before injection, the subject field receives Acme & Co. and displays as Acme & Co. in the inbox. Pre-process entity decoding for subject/preheader fields.
detect: contextual` — check if subject/preheader values pass through HTML unescaping before being passed to the ESP API
HBS-014 venial Test with a deliberately incomplete payload — all optional fields undefined, all arrays empty The {{#each items}} empty path and the {{#unless}} truthy path are the most common sources of broken layout. Comprehensive testing always uses complete data. Fail testing uses null/undefined/empty-array data.
detect: contextual` — advisory; ensure test harness includes a "minimum data" test fixture
HBS-015 counsel Use {{#with}} sparingly to reduce nesting verbosity — not as a general code-organisation strategy {{#with order.shipping}}...{{/with}} gives direct access to address, city, postcode without order.shipping. prefix. Overuse makes templates opaque — {{city}} with no clear context makes code review and debugging harder.
detect: contextual` — advisory
HBS-016 counsel Remove {{log}} helper calls before deploying templates to production {{log someValue}} outputs to the console during local development. It has no output in the rendered HTML but is extraneous in production template files.
detect: regex` — pattern: `\{\{log\s
HBS-017 counsel For SendGrid Dynamic Templates, prefer the Template API for version management over inlining template HTML in API calls Templates stored in the SendGrid dashboard can be versioned and rolled back without a code deployment. A/B testing, scheduling, and suppression lists are also manageable at the template level.
detect: contextual` — advisory; check if SendGrid calls use `template_id` vs inline `content
LIQ Liquid
Rules and gotchas for engineers building transactional email templates with Liquid — Shopify's open-source templating language (Ruby gem: `liquid`; JavaScript p…
liquid; JavaScript port: liquidjs). Covers Klaviyo's Liquid implementation, self-hosted LiquidJS pipelines, and the safety properties that make Liquid well-suited to email rendering. Liquid has no filesystem access and cannot execute arbitrary code — it is the safest option for rendering user-influenced templates.LIQ-001 mortal All output variables must use the default filter. {{ first_name }} renders as empty string when nil Without a default, a missing or nil variable silently produces "Hi ," in the email body. The default filter covers both nil values and absent keys. Source: Liquid Reference — Filters.
detect: regex` — pattern: `\{\{[\s]*[a-zA-Z_][a-zA-Z0-9_.]*[\s]*\}\}` (output tag with no filter pipe)
LIQ-002 mortal Use {% for %}...{% else %} to handle empty arrays. The {% else %} block renders when the array is nil or empty A {% for %} loop with no {% else %} leaves recipients with missing order rows, blank sections, or broken table structure when the array is empty. Always provide a fallback row or message.
detect: contextual` — check `{% for %}` loops for `{% else %}` fallback blocks
LIQ-003 mortal Use the escape filter on user-generated content rendered in HTML attribute positions <a href="{{ url }}"> is safe only when url is a trusted, validated URL. User-controlled url values may contain " characters that break the attribute, or javascript: protocol values. Use {{ url | escape }} for string attribute values containing UGC. Source: Liquid Reference — escape filter.
detect: contextual` — check if user-input variables in HTML attribute positions use `escape
LIQ-004 mortal Use strip_html before rendering user-generated content into email body text User-provided product names, addresses, and notes may contain HTML tags. In email, unexpected tags break layout and can inject unwanted styles. {{ product.name | strip_html }} removes all tags before rendering. Source: Liquid Reference — strip_html filter.
detect: contextual` — check if known user-generated content fields pass through `strip_html
LIQ-005 mortal Never use {% raw %} to inject large HTML blocks from user input. It bypasses all filtering {% raw %} outputs its content completely unprocessed. A {% raw %}{{ user.bio }}{% endraw %} where user.bio is user-controlled is an injection risk. {% raw %} is for displaying literal Liquid syntax in documentation or code examples only.
detect: contextual` — flag `{% raw %}` blocks that contain dynamic variables or user-controlled content
LIQ-006 venial Use the money filter (Shopify/Klaviyo) or a registered money filter for currency values ${{ price }} outputs $9.9 for a 9.90 float. Shopify and Klaviyo provide a money filter: {{ price | money }} → $9.90. For self-hosted LiquidJS, register a custom money filter using Intl.NumberFormat. Source: Shopify Liquid Reference — money filter.
detect: contextual` — check monetary variables for `money` filter or equivalent
LIQ-007 venial Use the date filter with an explicit format string for all date values Raw ISO 8601 strings (2026-03-18T14:00:00Z) are unacceptable in email copy. {{ delivery_date | date: "%B %-d, %Y" }} → "March 18, 2026". Use %-d (not %d) to suppress zero-padding on the day in Ruby Liquid. Source: Liquid Reference — date filter.
detect: contextual` — check date variables for `date:` filter with format string
LIQ-008 venial Use {% assign %} for computed values rather than long inline filter chains A filter chain longer than 3 filters is hard to read and debug: {{ order.total | times: 1.2 | round: 2 | money }}. Assign the intermediate result: {% assign total_with_tax = order.total | times: 1.2 | round: 2 %} then {{ total_with_tax | money }}.
detect: contextual` — check output tags for chains of more than 3 filters
LIQ-009 venial Use whitespace control {%- and -%} on logic tags inside table structures In Outlook Windows (Word engine), whitespace text nodes between <tr> elements cause layout gaps. {% for item in items %} on its own line emits a newline into the compiled output. Use {%- for item in items -%} to strip surrounding whitespace in table contexts. Source: Liquid Reference — whitespace control.
detect: contextual` — check `{% for %}`, `{% if %}`, `{% endif %}`, `{% endfor %}` inside `<table>` / `<tr>` structures for whitespace control modifiers
LIQ-010 venial Use forloop.first and forloop.last for row separators and conditional borders — they are available in all Liquid implementations {% if forloop.first %} and {% if forloop.last %} are standard Liquid loop variables. They are more readable than {% if forloop.index == 1 %} and work consistently across Ruby Liquid, LiquidJS, and Klaviyo.
detect: contextual` — advisory; prefer `forloop.first`/`forloop.last` over index comparisons
LIQ-011 venial In LiquidJS (Node.js), configure strictVariables: false in production to prevent rendering errors from incomplete recipient data strictVariables: true throws when a variable is referenced but not in context. For email, where recipient profile data may be incomplete (missing fields for new users), strict mode causes send failures rather than graceful fallbacks. Use default filters defensively and allow strictVariables: false. Source: LiquidJS Configuration documentation.
detect: contextual` — check LiquidJS Environment configuration for `strictVariables` setting
LIQ-012 venial In Klaviyo, use {{ person.first_name }} for profile properties and {{ event.extra.property }} for event properties Klaviyo's Liquid context exposes two namespaced objects: person (profile properties) and event (event payload). Raw {{ first_name }} is undefined in Klaviyo's context. Source: Klaviyo Developer Docs — Liquid Overview.
detect: contextual` — if `stack.esp` is "klaviyo", check that variables use `person.` or `event.extra.` accessors
LIQ-013 venial Use | truncate: 90, "" without trailing ellipsis when building preheader text The default truncate appends "..." — {{ preheader | truncate: 90 }} produces "..."-terminated text in the inbox preview. | truncate: 90, "" truncates cleanly. Source: Liquid Reference — truncate filter.
detect: contextual` — check if `truncate` filter on preheader values uses the empty-suffix variant
LIQ-014 venial Prefer {% render %} over {% include %} for shared partials in Shopify Liquid 5+ {% include %} is deprecated in Shopify Liquid 5+. {% render %} has strict scope isolation (the rendered partial cannot access the parent template's variables unless explicitly passed). Klaviyo does not support either — use {% capture %} for component-like patterns instead.
detect: contextual` — check if Shopify Liquid 5+ templates use deprecated `{% include %}
LIQ-015 counsel Use {% capture %} blocks to build complex strings before rendering, rather than constructing them inline {% capture full_name %}{{ first_name }} {{ last_name }}{% endcapture %} makes the constructed string available as {{ full_name }} without repeating the construction logic.
detect: contextual` — advisory
LIQ-016 counsel The cycle tag generates alternating values across loop iterations — use it for alternating row background colours {% cycle "#f4f4f4", "#ffffff" %} outputs the values in rotation with each call. Cleaner than {% if forloop.index | modulo: 2 == 0 %} comparisons.
detect: contextual` — advisory
LIQ-017 counsel Use {% comment %} for template documentation. Comment content is stripped from the rendered output {% comment %}This loop handles empty orders — see DELIV-007{% endcomment %} documents intent without affecting email output or size.
detect: contextual` — advisory
LIQ-018 mortal Test Liquid templates with explicitly nil values for all optional variables — not just with missing keys In Liquid, nil and a missing key produce identical output (empty string, default filter fires). However, in Klaviyo, nil vs absent profile property may have different send-path semantics. Test both: { first_name: nil } and {} with no first_name key.
detect: contextual` — advisory; ensure test fixtures include both nil values and absent keys
LIQ-019 venial In Klaviyo, do not use raw variable names without an approved namespace prefix Klaviyo's Liquid context exposes only four top-level namespaces: person (profile properties), event (event payload — dynamic content accessed via event.extra.*), organization (account-level properties), and unsubscribe_link. Variable names like {{ first_name }}, {{ email }}, {{ order_id }}, {{ customer.name }}, or {{ stats.revenue }} are ALL undefined in Klaviyo and silently render as empty string. {{ first_name }} is correct in plain LiquidJS; {{ customer.* }} is correct in Shopify — neither works in Klaviyo. Source: Klaviyo Developer Docs — Liquid Overview.
detect: regex` — (when `stack.esp` is "klaviyo") pattern: `\{\{[-\s]*(?!person\b|event\b|organization\b|unsubscribe_link\b)[a-zA-Z_][a-zA-Z0-9_.]*[-\s]*(?:\|[^}]*)?\}\}` — matches output tags whose root variable is not in the four approved Klaviyo namespaces
REMAIL React Email
Rules and gotchas for engineers building email templates with React Email — the Resend-maintained React/TypeScript email component library (`react-email@5.2.10`…
react-email@5.2.10, March 2026). React Email renders React components to HTML strings server-side. It does not abstract cross-client layout like MJML — engineers remain responsible for email-safe CSS. Its primary advantage over text-based templating is TypeScript compile-time checking of email data shapes.REMAIL-001 mortal Render components server-side only — never in the browser React Email render() produces static HTML strings. Components have no runtime DOM interaction, event handlers, or client state. They must be invoked from Node.js server context (API route, serverless function, background job queue) — not from a browser bundle. Importing email components into client-side React bundles increases bundle size and produces no useful output. Source: React Email documentation.
detect: contextual` — check that `render()` / `renderAsync()` calls are in server-side code only
REMAIL-002 mortal All required props must have TypeScript defaults or explicit runtime guards Missing required data at send time produces garbled output or rendering errors. TypeScript non-optional props (firstName: string, not firstName?: string) cause build failures when callers omit them — this is the correct behaviour. Optional props must have defaults: firstName = 'Valued Customer'.
detect: contextual` — check component prop interfaces; flag optional props without default values
REMAIL-003 mortal Use <Preview> for the preheader — do not manually code the hidden preheader div <Preview>Your order has shipped.</Preview> generates the correct multi-property hidden div. Manual preheader divs routinely omit mso-hide:all or use display:none alone, which is insufficient in Outlook preview panes (see GOTCHA-028). Source: React Email documentation.
detect: contextual` — check if template has `<Preview>` component; flag hand-coded hidden preheader divs
REMAIL-004 mortal Pass the output of render() to the ESP — do not use ReactDOM.renderToString() render() from @react-email/components applies email-specific HTML transformations beyond what ReactDOM.renderToString() produces: proper DOCTYPE, attribute serialisation, and email-client compatibility patches. Using ReactDOM.renderToString() directly produces subtly wrong HTML.
detect: contextual` — check that send path uses `render()` or `renderAsync()` from `@react-email/components
REMAIL-005 mortal Do not use React hooks in email components useEffect, useState, useRef, useContext, useReducer, and any other hook require a browser runtime. Email components are pure rendering functions executed in a Node.js SSR context. Hooks throw at render time. Source: React Email FAQ.
detect: regex` — pattern: `\buse(?:Effect|State|Ref|Context|Reducer|Callback|Memo|LayoutEffect)\b
REMAIL-006 mortal Do not use CSS Modules, styled-components, emotion, or any CSS-in-JS that requires a browser runtime or webpack/vite loader Email components render to static HTML strings. CSS Modules generate class names that reference a stylesheet — there is no stylesheet in an email. Styled-components and emotion require the CSS to be injected into the DOM at runtime. Only inline style objects and @react-email/tailwind utility classes generate CSS in the HTML string. Source: React Email documentation.
detect: contextual` — check email component files for CSS module imports or styled-components/emotion usage
REMAIL-007 mortal Pin @react-email/components to an exact version React Email has a frequent patch cadence. Output changes between versions affect rendered HTML. Use "0.0.31" not "^0.0.31".
detect: contextual` — check package.json for caret/tilde on `@react-email/components` and related packages
REMAIL-008 venial Type all email component props with explicit TypeScript interfaces any prop types defeat the primary advantage of React Email. When order.items is typed as OrderItem[] (with your application's shared type), renaming a field in OrderItem fails the TypeScript build immediately — not a production send.
detect: regex` — pattern: `:\s*any\b` in email component prop interfaces
REMAIL-009 venial Use <Img> from @react-email/components — not bare <img> <Img> applies email-safe defaults: display: block, border: 0, max-width: 100%. These defaults prevent the 4px gap-below-image bug (see RENDER-002) and image overflow in mobile clients.
detect: regex` — pattern: `<img\s` (lowercase `img` element — not the React Email component)
REMAIL-010 venial Use <Link> from @react-email/components for hyperlinks — not bare <a> <Link> applies email-safe inline style defaults including text-decoration: none overrides and properly serialises the href attribute for email clients.
detect: regex` — pattern: `<a\s+(?:href|style)=` outside of MSO conditional comment blocks
REMAIL-011 venial Use <Button> for CTAs and verify the rendered output includes VML for Outlook targets React Email's <Button> renders an <a> with inline styles. Check whether the compiled output from your React Email version includes the VML bulletproof button pattern for Outlook 2007–2019. If it does not, manually wrap with MSO conditional VML (see RENDER-014).
detect: contextual` — check compiled HTML output for VML when `<Button>` is used with Outlook as a target client
REMAIL-012 venial All href values in <Link> and <Button> must be absolute HTTPS URLs React Email does not validate URLs. Relative href values (e.g. href="/track/TOKEN") fail in all email clients — there is no base URL in email (see GOTCHA-025).
detect: contextual` — check `href` prop values for relative URL patterns
REMAIL-013 venial Generate a plain-text version alongside the HTML. React Email does not do this automatically render() returns HTML only. The plain-text MIME part is required for DELIV-007 compliance and is read by spam filters. Use the html-to-text npm package or maintain a parallel plain-text template. Source: Postmark deliverability guide.
detect: contextual` — check send path for plain-text generation alongside the HTML render
REMAIL-014 venial Use <Container> as the email wrapper — not a bare <div> with inline style <Container> generates a table-based centered wrapper compatible with Outlook 2007–2019. A <div style={{maxWidth: '600px', margin: '0 auto'}}> does not centre in Outlook Windows — it requires the table approach. Source: React Email documentation.
detect: contextual` — check outer layout structure uses `<Container>` not bare `<div>
REMAIL-015 venial Use renderAsync() for components that contain async data fetching render() is synchronous. If a component calls await fetch(...) or resolves a database query during rendering, use renderAsync(). Calling render() on an async component silently produces incomplete output without an error.
detect: contextual` — check for async component functions used with synchronous `render()
REMAIL-016 counsel Use the development preview server (@react-email/preview-server) for visual development The preview server renders all .tsx email templates in a specified directory with hot reload and a browser preview. It is significantly faster than the send-and-receive testing loop for visual iteration.
detect: contextual` — advisory
REMAIL-017 counsel Use <Hr> for visual dividers — not <div> borders or background-colour <tr> rows <Hr> from @react-email/components renders a <hr> with email-safe inline styles that display consistently across Outlook and modern clients.
detect: contextual` — advisory
REMAIL-018 counsel Share TypeScript type definitions between application models and email component props where possible The primary advantage of React Email over text templating systems is this integration. If Order, LineItem, and User types are shared, data-shape mismatches surface as build errors. Maintain the shared import rather than duplicating type definitions in the email package.
detect: contextual` — advisory; check if email components import shared types from the application domain layer
MZL Maizzle
Rules and gotchas for engineers building email templates with Maizzle — the Tailwind CSS email framework (stable: v5.5.0, February 2026). Maizzle applies the Ta…
MZL-001 mortal Run maizzle build production for all output intended for sending — not maizzle serve output maizzle serve produces development builds: CSS is not inlined, images use localhost paths, minification is off. Sending a development build sends unstyled HTML with broken image URLs. Source: Maizzle documentation — build environments.
detect: contextual` — check CI/CD pipeline to confirm the production build command is used for ESP delivery
MZL-002 mortal Pin Maizzle and Tailwind CSS to exact versions in package.json Tailwind v3 → v4 is a major breaking change affecting how Maizzle processes CSS. Maizzle v4 → v5 changed the build engine (Vite replaces Browsersync). Floating semver (^5.5.0, ^3.4.17) causes undetected visual regressions on npm install. Source: Maizzle v5 changelog; Tailwind CSS v4 migration guide.
detect: contextual` — check package.json for caret/tilde ranges on `maizzle` and `tailwindcss
MZL-003 mortal Do not use Tailwind flex, grid, inline-flex, or inline-grid utilities for structural layout Flexbox and grid are not supported in Outlook 2007–2019 or Gmail (for non-Google accounts). Maizzle does not restrict these utilities — they compile and inline successfully but silently fail in major clients. Use table-based HTML for all structural layout. Use flex utilities only inside @media queries scoped to clients known to support it. Source: caniemail.com.
detect: contextual` — check if flex/grid utilities appear on layout-critical structural elements
MZL-004 mortal Do not use Tailwind CSS variable utilities (bg-[var(--colour)], text-[var(--colour)]) in email templates CSS Custom Properties are not supported in Outlook 2007–2019, Gmail, or Yahoo Mail (see GOTCHA-024). Maizzle's CSS inlining step resolves static Tailwind values at build time, but arbitrary var() references cannot be statically resolved — they are emitted as-is into inline styles. Source: caniemail.com.
detect: regex` — pattern: `\bvar\(--[^)]+\)
MZL-005 mortal Add all dynamically composed class names to the Tailwind safelist in tailwind.config.js Tailwind's content scanner detects class names as literal strings. Classes built via string interpolation in Nunjucks expressions ("bg-" + colour) or component props are invisible to the scanner and their CSS is purged. List dynamic classes in safelist with regex patterns if needed.
detect: contextual` — check if any class names are composed dynamically; verify `safelist` covers them
MZL-006 mortal Compiled output in build/ or dist/ must never be hand-edited maizzle build overwrites compiled output entirely. Manual edits to compiled files are silently discarded by the next build, creating invisible drift between source and deployed HTML.
detect: contextual` — advisory; check if `build/` is committed as source-of-truth rather than generated artefact
MZL-007 mortal Test compiled HTML output in real email clients — not only the Maizzle dev browser preview The Maizzle browser preview renders in a modern browser engine. Compiled HTML must be tested in Outlook (Word engine), Gmail, and Apple Mail for cross-client issues that are invisible in browser rendering.
detect: contextual` — advisory; verify QA process includes real email client testing
MZL-008 venial Verify inlineCSS: true and minifyHTML: true are set in the production config environment Production config (config.production.js) should have CSS inlining enabled (so Tailwind classes become inline styles) and HTML minification enabled (to reduce size and eliminate whitespace gaps). These are off in development — confirm production config explicitly.
detect: contextual` — check `config.production.js` for `css.inline` and `minify` settings
MZL-009 venial Set prettify: false in production config Pretty-printed output adds newlines and indentation between table elements. Whitespace text nodes between <td> elements cause 4px layout gaps in some email clients (see GOTCHA-011). Production output should be compact.
detect: contextual` — check production config for `prettify` setting
MZL-010 venial Set explicit width and height HTML attributes on all <img> elements — Tailwind width utilities alone are not sufficient for Outlook 2007–2019 Outlook Windows ignores CSS width on images. The HTML width attribute is the only reliable size control. Use both: <img width="600" class="w-full" alt="..."> — CSS for responsive scaling, attribute for Outlook. Source: Campaign Monitor CSS support guide.
detect: contextual` — check `<img>` elements for explicit `width` and `height` HTML attributes
MZL-011 venial Use Tailwind's !important utilities (prefix !) to override email client default styles Email clients inject their own CSS resets. !bg-white (compiles to background-color: #ffffff !important) overrides Outlook's default grey body background. Standard Tailwind utilities without !important can be overridden by client defaults.
detect: contextual` — advisory; when client-default overrides are needed, use `!` prefix
MZL-012 venial For Outlook background images, use VML — either Maizzle's <x-bg-image> component or manually coded VML conditionals Standard CSS background-image is not supported in Outlook 2007–2019 (see RENDER-015). Maizzle does not abstract this automatically. The VML approach must be explicitly implemented in templates that use background images. Source: Maizzle documentation; Campaign Monitor VML guide.
detect: contextual` — check templates using `bg-[url(...)]` Tailwind classes or CSS `background-image` for Outlook VML fallback
MZL-013 venial Use x-component (or Maizzle's component include syntax) for shared email parts Duplicate header and footer HTML across templates creates divergence. Use Maizzle's component system to maintain a single source of truth for shared components.
detect: contextual` — check for duplicate HTML blocks (>10 lines) across templates without component includes
MZL-014 venial Use front matter for per-template settings — subject, preheader, from name, and CSS overrides Maizzle passes front matter fields into the template as page.subject, page.preheader, etc. This keeps send metadata alongside the template it describes and enables per-template <title> generation.
detect: contextual` — advisory; check if templates use front matter for metadata
MZL-015 venial Configure removeUnusedCSS: true in production only. Leave it disabled in development to avoid false negatives when iterating CSS removal is destructive — it strips classes not found in the template source. During development, temporarily commented-out classes or classes under construction are real and should not be removed. Source: Maizzle documentation — removeUnusedCSS.
detect: contextual` — check that `removeUnusedCSS` is false in local/development config and true in production
MZL-016 counsel Understand the layered build pipeline: Nunjucks (layout time) → Tailwind (CSS time) → inline (deploy time) → data injection (send time) Maizzle uses Nunjucks for template logic ({% if %}, {% for %}, {% macro %}). This handles layout-time logic when building the template. Per-recipient data injection (Handlebars placeholders, Liquid variables) happens at send time after Maizzle compilation. Nunjucks and Handlebars use conflicting {{ syntax — use MJML-style approach: write Handlebars placeholders in Maizzle source and ensure Nunjucks does not evaluate them.
detect: contextual` — advisory; verify Handlebars/Liquid placeholders survive Maizzle compilation
MZL-017 counsel Start new email types from Maizzle starter templates rather than blank HTML Maizzle starters include correct table structure, Outlook ghost table conditionals, meta tags (x-apple-disable-message-reformatting, color-scheme), and tested responsive patterns. Starting from blank HTML requires manually adding all these — a common source of omissions.
detect: contextual` — advisory
MZL-018 counsel PostCSS plugins that generate modern CSS (e.g. postcss-preset-env with CSS variable output) can introduce email-unsafe properties into the compiled output PostCSS postcss-preset-env with stage: 0 may transform properties in ways that produce CSS variables or calc() expressions. Audit your PostCSS plugin configuration to verify the compiled output contains only email-safe CSS. Source: PostCSS documentation.
detect: contextual` — review PostCSS config for plugins that may introduce CSS variables or modern transforms
Configuration
On first invocation, Elder creates
.email-absolution/config.yml via a questionnaire.
Individual templates may have their own
email.config.yml — per-email configs inherit from
the project config and only need to specify overrides.
Set strictness per category: strict,
pragmatic, or
aspirational.
# .email-absolution/config.yml
stack:
esp: sendgrid
templating: handlebars
clients: all
brand:
primary_colour: "#0066cc"
max_width: 600
strictness:
rendering: strict
html-css: strict
accessibility: pragmatic
deliverability: strict
gotchas: strict
content-ux: pragmatic
tooling: aspirational
email_defaults:
type: transactional
unsubscribe: false Client coverage
The rendering doctrine covers the full client support matrix. Nothing ships without passing the Witchfinder's examination of how it renders everywhere that matters.