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

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>>>;
    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

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 Apple Mail/iOS, ~40% 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

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%]
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 (#)
  • 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
  • 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)
styleBlockSizeBytesCombined size of all <style> block content
exceedsGmailCssLimitWhether the <style> block exceeds Gmail's ~8KB limit (Gmail strips the whole block, breaking responsive layout and dark mode)
cssValidationIssuesBasic CSS syntax problems (unbalanced braces, unclosed strings); omitted when none
warningsArray 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.

MemberPurpose
typeThe block type discriminant (required)
html(args)Inner HTML for the block (required); the Walker handles placement wrapping
placementsPlacements 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)

Prefer Block modules

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.