Three days. That's how long I spent debugging why a welcome email looked perfect in Apple Mail but was completely broken in Outlook. The fix? A single CSS property. display: flex doesn't work in Outlook because Outlook renders emails using Microsoft Word's HTML engine.
That experience led me to build emailens.dev - an email QA platform for developers that shows you exactly what breaks across Gmail, Outlook, Apple Mail, and 9 more clients, and gives you framework-native fix snippets for React Email, MJML, and Maizzle.
The Problem
Email rendering is the last frontier of browser compatibility hell. Except it's worse, because you're not dealing with browsers - you're dealing with email clients, each with their own rendering engine:
| Client | Engine | Year |
|---|---|---|
| Outlook Windows | Microsoft Word | 2007 |
| Gmail | Custom sanitizer | - |
| Apple Mail | WebKit | Modern |
| Yahoo Mail | Custom sanitizer | - |
| Thunderbird | Gecko | Modern |
| Samsung Mail | Custom WebView | - |
Outlook uses Microsoft Word to render HTML emails. Let that sink in. A word processor renders your HTML. This means no flexbox, no grid, no max-width, no border-radius without VML hacks.
Existing tools like Litmus charge $500+/month. For indie developers and bootstrapped founders sending a few transactional emails, that's absurd.
Emailens costs $9/month for 100 previews/day with full API access, or $19/month for unlimited previews with CI/CD and AI-powered fixes.
The Tech Stack
Emailens is a Turborepo monorepo with Bun:
Core
- Next.js 16 with App Router and React 19
- TypeScript in strict mode
- Tailwind CSS v4 with Shadcn/UI
Backend
- Supabase (PostgreSQL) with Drizzle ORM
- Better Auth for authentication (Google + GitHub OAuth)
- Upstash Redis for rate limiting
Infrastructure
- Browserless (self-hosted on Coolify VPS) for Playwright screenshot capture
- Cloudflare R2 for screenshot storage
- Polar.sh for subscription billing
- Resend + React Email for transactional emails
The Secret Sauce
- @emailens/engine - Open-source core engine (251 CSS properties tracked)
- Anthropic SDK - AI-powered email fix generation using Claude
Architecture: The Preview Pipeline
Every preview request goes through an 11-step pipeline. Here's how it works:
Step 1: Input Compilation
Emailens supports 4 input formats. If you're writing React Email JSX, MJML, or Maizzle templates, the system compiles them to HTML first:
let html: string;
if (format === 'jsx') {
html = await compileReactEmail(rawInput);
} else if (format === 'mjml') {
html = await compileMjml(rawInput);
} else if (format === 'maizzle') {
html = await compileMaizzle(rawInput);
} else {
html = rawInput;
}Step 2: Session-Based DOM Parsing
The engine parses the HTML once and shares the DOM across all analysis methods. This eliminates redundant parsing when running multiple checks on the same email:
function createSession(html: string, options?: { framework?: string }): EmailSession {
const $ = cheerio.load(html); // Single parse
return {
analyze() { return analyzeEmailFromDom($, options?.framework); },
score(warnings) { return generateCompatibilityScore(warnings); },
analyzeSpam() { return analyzeSpamFromDom($); },
validateLinks() { return validateLinksFromDom($); },
checkAccessibility() { return checkAccessibilityFromDom($); },
// Transforms create isolated copies (they mutate the DOM)
transformForClient(clientId) { return transformForClient(html, clientId); },
transformForAllClients() { return transformForAllClients(html); },
};
}All read-only operations share the single Cheerio DOM. Transform operations create isolated copies because they mutate the HTML per-client.
Step 3: CSS Compatibility Analysis
The core of Emailens - scanning every CSS property against a 251-feature support matrix:
function analyzeEmailFromDom($: CheerioAPI, framework?: string): CSSWarning[] {
const warnings: CSSWarning[] = [];
// 1. HTML element detection (style, link, svg, form, video, etc.)
for (const feature of HTML_ELEMENT_FEATURES) {
const selector = HTML_ELEMENT_SELECTORS[feature];
if ($(selector).length === 0) continue;
for (const client of EMAIL_CLIENTS) {
if (CSS_SUPPORT[feature]?.[client.id] === 'unsupported') {
warnings.push({ property: feature, client: client.id, severity: 'error', ... });
}
}
}
// 2. Parse <style> blocks with css-tree
$('style').each((_, el) => {
const ast = csstree.parse($(el).text());
csstree.walk(ast, {
visit: 'Declaration',
enter(node) {
checkPropertySupport(node.property, warnings);
}
});
});
// 3. Scan inline styles
$('[style]').each((_, el) => {
const style = $(el).attr('style') || '';
parseInlineStyles(style, warnings);
});
// 4. At-rules (@media, @import, @font-face)
// 5. Pseudo-classes and pseudo-elements
// 6. CSS functions (calc, var, clamp)
return warnings;
}The 251-feature support matrix is synced from caniemail.com data. Each feature has a support level per client: supported, partial, unsupported, or unknown.
Step 4: Scoring
Each client gets a 0-100 compatibility score based on the issues found:
function generateCompatibilityScore(warnings: CSSWarning[]): Record<string, ClientScore> {
const result: Record<string, ClientScore> = {};
for (const client of EMAIL_CLIENTS) {
const clientWarnings = warnings.filter(w => w.client === client.id);
const errors = clientWarnings.filter(w => w.severity === 'error').length;
const warns = clientWarnings.filter(w => w.severity === 'warning').length;
const info = clientWarnings.filter(w => w.severity === 'info').length;
// Each error costs 15 points, warning costs 5, info costs 1
const score = Math.max(0, Math.min(100,
100 - errors * 15 - warns * 5 - info * 1
));
result[client.id] = { score, errors, warnings: warns, info };
}
return result;
}The overall score is the average across all 12 clients. An email scoring 95+ across all clients is production-ready.
Step 5: Client-Specific Transforms
For each of the 12 clients, the engine transforms the HTML to simulate what that client's rendering engine would produce. Gmail strips <style> blocks. Outlook ignores flexbox. Yahoo strips class names. Each transform mimics these behaviors:
function transformForClient(html: string, clientId: string): TransformResult {
const $ = cheerio.load(html);
switch (clientId) {
case 'gmail-web':
// Gmail strips <style> blocks, only inline styles survive
$('style').remove();
$('link[rel="stylesheet"]').remove();
break;
case 'outlook-windows':
// Outlook uses Word's HTML engine
// Remove unsupported properties
$('[style]').each((_, el) => {
const style = $(el).attr('style') || '';
$(el).attr('style', stripUnsupportedOutlookCSS(style));
});
break;
// ... 10 more clients
}
return { clientId, html: $.html() };
}Step 6: Dark Mode Simulation
Each email client handles dark mode differently. The simulator mimics client-specific behavior:
function simulateDarkMode(html: string, clientId: string) {
const $ = cheerio.load(html);
const warnings: CSSWarning[] = [];
// Detect transparent PNGs/SVGs that may disappear
$('img').each((_, el) => {
const src = $(el).attr('src') || '';
if (src.endsWith('.png') || src.endsWith('.svg')) {
warnings.push({
severity: 'warning',
message: 'Image with transparent background may disappear in dark mode.',
});
}
});
switch (clientId) {
case 'gmail-ios':
// Full color inversion
applyColorInversion($, 'full');
break;
case 'outlook-windows':
// Full inversion with [data-ogsc]/[data-ogsb] overrides
applyColorInversion($, 'full');
break;
case 'apple-mail-macos':
// Respects prefers-color-scheme if present
if (!html.includes('prefers-color-scheme')) {
applyColorInversion($, 'partial');
warnings.push({
severity: 'info',
message: 'Add @media (prefers-color-scheme: dark) for best dark mode support.',
});
}
break;
}
return { html: $.html(), warnings };
}The color inversion uses luminance thresholds: light colors (luminance > 0.7) flip to dark, dark colors (luminance < 0.15) flip to light. This matches how clients like Gmail iOS and Outlook actually invert email colors.
Steps 7-11: Quality Analysis & Screenshots
Every preview also runs:
- Spam scoring: 45+ heuristic rules modeled after SpamAssassin
- Link validation: Broken hrefs, insecure HTTP, javascript: URLs, deceptive links
- Accessibility audit: WCAG contrast ratios, alt text, heading hierarchy
- Image analysis: Missing dimensions, oversized data URIs, tracking pixels
- Screenshot capture: Playwright on Browserless captures actual rendered output for each client
Screenshots are captured with bounded concurrency (3 at a time) to avoid overloading the Browserless instance:
async function captureScreenshots(previewId: string, variants: ScreenshotVariant[]) {
const browser = await connectBrowser(); // CDP to Browserless
const CONCURRENCY = 3;
for (let i = 0; i < variants.length; i += CONCURRENCY) {
const chunk = variants.slice(i, i + CONCURRENCY);
await Promise.allSettled(chunk.map(async (variant) => {
const context = await browser.newContext({
viewport: { width: 680, height: 900 },
colorScheme: variant.mode,
});
const page = await context.newPage();
await page.setContent(variant.html, { waitUntil: 'domcontentloaded' });
const buffer = await page.screenshot({ type: 'png', fullPage: false });
await uploadToR2(`${previewId}/${variant.clientId}_${variant.mode}.png`, buffer);
}));
}
}AI-Powered Fixes
The most valuable feature for users: when Emailens detects issues, it can generate framework-native fix code using Claude.
Fix Classification
Not all issues need AI. The engine classifies each warning:
- CSS-only (
fixType: "css"): Simple property swaps or fallbacks. Generated from static fix snippets. - Structural (
fixType: "structural"): Requires HTML restructuring. Needs AI.
For example:
display: flexin Outlook → structural (needs conversion to<table>layout)border-radiusin Outlook → structural (needs VML<v:roundrect>)max-widthin Gmail → CSS-only (addwidth: 100%fallback)
Token Estimation Before API Calls
AI calls cost money. Before sending anything to Claude, the system estimates tokens:
const CHARS_PER_TOKEN = 3.5;
const OUTPUT_RATIO = 1.3;
function estimateAiFixTokens(options: EstimateOptions): TokenEstimate {
const prompt = generateFixPrompt(options);
let promptTokens = Math.ceil(prompt.length / CHARS_PER_TOKEN);
// Smart truncation: remove info-severity first, then CSS-only duplicates
if (promptTokens > options.maxInputTokens) {
options.warnings = intelligentTruncate(options.warnings, options.maxInputTokens);
}
return {
inputTokens: promptTokens,
estimatedOutputTokens: Math.ceil(options.originalHtml.length * OUTPUT_RATIO / CHARS_PER_TOKEN),
truncated: options.warnings.length < originalCount,
};
}The intelligent truncation prioritizes: keep all structural warnings (they're the most impactful), then errors, then warnings, then info. If the prompt still exceeds the token budget, deduplicate CSS-only warnings that affect the same property across multiple clients.
The Fix Generation
const result = await generateAiFix({
originalHtml: html,
warnings,
scores,
format: 'jsx', // or 'mjml', 'maizzle', 'html'
provider: async (prompt) => {
const msg = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 8192,
system: AI_FIX_SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
});
return msg.content.find(b => b.type === 'text')?.text || '';
},
});The system prompt instructs Claude to:
- Return only the fixed code in a code fence
- Preserve all existing content and visual design
- Convert
display:flex/gridto<table>layouts - Use VML
<v:roundrect>for border-radius in Outlook - Use
<!--[if mso]>conditionals for Outlook-specific fixes - Apply ALL fixes, not just the first one found
The fix output is format-aware - if you're writing React Email JSX, the fix comes back in JSX syntax. If you're using MJML, you get MJML elements.
Graceful Degradation
The preview pipeline is designed so no single sub-system failure kills the entire response:
// Screenshots fail? Return results without them
let screenshots = {};
try {
screenshots = await captureScreenshots(previewId, variants);
} catch { /* continue */ }
// Dark mode fails for one client? Skip it
for (const t of transforms) {
try {
darkMode[t.clientId] = simulateDarkMode(t.html, t.clientId);
} catch { /* skip this client */ }
}
// Database write fails? Return API response anyway
try {
await db.insert(previews).values({ ... });
} catch { /* non-fatal */ }If Browserless is down, users still get CSS analysis, scores, and quality reports - just no screenshots. If the database is slow, the response returns immediately and the persist happens in the background. The only hard requirement is the core engine analysis.
Usage Tracking & Billing
Daily usage is tracked atomically with a composite primary key to prevent race conditions:
// Schema: primary key is (userId, date)
const usage = pgTable('usage', {
userId: text('user_id').notNull(),
date: date('date').notNull(),
previewCount: integer('preview_count').notNull().default(0),
}, (t) => [primaryKey({ columns: [t.userId, t.date] })]);
// Atomic increment with plan limit check
async function incrementUsageAtomic(userId: string, limit: number) {
const today = new Date().toISOString().split('T')[0];
const result = await db
.insert(usage)
.values({ userId, date: today, previewCount: 1 })
.onConflictDoUpdate({
target: [usage.userId, usage.date],
set: { previewCount: sql`${usage.previewCount} + 1` },
})
.returning({ newCount: usage.previewCount });
return { allowed: result[0].newCount <= limit, newCount: result[0].newCount };
}At 80% of the daily limit, users get a warning email. At 100%, the API returns a 429 with upgrade information.
What I Learned
1. CSS compatibility data is a moat. The 251-feature support matrix took weeks to compile and verify. It's the foundation everything else builds on. Syncing from caniemail.com data and running validation tests against real email clients gave me confidence in the analysis results.
2. Self-hosting Browserless saves serious money. Running Browserless on a Coolify VPS costs ~$20/month. The managed service would cost 10x that at the volume I need for screenshots.
3. AI fixes need guardrails. Without token estimation and smart truncation, a complex email could generate a $5 API call. Capping input at 32,000 tokens and truncating low-severity warnings first keeps costs predictable while preserving the most impactful fixes.
4. Graceful degradation is non-negotiable for dev tools. Developers hate flaky tools. If one sub-system fails, the rest of the pipeline should still return useful data. Every optional step is wrapped in try/catch.
Try It
If you're building emails with React Email, MJML, or raw HTML, give emailens.dev a try. The free tier gives you 30 previews/day across all 12 clients.
The core engine, MCP server, and CLI are all open source - you can integrate email QA directly into your build pipeline.
Got questions about email rendering or want to chat about the architecture? Find me on X or email [email protected].