Email Renderer
The @owlat/email-renderer package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode, and Outlook VML fallbacks.
The @owlat/email-renderer package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode support, and Outlook VML fallbacks.
Quick Start
import { renderEmailHtml } from '@owlat/email-renderer';
import type { EditorBlock } from '@owlat/shared';
const blocks: EditorBlock[] = [
{
id: 'block-1',
type: 'text',
content: {
html: '<p>Hello {{firstName}}</p>',
blockType: 'paragraph',
fontSize: 16,
textColor: '#333333',
},
},
];
const html = renderEmailHtml(blocks, {
theme: { primaryColor: '#4f46e5' },
preheaderText: 'Your weekly update is here',
inlineCss: true,
});
Rendering Pipeline
EditorBlock[]
│
▼ Conditional content filtering (variable-based show/hide)
▼ Theme defaults applied (heading styles, button styles, body text)
▼ Mobile font size rules collected
│
▼ Per-block rendering (table-based HTML with VML fallbacks)
│
▼ Document wrapping (DOCTYPE, head, styles, boilerplate)
▼ CSS inlining (styles onto elements for Gmail/Yahoo)
▼ Optional HTML minification
│
▼ Final HTML string
Render Options
All options are passed as the second argument to renderEmailHtml():
| Option | Type | Default | Description |
|---|---|---|---|
theme | EmailTheme | See below | Design tokens (colors, fonts, spacing) |
darkMode | boolean | false | Enable dark mode preview rendering |
preheaderText | string | '' | Hidden inbox preview text |
title | string | '' | Document title (browser tab) |
baseWidth | number | 600 | Content width in px (500-700 typical) |
breakpoint | number | 480 | Mobile responsive breakpoint in px |
direction | 'ltr' | 'rtl' | 'ltr' | Text direction for RTL languages |
lang | string | 'en' | HTML lang attribute |
inlineCss | boolean | true | Inline CSS onto elements for Gmail/Yahoo |
minify | boolean | false | Minify output HTML (preserves MSO comments) |
variableType | VariableType | 'personalization' | Variable rendering mode |
variableValues | Record<string, string> | {} | Values for conditional content evaluation |
fontUrls | string[] | [] | Web font URLs to import |
customCss | string | '' | Custom CSS injected into the style block |
linkTransform | Function | — | Transform all link URLs (UTM, click tracking) |
onWarning | Function | — | Callback for non-fatal render warnings |
targetClient | TargetClient | — | Simulate rendering for a specific email client (gmail, outlookDesktop, outlookNew, appleMail, yahooMail) |
validationLevel | ValidationLevel | 'soft' | Validation strictness: skip (none), soft (warn), strict (throw) |
gmailAnnotations | GmailAnnotations | — | Gmail Promotions tab annotations (Schema.org JSON-LD for rich cards) |
EmailTheme
interface EmailTheme {
primaryColor?: string;
fontFamily?: string;
backgroundColor?: string;
headingFontFamily?: string;
bodyFontSize?: number;
bodyTextColor?: string;
linkColor?: string;
borderRadius?: number;
spacingUnit?: number;
buttonDefaults?: {
backgroundColor?: string;
textColor?: string;
borderRadius?: number;
fontSize?: number;
fontFamily?: string;
fontWeight?: number;
paddingX?: number;
paddingY?: number;
};
headingDefaults?: {
h1?: HeadingStyle;
h2?: HeadingStyle;
h3?: HeadingStyle;
};
blockDefaults?: Partial<Record<BlockType, Record<string, unknown>>>;
darkModeBackgroundColor?: string; // default '#121212'
darkModeTextColor?: string; // default '#e4e4e7'
darkModeLinkColor?: string; // default '#93c5fd'
baseWidth?: number; // default 600 (min 400, max 800)
}
Theme defaults are merged with block-level properties. Block-level values always take priority.
The blockDefaults field works like MJML's mj-attributes — it lets you set default properties for any block type (e.g., default padding for all text blocks, default border-radius for all buttons). These are shallow-merged into block content before rendering.
Block Types
The renderer supports 17 block types:
Content Blocks
| Type | Description |
|---|---|
text | Rich text (paragraph, h1, h2, h3) with font size, color, alignment, line height |
image | Responsive image with alt text, link, srcset/retina, dark mode image swap |
button | CTA with VML bulletproof rendering for Outlook, configurable width (px/%) |
video | Thumbnail with SVG play button overlay, links to video URL |
rawHtml | Raw HTML injection (use with care) |
Layout Blocks
| Type | Description |
|---|---|
columns | 1-4 column layouts with ratio presets, per-column styling, gap, non-stacking, reverse mobile order |
container | Nested block grouping with background, border, border-radius |
hero | Background image section with VML Outlook fallback, overlay, vertical alignment |
divider | Horizontal rule with color, thickness, width, style |
spacer | Vertical spacing |
Interactive Blocks
| Type | Description |
|---|---|
accordion | CSS-only expandable sections (interactive in Apple Mail/iOS, ~40% of clients; expanded fallback elsewhere) |
menu | Horizontal navigation with CSS-only hamburger toggle on mobile |
carousel | CSS-only image slideshow with navigation dots (interactive in Apple Mail/iOS, shows first image elsewhere) |
Data Blocks
| Type | Description |
|---|---|
table | Data table with rich cells, column widths, colSpan/rowSpan, header, footer, caption, striped rows |
social | Social media icon links (filled/outline style) with text-initial fallback |
list | Table-based list (bullet, numbered, check, icon) — avoids <ul>/<ol> client inconsistencies |
progressBar | Visual progress bar with label, fully table-based |
CSS Inlining
Gmail, Yahoo, and several other clients strip <style> tags from emails. The CSS inlining engine applies computed styles directly onto HTML elements as inline style attributes.
// Enabled by default
const html = renderEmailHtml(blocks);
// Disable for debugging
const html = renderEmailHtml(blocks, { inlineCss: false });
What gets inlined: Base styles (font-size, color, background-color, padding, etc.) from the generated <style> block are matched to elements and applied inline.
What stays in <style>: Media queries (responsive), @media (prefers-color-scheme:dark) (dark mode), @keyframes (animations), and pseudo-selector rules. These require the <style> block and degrade gracefully in clients that strip it.
The inliner is also exported standalone:
import { inlineCss } from '@owlat/email-renderer';
const inlined = inlineCss('<style>.foo{color:red}</style><div class="foo">Hi</div>');
// <div class="foo" style="color:red">Hi</div>
Dark Mode
The renderer generates full dark mode support using @media (prefers-color-scheme: dark) CSS rules.
Features
- Meta tags (
color-scheme,supported-color-schemes) for client detection - Automatic text color inversion (dark backgrounds, light text)
- Link color adjustment (
#93c5fdin dark mode) - Image opacity reduction (
opacity: 0.9) - Dark mode image swap: Set
darkSrcon image blocks to show a different image in dark mode
const imageBlock: EditorBlock = {
id: 'img-1',
type: 'image',
content: {
src: '/logo-light.png',
darkSrc: '/logo-dark.png', // Shown in dark mode
alt: 'Logo',
width: 100,
},
};
- Per-block dark overrides: Blocks can specify
darkOverrideswith custombackgroundColorandtextColorvalues, applied via CSS custom properties
Client Support
| Client | Dark Mode Support |
|---|---|
| Apple Mail / iOS | Full (media query) |
| Outlook.com | Full (media query) |
| Gmail (mobile) | Partial (forces own dark mode) |
| Outlook Desktop | None |
| Gmail (web) | None |
Outlook VML Support
Outlook Desktop ignores CSS border-radius, background-image on divs, and many modern CSS properties. The renderer generates VML (Vector Markup Language) fallbacks wrapped in MSO conditional comments.
Bulletproof Buttons
Buttons render with v:roundrect VML elements that support rounded corners and background colors in Outlook:
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="https://..."
style="height:44px;v-text-anchor:middle;width:200px"
arcsize="18%" fillcolor="#4f46e5" stroke="f">
<v:textbox inset="0,0,0,0">
<center style="color:#ffffff;font-size:16px">Click here</center>
</v:textbox>
</v:roundrect>
<![endif]-->
Background Images
Hero blocks and containers with background images use v:image VML for Outlook compatibility.
Fixed-Width Tables
The boilerplate wraps content in MSO-conditional fixed-width tables to enforce the baseWidth in Outlook (which ignores max-width).
Responsive Design
Mobile Stacking
Column layouts automatically stack on mobile (below the configured breakpoint). This can be controlled per-block:
// Entire columns block
content.mobileStacking = true; // default: true
// Per-column override
content.columnStyles = [
{ stackOnMobile: false }, // This column won't stack
{}, // This column follows parent setting
];
// Reverse stack order on mobile
content.mobileStackOrder = 'reverse'; // Image-right becomes image-first on mobile
Responsive Visibility
Any block supports hideOnMobile and hideOnDesktop flags for responsive show/hide:
content.hideOnMobile = true; // Hidden below breakpoint
content.hideOnDesktop = true; // Hidden above breakpoint
Responsive Font Size
Text blocks support a separate mobileFontSize that is applied via media query:
const textBlock = {
type: 'text',
content: {
fontSize: 18, // Desktop
mobileFontSize: 14, // Mobile
},
};
Fluid Images
Images automatically become fluid on mobile via the owlat-fluid-img CSS class (width: 100%; height: auto).
Conditional Content
Blocks can be shown or hidden based on variable values at render time:
const block: EditorBlock = {
type: 'text',
content: {
html: '<p>Premium member content</p>',
condition: {
variable: 'plan',
operator: 'equals',
value: 'premium',
},
},
};
// Only renders if plan === 'premium'
renderEmailHtml([block], {
variableValues: { plan: 'premium' },
});
Supported operators: exists, notExists, equals, notEquals, contains.
Repeat Blocks
Iterate over array variables to render a block once per item — useful for e-commerce product lists, order items, and recommendation rows:
const block: EditorBlock = {
type: 'text',
content: {
html: '<p>{{product.name}} — {{product.price}}</p>',
repeat: {
variable: 'products', // Key in variableValues (JSON-encoded array)
itemAlias: 'product', // Alias for {{product.field}} interpolation
maxItems: 5, // Optional cap on iterations
},
},
};
renderEmailHtml([block], {
variableValues: {
products: JSON.stringify([
{ name: 'Widget', price: '$9.99' },
{ name: 'Gadget', price: '$19.99' },
]),
},
});
Inside repeated blocks, use to reference item properties and for the zero-based iteration index. If maxItems is set, only the first N items are rendered.
Repeat blocks can be combined with conditional content — the condition is evaluated once on the block, while the repeat iterates over the array.
Gradient Backgrounds
Buttons, containers, and hero blocks support gradient backgrounds via the GradientBackground interface:
interface GradientBackground {
direction: string; // CSS direction, e.g. 'to right', '135deg'
stops: Array<{ color: string; position: number }>;
}
// Example: gradient button
const buttonBlock: EditorBlock = {
type: 'button',
content: {
text: 'Get Started',
url: 'https://example.com',
backgroundColor: '#4f46e5', // Solid fallback
backgroundGradient: {
direction: 'to right',
stops: [
{ color: '#4f46e5', position: 0 },
{ color: '#7c3aed', position: 100 },
],
},
// ...other button props
},
};
The renderer outputs a CSS linear-gradient with the solid backgroundColor as a fallback. For Outlook, a VML <v:fill type="gradient"> element provides gradient rendering where CSS gradients aren't supported.
Link Transform
Apply UTM parameters, click tracking, or any URL rewriting to all links in the email:
const html = renderEmailHtml(blocks, {
linkTransform: (url, { blockType, blockId }) => {
const u = new URL(url);
u.searchParams.set('utm_source', 'email');
u.searchParams.set('utm_medium', blockType);
return u.toString();
},
});
The transform is applied to links in buttons, images, social icons, menu items, and carousels.
CSS Animations
Progressive enhancement animations that only play when the user allows motion:
/* Generated automatically */
@media (prefers-reduced-motion: no-preference) {
.owlat-animate-fade-in { animation: owlat-fade-in 0.6s ease-out both; }
.owlat-animate-slide-up { animation: owlat-slide-up 0.6s ease-out both; }
}
Apply to blocks via cssClass:
content.cssClass = 'owlat-animate-fade-in';
Works in Apple Mail and iOS Mail. Silently ignored elsewhere.
Plain Text Renderer
Generate multipart plain text for accessibility and deliverability:
import { renderPlainText } from '@owlat/email-renderer';
const text = renderPlainText(blocks, { baseWidth: 600 });
All block types have plain text representations:
| Block | Plain Text Output |
|---|---|
text | Stripped HTML with link extraction |
button | [Button Text] (URL) |
image | [Image: alt text] or [Image: alt text] (link) |
divider | --- separator |
table | Pipe-delimited rows with header/footer |
social | Platform name + URL per line |
list | - item / 1. item / [x] item |
progressBar | [Progress: 75%] |
carousel | [Image 1: alt] (link) per image |
Block Validator
Pre-render structural validation catches issues before rendering:
import { validateBlocks } from '@owlat/email-renderer';
const { valid, issues } = validateBlocks(blocks);
for (const issue of issues) {
console.log(`[${issue.severity}] ${issue.code}: ${issue.message}`);
}
Validation Checks
- Text: Empty content detection
- Image: Missing
src, missingalttext, invalid dimensions - Button: Empty text, empty URL, placeholder URL (
#) - Video: Missing video URL, missing thumbnail
- Columns: Empty columns, excessive nesting depth
- Table: Empty headers, empty rows
- Carousel: No images, images without
srcoralt - List: Empty items, icon type without icon URL
- ProgressBar: Value out of 0-100 range, low bar/track color contrast
- General: Excessive block count, deeply nested structures
Accessibility Audit
Enable the accessibility audit for WCAG-oriented checks:
const { issues } = validateBlocks(blocks, { accessibilityAudit: true });
Additional checks include:
- Color contrast: Button text/background contrast ratio below 4.5:1 (WCAG AA)
- Heading hierarchy: Skipped heading levels (e.g., h1 followed by h3)
- Link text quality: Detects vague link text like "click here" or "read more"
- Table captions: Tables without
captionTextfor screen readers - Image alt text: Carousel images missing descriptive alt text
Email Analyzer
Post-render analysis for deliverability and quality:
import { analyzeEmail } from '@owlat/email-renderer';
const analysis = analyzeEmail(html, { subjectLine: 'Your order' });
Metrics
| Metric | Description |
|---|---|
htmlSizeBytes | Total HTML size |
exceedsGmailClip | Whether email exceeds Gmail's 102KB clipping threshold |
tableNestingDepth | Maximum table nesting (>10 may cause rendering issues) |
imageCount | Total images (>20 triggers warning) |
linkCount | Total links (>60 triggers spam filter warning) |
hasTextContent | Whether email has meaningful text (not image-only) |
textToImageRatio | Characters per image (higher is better for deliverability) |
displayNoneCount | Hidden elements beyond preheader (excessive = spam signal) |
styleBlockSizeBytes | Combined size of all <style> block content |
exceedsGmailCssLimit | Whether the <style> block exceeds Gmail's ~8KB limit (Gmail strips the whole block, breaking responsive layout and dark mode) |
cssValidationIssues | Basic CSS syntax problems (unbalanced braces, unclosed strings); omitted when none |
warnings | Array of actionable recommendations |
Pass a subjectLine to also flag subjects over 70 chars (Gmail truncation)
and ALL-CAPS subjects (spam signal). The warnings array additionally calls
out HTML that breaks in common clients: position:absolute (stripped by
Gmail), <form>/<input> elements (stripped outside Apple Mail/iOS), and
<video>/<audio> tags (Apple Mail/iOS only).
Pass { includeBreakdown: true } to populate sizeBreakdown (size by content
category) and optimizations (suggestions with estimated byte savings) on the
result:
const analysis = analyzeEmail(html, { subjectLine: 'Your order', includeBreakdown: true });
Client Compatibility Data
Per-block Feature compatibility and Property compatibility live inside each
Block module (under packages/email-renderer/src/blocks/<type>/) and are
surfaced through the Compatibility walker exported by @owlat/email-renderer.
The @owlat/shared package still owns the cross-cutting types, the client
metadata, and the plugin extension registries.
import {
getBlockCompatibility,
getPropertyCompatibility,
getCriticalProperties,
getClientPropertyIssues,
} from '@owlat/email-renderer';
import { emailClients, registerBlockCompatibility } from '@owlat/shared';
Per-Block Compatibility
const compat = getBlockCompatibility('accordion');
// [{
// feature: 'CSS-only toggle',
// description: 'Expandable/collapsible sections using :checked selector',
// support: { gmail: 'none', outlookDesktop: 'none', appleMail: 'full', ... },
// fallback: 'Falls back to all sections expanded in unsupported clients — content always visible',
// owlatHandled: true,
// canIEmailSlug: 'css-pseudo-class-checked',
// }, ...]
Per-Property Compatibility
const props = getPropertyCompatibility('image', 'borderRadius');
// [{
// property: 'borderRadius',
// description: 'Rounded corners on images',
// support: { gmail: 'full', outlookDesktop: 'none', appleMail: 'full', ... },
// severity: 'warning',
// recommendation: 'Use PNG with baked-in corners for Outlook',
// owlatHandled: false,
// }]
Severity Levels
- critical — Feature completely broken in major clients, no fallback
- warning — Degraded in some clients, fallback exists
- info — Minor visual differences, safe to use
Supported Clients
Gmail (Web), Gmail (Mobile App), Outlook Desktop (Classic), Outlook 365 (Web), Outlook (New), Outlook (Mac), Apple Mail, iOS Mail, Yahoo Mail, Samsung Mail, Thunderbird, ProtonMail.
AMP for Email
Generate AMP4Email-compatible HTML as a third format alongside HTML and plain text. AMP emails support interactive components (accordion, carousel) natively in supported clients.
import { renderAmpEmail } from '@owlat/email-renderer';
const amp = renderAmpEmail(blocks, {
title: 'Your weekly update',
baseWidth: 600,
lang: 'en',
});
The AMP renderer replaces CSS-only interactive components (accordion, carousel) with their AMP equivalents (amp-accordion, amp-carousel). Layout blocks (columns, hero, container) recurse into their children; table-based blocks (table, list, progress bar, menu) reuse their AMP-valid markup; and image-bearing blocks (image, social, video) emit <amp-img>. The only block without an AMP equivalent is the raw HTML block — AMP forbids arbitrary HTML, so it is omitted from AMP output.
Delivery: When you send a block-designed Postbox email that uses an interactive block (accordion/carousel), the AMP rendering is attached automatically as a text/x-amp-html alternative part alongside the HTML and plain-text parts. AMP-capable clients render the interactive version; everyone else falls through to the HTML fallback. (Designs without an interactive block ship HTML + text only — the AMP variant would be byte-for-byte equivalent.)
Client support: Gmail (Web & Mobile), Yahoo Mail, Mail.ru, FairEmail. Most other clients will show the HTML fallback from the multipart message.
Template Diff
Compare two rendered email HTML strings and detect structural changes. Useful for template versioning, A/B test verification, and regression detection.
import { diffEmails } from '@owlat/email-renderer';
const diff = diffEmails(oldHtml, newHtml);
if (!diff.identical) {
console.log(`${diff.changes.length} changes detected (${diff.sizeDelta > 0 ? '+' : ''}${diff.sizeDelta} bytes)`);
for (const change of diff.changes) {
console.log(`[${change.type}] ${change.category}: ${change.description}`);
}
}
Each change includes a type (added, removed, modified), a category (text, style, image, link, structure, meta), and summary stats with counts per category.
Block Modules
Every built-in block type is a Block module — a single vertical that owns
that type's HTML, plaintext, AMP, validation, default content factory,
placement metadata, and compatibility data. Modules register themselves at
package load (see packages/email-renderer/src/blocks/_builtin-modules.ts) and
the Walker in packages/email-renderer/src/blocks/index.ts dispatches by
block.type. Each module lives in its own directory, e.g.
packages/email-renderer/src/blocks/text/index.ts exports textModule.
This is the modern extension API. To ship a custom block type, implement
BlockModule<T> (defined in packages/email-renderer/src/blocks/_module.ts)
and register it with registerBlockModule() before freezing the registry:
import {
registerBlockModule,
finalizeBlockRegistry,
} from '@owlat/email-renderer';
import type { BlockModule } from '@owlat/email-renderer';
const ratingModule: BlockModule<'rating'> = {
type: 'rating',
// Inner HTML only — the Walker wraps it for the placement.
html({ content, ctx }) {
const { value, maxValue } = content as { value: number; maxValue: number };
const stars = '★'.repeat(value) + '☆'.repeat(maxValue - value);
return `<tr><td align="center" style="font-size:24px;padding:${ctx.theme.spacingUnit ?? 8}px 0">${stars}</td></tr>`;
},
// Optional: plaintext, amp, validate, isEmpty, layout, applyTheme,
// responsiveCss, compatibility, createDefault, placements.
};
registerBlockModule(ratingModule);
finalizeBlockRegistry(); // prevents further registration
Module Surface
A BlockModule<T> is generic over its block type, so content is narrowed to
that variant. Only type and html are required; everything else is optional.
| Member | Purpose |
|---|---|
type | The block type discriminant (required) |
html(args) | Inner HTML for the block (required); the Walker handles placement wrapping |
placements | Placements the block accepts: root, column, container, hero (default ['root']) |
plaintext(args) | Plain-text representation for multipart emails |
amp(args) | AMP4Email variant; omit a block from AMP output by returning undefined |
validate(args) | Domain validation; bridged into validateBlocks() automatically |
preflight(args) | Render-time non-fatal warnings |
isEmpty(content) | Lets the Walker skip empty blocks before wrapping |
layout(content) | Section-layout overrides (background, padding, sectionMode) |
applyTheme(content, theme) | Apply theme-derived defaults; block-level values always win |
responsiveCss(args) | Emit per-block responsive CSS (e.g. mobile font size) |
compatibility | { features, properties } read by the Compatibility walker |
createDefault(theme) | Default content for the editor's "add block" affordance |
Composite blocks (columns, container, hero) recurse into their children
via the walk recursion entry passed in args — they never call other
modules directly. Registry helpers are registerBlockModule,
unregisterBlockModule, finalizeBlockRegistry, isBlockRegistryFrozen,
moduleFor(type), and registeredBlockTypes().
Custom Block Registry (legacy)
The registerBlock(type, renderer) path below is the legacy custom-renderer
fallback retained for backward compatibility. New code should implement a
BlockModule and call registerBlockModule() (above) instead — a module owns
HTML, plaintext, AMP, validation, and compatibility in one unit, whereas a
BlockRenderer only produces HTML.
Register custom block renderers for third-party or application-specific block types. Custom blocks are rendered alongside built-in blocks but cannot override them.
Defining a Block Renderer
A BlockRenderer function receives the block content, render context, and the full block object, and returns an HTML string:
import type { BlockRenderer, RenderContext } from '@owlat/email-renderer';
import type { EditorBlock } from '@owlat/shared';
const renderRating: BlockRenderer = (content, ctx, block) => {
const { value, maxValue } = content as { value: number; maxValue: number };
const stars = '★'.repeat(value) + '☆'.repeat(maxValue - value);
return `<tr><td align="center" style="font-size:24px;padding:${ctx.theme.spacingUnit ?? 8}px 0">${stars}</td></tr>`;
};
Registering and Finalizing
import { registerBlock, finalizeRegistry } from '@owlat/email-renderer';
// Register during application setup
registerBlock('rating', renderRating);
// Freeze the registry to prevent runtime mutation
finalizeRegistry();
After finalizeRegistry() is called, any further calls to registerBlock() or unregisterBlock() will throw. Use isRegistryFinalized() to check the freeze state.
Using Custom Blocks
Once registered, custom blocks render like any built-in block:
const blocks: EditorBlock[] = [
{
id: 'block-1',
type: 'rating' as any,
content: { value: 4, maxValue: 5 },
},
];
const html = renderEmailHtml(blocks);
Email Health Score
Compute an overall health score (0–100) across compatibility, accessibility, deliverability, and Outlook-specific support:
import { getEmailHealthScore, renderEmailHtml } from '@owlat/email-renderer';
const html = renderEmailHtml(blocks);
const health = getEmailHealthScore(blocks, html);
console.log(`Score: ${health.overall}/100`);
console.log(`Compatibility: ${health.compatibility}`);
console.log(`Accessibility: ${health.accessibility}`);
console.log(`Deliverability: ${health.deliverability}`);
console.log(`Outlook support: ${health.outlookSupport}`);
for (const rec of health.recommendations) {
console.log(`[${rec.impact}] ${rec.category}: ${rec.message}`);
}
overall is a weighted composite of the four sub-scores. Each recommendation
carries a category of compatibility, accessibility, deliverability, or
outlook, and an impact of high, medium, or low.
Client Simulators
The targetClient render option (see Render Options)
degrades the output as part of the render call. The same degradation transforms
are also exposed standalone so you can post-process any HTML string — for
example to generate a side-by-side preview gallery without re-rendering:
import { simulateClient } from '@owlat/email-renderer';
const gmailView = simulateClient(html, 'gmail'); // strips <style>, etc.
const outlookView = simulateClient(html, 'outlookDesktop');
Built-in simulators ship for gmail, outlookDesktop, outlookNew,
appleMail, and yahooMail (the TargetClient union). Unknown clients pass
the HTML through unchanged.
The registry is extensible. Install or replace a simulator before use; the
helpers live in packages/email-renderer/src/simulators/registry.ts:
import {
registerClientSimulator,
unregisterClientSimulator,
clientSimulators,
} from '@owlat/email-renderer';
import type { ClientSimulator } from '@owlat/email-renderer';
const myGmail: ClientSimulator = (html) => html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
registerClientSimulator('gmail', myGmail); // replaces the built-in
A ClientSimulator is a (html: string) => string transform.
Optimization Suggestions
Get actionable suggestions to reduce email size:
import { suggestOptimizations } from '@owlat/email-renderer';
const suggestions = suggestOptimizations(html);
for (const s of suggestions) {
console.log(`[${s.category}] ${s.description} (save ~${s.estimatedSavings} bytes)`);
}
Suggestion categories include minification (whitespace removal), vml (Outlook conditional blocks), css (style block inlining), and images (unoptimized images).
Exports
// Rendering
export { renderEmailHtml, renderBlockFragment } from './renderer';
export { renderAmpEmail } from './amp';
export { renderPlainText } from './plaintext';
export { inlineCss } from './inliner';
// Analysis & validation
export { analyzeEmail, getEmailHealthScore, suggestOptimizations } from './analyzer';
export { validateBlocks, ValidationError } from './validator';
export { diffEmails } from './diff';
// Block module API (modern extension path)
export {
registerBlockModule, unregisterBlockModule, finalizeBlockRegistry,
isBlockRegistryFrozen, moduleFor, registeredBlockTypes,
} from './blocks/_registry';
export type {
BlockModule, BlockOf, ContentOf, Placement, RenderArgs,
PlainArgs, AmpArgs, ValidateArgs, HtmlWalk, PlaintextWalk,
} from './blocks/_module';
// Legacy custom block registry (HTML-only renderers)
export { registerBlock, unregisterBlock, getRegisteredBlocks, finalizeRegistry, isRegistryFinalized } from './blocks';
export type { BlockRenderer } from './blocks';
// Client simulators
export {
simulateClient, registerClientSimulator,
unregisterClientSimulator, clientSimulators,
} from './simulators';
export type { ClientSimulator } from './simulators';
// Compatibility (per-block data lives in Block modules, surfaced via the walker)
export {
getBlockCompatibility, getPropertyCompatibility, getCriticalProperties,
getHandledFeatures, getClientIssues, getClientPropertyIssues, getAudienceReach,
scoreBlockCompatibility, getBlockLimitationSummary, getSafeBlockConfig,
checkPropertyCompatibility, featuresFor, propertiesFor,
allBlockTypes, allFeatures, allProperties,
} from './compatibility';
export type { BlockTaggedFeature, BlockTaggedProperty, BlockLimitation } from './compatibility';
// Sanitization utilities
export { escapeHtml, escapeAttr, sanitizeUrl, sanitizeCss, sanitizeRawHtml } from './sanitize';
// Types
export type { RenderOptions, RenderContext, TargetClient, ValidationLevel, EmailHealthScore, EmailHealthRecommendation, GmailAnnotations } from './types';
export type { EmailAnalysis, EmailSizeBreakdown, OptimizationSuggestion } from './analyzer';
export type { ValidationIssue, ValidateOptions } from './validator';
export type { EmailDiff, EmailDiffChange } from './diff';
Plugin extension points for compatibility metadata — registerEmailClient,
registerBlockCompatibility, and the emailClients registry — live in
@owlat/shared, not this package.
File Structure
packages/email-renderer/src/
├── index.ts # Public package surface (see Exports)
├── renderer.ts # Main entry: renderEmailHtml, renderBlockFragment
├── boilerplate.ts # HTML document wrapper (DOCTYPE, head, MSO tables)
├── styles.ts # CSS generation (resets, media queries, dark mode)
├── inliner.ts # CSS inlining engine
├── outlook.ts # VML helpers (roundrect, background images)
├── plaintext.ts # Plain text renderer
├── amp.ts # AMP for Email renderer
├── diff.ts # Template diff/change detection
├── analyzer.ts # Post-render email analysis
├── validator.ts # validateBlocks() orchestrator
├── sanitize.ts # HTML/CSS/URL sanitization utilities
├── types.ts # RenderOptions, RenderContext, EmailHealthScore, ...
├── helpers/
│ ├── index.ts
│ ├── table.ts # Table column width helpers
│ ├── gradient.ts # Gradient CSS/VML generation
│ ├── dimensions.ts # Width/height calculation helpers
│ ├── padding.ts # Padding shorthand helpers
│ ├── inline-styles.ts # Inline style string builders
│ ├── linkTransform.ts # Link URL rewriting
│ ├── text.ts # Text/HTML helpers
│ └── validation.ts # Shared validation helpers
├── validators/
│ ├── index.ts
│ └── registry.ts # Block validator registry (bridges module.validate)
├── compatibility/
│ ├── index.ts # getBlockCompatibility, scoreBlockCompatibility, ...
│ ├── scoring.ts # Compatibility/audience-reach scoring
│ ├── ui.ts # Builder-UI limitation summaries & fix suggestions
│ └── walker.ts # Dispatches over registered Block modules
├── simulators/
│ ├── index.ts # simulateClient + registry re-exports
│ ├── registry.ts # registerClientSimulator / clientSimulators
│ ├── gmail.ts
│ ├── outlookDesktop.ts
│ ├── outlookNew.ts
│ ├── appleMail.ts
│ └── yahooMail.ts
├── preview/
│ ├── fixtures.ts # Sample blocks for preview generation
│ ├── generate.ts
│ └── assets/
└── blocks/
├── index.ts # Walker: dispatches by block.type, owns placement wrapping
├── _module.ts # BlockModule<T> interface + arg/walk types
├── _registry.ts # registerBlockModule, moduleFor, finalizeBlockRegistry
├── _builtin-modules.ts # Side-effect registration of all 17 built-in modules
├── text/index.ts # exports textModule
├── image/index.ts # exports imageModule (srcset, dark swap)
├── button/index.ts # exports buttonModule (VML bulletproof)
├── divider/index.ts
├── spacer/index.ts
├── columns/index.ts # stacking, reverse, gap
├── social/index.ts
├── container/index.ts # nesting, VML bg
├── hero/index.ts # VML bg image
├── table/index.ts # rich cells, colSpan/rowSpan
├── accordion/index.ts # CSS-only toggle
├── menu/index.ts # hamburger
├── carousel/index.ts # CSS-only radio nav
├── list/index.ts # table-based
├── progressBar/index.ts
├── rawHtml/index.ts
└── video/index.ts
Each block directory contains an index.ts exporting a <type>Module
(e.g. text/index.ts exports textModule). They are registered as a batch by
_builtin-modules.ts at package load.