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():

OptionTypeDefaultDescription
themeEmailThemeSee belowDesign tokens (colors, fonts, spacing)
darkModebooleanfalseEnable dark mode preview rendering
preheaderTextstring''Hidden inbox preview text
titlestring''Document title (browser tab)
baseWidthnumber600Content width in px (500-700 typical)
breakpointnumber480Mobile responsive breakpoint in px
direction'ltr' | 'rtl''ltr'Text direction for RTL languages
langstring'en'HTML lang attribute
inlineCssbooleantrueInline CSS onto elements for Gmail/Yahoo
minifybooleanfalseMinify output HTML (preserves MSO comments)
variableTypeVariableType'personalization'Variable rendering mode
variableValuesRecord<string, string>{}Values for conditional content evaluation
fontUrlsstring[][]Web font URLs to import
customCssstring''Custom CSS injected into the style block
linkTransformFunctionTransform all link URLs (UTM, click tracking)
onWarningFunctionCallback for non-fatal render warnings
targetClientTargetClientSimulate rendering for a specific email client (gmail, outlookDesktop, outlookNew, appleMail, yahooMail)
validationLevelValidationLevel'soft'Validation strictness: skip (none), soft (warn), strict (throw)
gmailAnnotationsGmailAnnotationsGmail 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

TypeDescription
textRich text (paragraph, h1, h2, h3) with font size, color, alignment, line height
imageResponsive image with alt text, link, srcset/retina, dark mode image swap
buttonCTA with VML bulletproof rendering for Outlook, configurable width (px/%)
videoThumbnail with SVG play button overlay, links to video URL
rawHtmlRaw HTML injection (use with care)

Layout Blocks

TypeDescription
columns1-4 column layouts with ratio presets, per-column styling, gap, non-stacking, reverse mobile order
containerNested block grouping with background, border, border-radius
heroBackground image section with VML Outlook fallback, overlay, vertical alignment
dividerHorizontal rule with color, thickness, width, style
spacerVertical spacing

Interactive Blocks

TypeDescription
accordionCSS-only expandable sections (interactive in ~60% of clients, expanded fallback elsewhere)
menuHorizontal navigation with CSS-only hamburger toggle on mobile
carouselCSS-only image slideshow with navigation dots (interactive in Apple Mail/iOS, shows first image elsewhere)

Data Blocks

TypeDescription
tableData table with rich cells, column widths, colSpan/rowSpan, header, footer, caption, striped rows
socialSocial media icon links (filled/outline style) with text-initial fallback
listTable-based list (bullet, numbered, check, icon) — avoids <ul>/<ol> client inconsistencies
progressBarVisual progress bar with label, fully table-based
countdownStatic 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 (#93c5fd in dark mode)
  • Image opacity reduction (opacity: 0.9)
  • Dark mode image swap: Set darkSrc on 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 darkOverrides with custom backgroundColor and textColor values, applied via CSS custom properties

Client Support

ClientDark Mode Support
Apple Mail / iOSFull (media query)
Outlook.comFull (media query)
Gmail (mobile)Partial (forces own dark mode)
Outlook DesktopNone
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.

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:

BlockPlain Text Output
textStripped HTML with link extraction
button[Button Text] (URL)
image[Image: alt text] or [Image: alt text] (link)
divider--- separator
tablePipe-delimited rows with header/footer
socialPlatform 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, missing alt text, 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 src or alt
  • 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 captionText for 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

MetricDescription
htmlSizeBytesTotal HTML size
exceedsGmailClipWhether email exceeds Gmail's 102KB clipping threshold
tableNestingDepthMaximum table nesting (>10 may cause rendering issues)
imageCountTotal images (>20 triggers warning)
linkCountTotal links (>60 triggers spam filter warning)
hasTextContentWhether email has meaningful text (not image-only)
textToImageRatioCharacters per image (higher is better for deliverability)
displayNoneCountHidden elements beyond preheader (excessive = spam signal)
warningsArray 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