Email Renderer
The `@owlat/email-renderer` package converts editor JSON blocks into production-ready HTML emails with cross-client compatibility, CSS inlining, dark mode...
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>>>;
}
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 18 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 ~60% 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 |
countdown | Static countdown timer with optional live image URL for real-time updates |
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%] |
countdown | [Countdown: 2d 5h 30m remaining] or expired text |
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 (
https://) - 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
- Countdown: Missing or invalid target date, missing expired text
- 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) |
warnings | Array of actionable recommendations |
Client Compatibility Data
The @owlat/shared package exports per-block and per-property compatibility data for 12 email clients:
import {
blockCompatibility,
propertyCompatibility,
getBlockCompatibility,
getPropertyCompatibility,
getCriticalProperties,
getClientPropertyIssues,
} from '@owlat/shared';
Per-Block Compatibility
const compat = getBlockCompatibility('accordion');
// {
// blockType: 'accordion',
// description: 'CSS-only toggle (interactive in ~40% of clients)',
// support: { gmail: 'none', outlookDesktop: 'none', appleMail: 'full', ... },
// fallbackBehavior: 'Content shown expanded in all clients',
// }
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). Non-supported blocks fall back to basic HTML within AMP constraints.
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.
Custom Block Registry
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, and deliverability:
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}`);
for (const rec of health.recommendations) {
console.log(`[${rec.impact}] ${rec.category}: ${rec.message}`);
}
Compatibility Analysis
Analyze rendered HTML against Can I Email data for specific email clients:
import { analyzeCompatibility } from '@owlat/email-renderer';
const analysis = analyzeCompatibility(html, customCss, ['gmail', 'outlookDesktop', 'appleMail']);
// analysis.clientIssues — per-client errors and warnings
// analysis.overallScore — 0-100 compatibility score
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, analyzeCompatibility, getEmailHealthScore, suggestOptimizations } from './analyzer';
export { validateBlocks, ValidationError } from './validator';
export { diffEmails } from './diff';
// Custom block registry
export { registerBlock, unregisterBlock, getRegisteredBlocks, finalizeRegistry, isRegistryFinalized } from './blocks';
// Sanitization utilities
export { escapeHtml, escapeAttr, sanitizeUrl, sanitizeCss, sanitizeRawHtml } from './sanitize';
// Types
export type { RenderOptions, RenderContext, TargetClient, ValidationLevel, CanIEmailAnalysis, CanIEmailClientIssues, CanIEmailIssue, EmailHealthScore, EmailHealthRecommendation, GmailAnnotations } from './types';
export type { EmailAnalysis, EmailSizeBreakdown, OptimizationSuggestion } from './analyzer';
export type { ValidationIssue, ValidateOptions } from './validator';
export type { EmailDiff, EmailDiffChange } from './diff';
export type { BlockRenderer } from './blocks';
File Structure
packages/email-renderer/src/
├── 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 # Pre-render block validation
├── sanitize.ts # HTML/CSS/URL sanitization utilities
├── types.ts # RenderOptions, RenderContext
├── helpers/
│ ├── 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
└── blocks/
├── index.ts # Block router
├── text.ts # Text/heading renderer
├── image.ts # Image renderer (srcset, dark swap)
├── button.ts # Button renderer (VML bulletproof)
├── divider.ts # Divider renderer
├── spacer.ts # Spacer renderer
├── columns.ts # Columns renderer (stacking, reverse, gap)
├── social.ts # Social icons renderer
├── container.ts # Container renderer (nesting, VML bg)
├── hero.ts # Hero renderer (VML bg image)
├── table.ts # Table renderer (rich cells, colSpan)
├── accordion.ts # Accordion renderer (CSS-only toggle)
├── menu.ts # Menu renderer (hamburger)
├── carousel.ts # Carousel renderer (CSS-only radio nav)
├── list.ts # List renderer (table-based)
├── progressBar.ts # Progress bar renderer
└── countdown.ts # Countdown timer renderer