
Email Template Builder
- 88 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Email Template Builder is a Claude skill that builds transactional email template systems with React Email or MJML plus a multi-provider send abstraction for Resend, SendGrid, Postmark, and SES.
About
Email Template Builder builds transactional email template systems with React Email or MJML. It covers component-based responsive layouts, dark mode, i18n, a multi-provider send abstraction (Resend, SendGrid, Postmark, SES), spam-score optimization, local preview servers, and UTM tracking. A developer uses it when setting up transactional email infrastructure, building an email design system, or debugging deliverability. For writing the copy and sequences it points to email-sequence.
- Builds component-based transactional email templates with React Email or MJML, including dark mode and i18n
- Provides a multi-provider send abstraction for Resend, SendGrid, Postmark, and AWS SES
- Covers responsive layouts, spam-score optimization, preview servers, and UTM tracking
Email Template Builder by the numbers
- 88 all-time installs (skills.sh)
- Ranked #3,029 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
email-template-builder capabilities & compatibility
Free skill; requires your own email-provider API key (Resend, SendGrid, Postmark, or SES).
- Capabilities
- email sequence
- Works with
- aws
- Use cases
- email · frontend · api development
- Pricing
- Bring your own API key
What email-template-builder says it does
Build production-grade email template systems with React Email or MJML.
Recommendation:** React Email for TypeScript teams shipping SaaS. MJML for marketing teams needing maximum compatibility across Outlook, Gmail, Apple Mail, and legacy clients.
This skill builds the email rendering and sending infrastructure. For writing email copy and designing sequences, use email-sequence.
npx skills add https://github.com/borghei/claude-skills --skill email-template-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 88 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Build transactional email template systems and multi-provider sending infrastructure with React Email or MJML.
Who is it for?
Developers standing up transactional email infrastructure or an email design system with a provider-agnostic send layer.
Skip if: Writing email copy or designing lifecycle sequences - use email-sequence for that; this skill builds the rendering and sending code.
When should I use this skill?
Setting up transactional email infrastructure, building an email design system, or debugging deliverability issues.
What you get
Production-ready component-based email templates with a unified send function across Resend, SendGrid, Postmark, and SES.
- React Email or MJML template system
- multi-provider send abstraction
- preview server and i18n strings
By the numbers
- 4 supported email providers (Resend, SendGrid, Postmark, SES)
- 2 template frameworks (React Email, MJML)
Files
Email Template Builder
Tier: POWERFUL Category: Engineering / Marketing Tags: email templates, React Email, MJML, responsive email, deliverability, transactional email, dark mode
Overview
Build complete transactional email systems: component-based templates with React Email or MJML, multi-provider sending abstraction, local preview with hot reload, i18n support, dark mode, spam optimization, and UTM tracking. Outputs production-ready code for any major email provider.
This skill builds the email rendering and sending infrastructure. For writing email copy and designing sequences, use email-sequence.
---
Architecture Decision: React Email vs MJML
| Factor | React Email | MJML |
|---|---|---|
| Component reuse | Full React component model | Partial (mj-attributes) |
| TypeScript | Native | Requires build step |
| Preview server | Built-in (email dev) | Requires separate setup |
| Email client compatibility | Good (renders to tables) | Excellent (battle-tested) |
| Dark mode | CSS media queries | CSS media queries |
| Learning curve | Low (if you know React) | Low (HTML-like syntax) |
| Best for | Teams already using React | Maximum email client compat |
Recommendation: React Email for TypeScript teams shipping SaaS. MJML for marketing teams needing maximum compatibility across Outlook, Gmail, Apple Mail, and legacy clients.
---
Project Structure
emails/
├── components/
│ ├── layout/
│ │ ├── base-layout.tsx # Shared wrapper: header, footer, styles
│ │ ├── button.tsx # CTA button component
│ │ └── divider.tsx # Styled horizontal rule
│ ├── blocks/
│ │ ├── hero.tsx # Hero section with heading + text
│ │ ├── feature-row.tsx # Icon + text feature highlight
│ │ ├── testimonial.tsx # Quote + attribution
│ │ └── pricing-table.tsx # Plan comparison
├── templates/
│ ├── welcome.tsx # Welcome / confirm email
│ ├── password-reset.tsx # Password reset link
│ ├── invoice.tsx # Payment receipt / invoice
│ ├── trial-expiring.tsx # Trial expiration warning
│ ├── weekly-digest.tsx # Activity summary
│ └── team-invite.tsx # Team invitation
├── lib/
│ ├── send.ts # Unified send function
│ ├── providers/
│ │ ├── resend.ts # Resend adapter
│ │ ├── sendgrid.ts # SendGrid adapter
│ │ ├── postmark.ts # Postmark adapter
│ │ └── ses.ts # AWS SES adapter
│ ├── tracking.ts # UTM parameter injection
│ └── render.ts # Template rendering
├── i18n/
│ ├── en.ts # English strings
│ ├── de.ts # German strings
│ └── types.ts # Typed translation keys
└── package.json---
Base Layout Component
// emails/components/layout/base-layout.tsx
import {
Body, Container, Head, Html, Img, Preview,
Section, Text, Hr, Font
} from "@react-email/components";
interface BaseLayoutProps {
preview: string;
locale?: string;
children: React.ReactNode;
}
export function BaseLayout({ preview, locale = "en", children }: BaseLayoutProps) {
return (
<Html lang={locale}>
<Head>
<Font
fontFamily="Inter"
fallbackFontFamily="Arial"
webFont={{
url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2",
format: "woff2",
}}
fontWeight={400}
fontStyle="normal"
/>
<style>{`
@media (prefers-color-scheme: dark) {
.email-body { background-color: #111827 !important; }
.email-container { background-color: #1f2937 !important; }
.email-text { color: #e5e7eb !important; }
.email-heading { color: #f9fafb !important; }
.email-muted { color: #9ca3af !important; }
}
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; padding: 16px !important; }
}
`}</style>
</Head>
<Preview>{preview}</Preview>
<Body className="email-body" style={body}>
<Container className="email-container" style={container}>
<Section style={header}>
<Img
src={`${process.env.ASSET_URL}/logo.png`}
width={120} height={36} alt="[Product]"
/>
</Section>
<Section style={content}>{children}</Section>
<Hr className="email-muted" style={divider} />
<Section style={footer}>
<Text className="email-muted" style={footerText}>
[Company] Inc. - [Address]
</Text>
<Text className="email-muted" style={footerText}>
<a href="{{unsubscribe_url}}" style={link}>Unsubscribe</a>
{" | "}
<a href="{{preferences_url}}" style={link}>Email Preferences</a>
{" | "}
<a href="{{privacy_url}}" style={link}>Privacy Policy</a>
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
// Styles (inline for email client compatibility)
const body = { backgroundColor: "#f3f4f6", fontFamily: "Inter, Arial, sans-serif", margin: 0, padding: "40px 0" };
const container = { maxWidth: "600px", margin: "0 auto", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden" };
const header = { padding: "24px 32px", borderBottom: "1px solid #e5e7eb" };
const content = { padding: "32px" };
const divider = { borderColor: "#e5e7eb", margin: "0 32px" };
const footer = { padding: "24px 32px" };
const footerText = { fontSize: "12px", color: "#6b7280", textAlign: "center" as const, margin: "4px 0", lineHeight: "1.6" };
const link = { color: "#6b7280", textDecoration: "underline" };---
Template Examples
Welcome Email
// emails/templates/welcome.tsx
import { Button, Heading, Text } from "@react-email/components";
import { BaseLayout } from "../components/layout/base-layout";
interface WelcomeProps {
name: string;
confirmUrl: string;
trialDays?: number;
}
export default function Welcome({ name, confirmUrl, trialDays = 14 }: WelcomeProps) {
return (
<BaseLayout preview={`Welcome, ${name}! Confirm your email to get started.`}>
<Heading className="email-heading" style={h1}>
Welcome to [Product], {name}
</Heading>
<Text className="email-text" style={text}>
You have {trialDays} days to explore everything -- no credit card required.
Confirm your email to activate your account:
</Text>
<Button href={confirmUrl} style={button}>
Confirm Email Address
</Button>
<Text className="email-muted" style={muted}>
Button not working? Paste this link in your browser:{" "}
<a href={confirmUrl} style={linkStyle}>{confirmUrl}</a>
</Text>
</BaseLayout>
);
}
const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px", lineHeight: "1.3" };
const text = { fontSize: "16px", lineHeight: "1.6", color: "#374151", margin: "0 0 24px" };
const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", fontSize: "16px", fontWeight: "600", padding: "12px 24px", textDecoration: "none", display: "inline-block" };
const muted = { fontSize: "13px", color: "#6b7280", marginTop: "24px", lineHeight: "1.5" };
const linkStyle = { color: "#4f46e5", wordBreak: "break-all" as const };Invoice Email
// emails/templates/invoice.tsx
import { Row, Column, Section, Heading, Text, Hr, Button } from "@react-email/components";
import { BaseLayout } from "../components/layout/base-layout";
interface LineItem { description: string; amount: number; }
interface InvoiceProps {
name: string;
invoiceNumber: string;
date: string;
dueDate: string;
items: LineItem[];
total: number;
currency?: string;
downloadUrl: string;
}
export default function Invoice({
name, invoiceNumber, date, dueDate, items,
total, currency = "USD", downloadUrl,
}: InvoiceProps) {
const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency });
return (
<BaseLayout preview={`Invoice ${invoiceNumber} -- ${fmt.format(total / 100)}`}>
<Heading className="email-heading" style={h1}>
Invoice #{invoiceNumber}
</Heading>
<Text className="email-text" style={text}>Hi {name},</Text>
<Text className="email-text" style={text}>
Here is your invoice. Thank you for your business.
</Text>
{/* Meta row */}
<Section style={metaBox}>
<Row>
<Column>
<Text style={metaLabel}>Invoice Date</Text>
<Text style={metaValue}>{date}</Text>
</Column>
<Column>
<Text style={metaLabel}>Due Date</Text>
<Text style={metaValue}>{dueDate}</Text>
</Column>
<Column>
<Text style={metaLabel}>Amount Due</Text>
<Text style={metaValueBold}>{fmt.format(total / 100)}</Text>
</Column>
</Row>
</Section>
{/* Line items */}
{items.map((item, i) => (
<Row key={i} style={i % 2 === 0 ? rowEven : rowOdd}>
<Column><Text style={cell}>{item.description}</Text></Column>
<Column><Text style={cellRight}>{fmt.format(item.amount / 100)}</Text></Column>
</Row>
))}
<Hr style={divider} />
<Row>
<Column><Text style={totalLabel}>Total</Text></Column>
<Column><Text style={totalValue}>{fmt.format(total / 100)}</Text></Column>
</Row>
<Button href={downloadUrl} style={button}>
Download PDF
</Button>
</BaseLayout>
);
}
const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px" };
const text = { fontSize: "15px", lineHeight: "1.6", color: "#374151", margin: "0 0 12px" };
const metaBox = { backgroundColor: "#f9fafb", borderRadius: "8px", padding: "16px", margin: "16px 0" };
const metaLabel = { fontSize: "11px", color: "#6b7280", fontWeight: "600", textTransform: "uppercase" as const, margin: "0 0 4px", letterSpacing: "0.05em" };
const metaValue = { fontSize: "14px", color: "#111827", margin: "0" };
const metaValueBold = { fontSize: "18px", fontWeight: "700", color: "#4f46e5", margin: "0" };
const rowEven = { backgroundColor: "#ffffff" };
const rowOdd = { backgroundColor: "#f9fafb" };
const cell = { fontSize: "14px", color: "#374151", padding: "10px 12px" };
const cellRight = { ...cell, textAlign: "right" as const };
const divider = { borderColor: "#e5e7eb", margin: "8px 0" };
const totalLabel = { fontSize: "16px", fontWeight: "700", color: "#111827", padding: "8px 12px" };
const totalValue = { ...totalLabel, textAlign: "right" as const };
const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", padding: "12px 24px", fontSize: "15px", fontWeight: "600", textDecoration: "none", display: "inline-block", marginTop: "16px" };---
Multi-Provider Send Abstraction
// emails/lib/send.ts
import { render } from "@react-email/render";
interface EmailPayload {
to: string;
subject: string;
template: React.ReactElement;
tags?: Record<string, string>;
}
interface EmailProvider {
send(payload: { to: string; subject: string; html: string; text: string; tags?: Record<string, string> }): Promise<{ id: string }>;
}
// Provider factory
function getProvider(): EmailProvider {
const provider = process.env.EMAIL_PROVIDER || "resend";
switch (provider) {
case "resend": return require("./providers/resend").default;
case "sendgrid": return require("./providers/sendgrid").default;
case "postmark": return require("./providers/postmark").default;
case "ses": return require("./providers/ses").default;
default: throw new Error(`Unknown email provider: ${provider}`);
}
}
export async function sendEmail(payload: EmailPayload) {
const html = addTracking(render(payload.template), { campaign: payload.tags?.type || "transactional" });
const text = render(payload.template, { plainText: true });
return getProvider().send({
to: payload.to,
subject: payload.subject,
html,
text,
tags: payload.tags,
});
}---
UTM Tracking Injection
// emails/lib/tracking.ts
interface TrackingConfig {
campaign: string;
source?: string;
medium?: string;
}
export function addTracking(html: string, config: TrackingConfig): string {
const params = new URLSearchParams({
utm_source: config.source || "email",
utm_medium: config.medium || "transactional",
utm_campaign: config.campaign,
}).toString();
// Add UTM to all internal links (skip unsubscribe and external)
return html.replace(
/href="(https?:\/\/(?:www\.)?yourdomain\.com[^"]*?)"/g,
(match, url) => {
const sep = url.includes("?") ? "&" : "?";
return `href="${url}${sep}${params}"`;
}
);
}---
i18n System
// emails/i18n/types.ts
export interface EmailStrings {
welcome: {
preview: (name: string) => string;
heading: (name: string) => string;
body: (days: number) => string;
cta: string;
fallbackLink: string;
};
invoice: {
preview: (number: string, amount: string) => string;
heading: (number: string) => string;
greeting: (name: string) => string;
downloadCta: string;
};
common: {
unsubscribe: string;
preferences: string;
privacy: string;
};
}
// emails/i18n/en.ts
import type { EmailStrings } from "./types";
export const en: EmailStrings = {
welcome: {
preview: (name) => `Welcome, ${name}! Confirm your email to get started.`,
heading: (name) => `Welcome to [Product], ${name}`,
body: (days) => `You have ${days} days to explore everything -- no credit card required.`,
cta: "Confirm Email Address",
fallbackLink: "Button not working? Paste this link in your browser:",
},
// ... other templates
};
// emails/i18n/de.ts
import type { EmailStrings } from "./types";
export const de: EmailStrings = {
welcome: {
preview: (name) => `Willkommen, ${name}! Bestaetigen Sie Ihre E-Mail.`,
heading: (name) => `Willkommen bei [Product], ${name}`,
body: (days) => `Sie haben ${days} Tage Zeit, alles zu erkunden -- keine Kreditkarte noetig.`,
cta: "E-Mail-Adresse bestaetigen",
fallbackLink: "Button funktioniert nicht? Fuegen Sie diesen Link in Ihren Browser ein:",
},
// ... other templates
};---
Deliverability Checklist
DNS Records (Required)
- [ ] SPF:
v=spf1 include:_spf.provider.com ~allon sending domain - [ ] DKIM: Provider-specific CNAME records configured
- [ ] DMARC:
v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com - [ ] Return-Path: Matches sending domain (not provider default)
Content Rules
- [ ] Sender uses own domain (not
@gmail.com) - [ ] Subject under 50 characters, no ALL CAPS, no spam triggers
- [ ] Text-to-image ratio: minimum 60% text
- [ ] Plain text version included alongside HTML
- [ ] Unsubscribe link in every email (CAN-SPAM, GDPR, one-click)
- [ ] Physical mailing address in footer (CAN-SPAM requirement)
- [ ] No URL shorteners (use full branded links)
- [ ] Single primary CTA per email
- [ ] All images have alt text
- [ ] HTML validates (no broken/unclosed tags)
Infrastructure
- [ ] Separate sending domains for transactional vs marketing
- [ ] Warm up new sending domains gradually (start with 50/day, increase 2x weekly)
- [ ] Monitor bounce rates (<2% hard bounces)
- [ ] Process bounces and complaints automatically
- [ ] Test with Mail-Tester.com before production sends (target: 9+/10)
---
Email Client Compatibility
Known Quirks
| Client | Quirk | Workaround |
|---|---|---|
| Outlook (Windows) | No CSS grid/flexbox, ignores margin on images | Use <table> layout (React Email handles this) |
| Gmail | Strips <head> styles, limits CSS | Inline all styles (React Email handles this) |
| Apple Mail | Best support, renders dark mode well | Standard approach works |
| Yahoo Mail | Limited CSS support | Avoid advanced selectors |
| Outlook.com | Strips background images | Use background-color as fallback |
Testing Matrix
Test every template on these clients before production:
| Priority | Client | Method |
|---|---|---|
| Critical | Gmail (web) | Send test email |
| Critical | Apple Mail (iOS) | Send test email |
| Critical | Outlook (Windows, latest) | Litmus or Email on Acid |
| High | Outlook.com (web) | Send test email |
| High | Gmail (Android) | Send test email |
| Medium | Yahoo Mail | Litmus |
| Medium | Outlook (Mac) | Send test email |
---
Dev Workflow
# Start preview server with hot reload
npx email dev --dir emails/templates --port 3001
# Export to static HTML (for testing with Litmus/Email on Acid)
npx email export --dir emails/templates --outDir emails/dist
# Send test email
npx tsx emails/lib/send-test.ts --template welcome --to test@example.com
# Validate HTML
npx email lint --dir emails/templates---
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Using CSS grid/flexbox | Layout breaks in Outlook | Use Row/Column from React Email (renders to tables) |
| Container wider than 600px | Breaks on Gmail mobile | Max-width: 600px on container |
| Missing plain text version | Lower deliverability score | Always generate plain text with render(template, { plainText: true }) |
| Same domain for transactional + marketing | Marketing complaints tank transactional delivery | Separate sending domains/subdomains |
| Skipping email warm-up | Emails go to spam | Start low, increase gradually over 2-4 weeks |
| Dark mode ignoring | Unreadable emails for 30%+ of users | Add prefers-color-scheme: dark media queries with !important |
---
Related Skills
| Skill | Use When |
|---|---|
| email-sequence | Writing email copy and designing automation flows |
| analytics-tracking | Setting up email engagement tracking and attribution |
| launch-strategy | Coordinating email templates for product launches |
---
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Email clipped in Gmail | HTML over 102KB | Run render_size_analyzer.py. Remove comments, minify, replace base64 images. |
| Layout broken in Outlook | CSS flexbox/grid used | Use table-based layout. Run template_validator.py for compatibility check. |
| Styles stripped in Gmail | Styles in <head> only | Inline all CSS. React Email handles this automatically. |
| Unreadable in dark mode | No dark mode CSS | Add prefers-color-scheme: dark media queries with !important. |
| Low deliverability score | Missing unsubscribe, heavy images | Run spam_score_checker.py. Add RFC 8058 one-click unsubscribe headers. |
| Images not loading | Blocked by email client defaults | Add descriptive alt text. Maintain 60%+ text-to-image ratio. |
| Template renders differently across clients | Unsupported CSS properties | Test on Gmail, Apple Mail, Outlook (Windows) before production sends. |
---
Success Criteria
- Spam score of 9+/10 on mail-tester.com before production sends
- Template renders correctly on Gmail, Apple Mail, and Outlook (Windows)
- HTML under 80KB (well under Gmail's 102KB clip threshold)
- Text-to-image ratio above 60%
- Dark mode tested and readable for 30%+ of users
- All images have alt text and explicit width/height dimensions
- One-click unsubscribe (RFC 8058) implemented in all templates
- Separate sending domains for transactional vs. marketing email
---
Scope & Limitations
In Scope: Email HTML/CSS template engineering, React Email and MJML components, multi-provider sending abstraction, i18n, dark mode, deliverability infrastructure, spam score optimization.
Out of Scope: Email copy/sequence writing (use email-sequence), marketing automation workflows, email list management, A/B test statistical analysis.
---
Python Automation Tools
1. Spam Score Checker (scripts/spam_score_checker.py)
Analyzes email HTML for spam risk: text-to-image ratio, link density, spam words, unsubscribe presence, HTML structure.
python scripts/spam_score_checker.py template.html
python scripts/spam_score_checker.py template.html --json2. Template Validator (scripts/template_validator.py)
Validates email templates for client compatibility (Outlook, Gmail), accessibility, responsive design, and inline styles.
python scripts/template_validator.py template.html
python scripts/template_validator.py template.html --json3. Render Size Analyzer (scripts/render_size_analyzer.py)
Analyzes template file size, estimates render weight, and checks against Gmail's 102KB clip threshold with detailed breakdown.
python scripts/render_size_analyzer.py template.html
python scripts/render_size_analyzer.py --dir templates/ --json#!/usr/bin/env python3
"""
Email Render Size Analyzer
Analyzes email template file size, estimates render weight,
and checks against Gmail's 102KB clip threshold.
Usage:
python render_size_analyzer.py template.html
python render_size_analyzer.py template.html --json
python render_size_analyzer.py --dir templates/
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
GMAIL_CLIP_THRESHOLD_KB = 102
WARNING_THRESHOLD_KB = 80
OPTIMAL_SIZE_KB = 50
def analyze_file(filepath: str) -> dict:
path = Path(filepath)
html = path.read_text()
raw_bytes = len(html.encode("utf-8"))
raw_kb = raw_bytes / 1024
# Count elements
tags = re.findall(r"<(\w+)", html)
tag_counts = {}
for t in tags:
t_lower = t.lower()
tag_counts[t_lower] = tag_counts.get(t_lower, 0) + 1
# Inline style weight
inline_styles = re.findall(r'style\s*=\s*"([^"]*)"', html)
style_bytes = sum(len(s.encode()) for s in inline_styles)
style_kb = style_bytes / 1024
# Style block weight
style_blocks = re.findall(r"<style[^>]*>(.*?)</style>", html, re.DOTALL | re.IGNORECASE)
block_bytes = sum(len(s.encode()) for s in style_blocks)
block_kb = block_bytes / 1024
# Image references
img_srcs = re.findall(r'<img[^>]+src\s*=\s*"([^"]*)"', html, re.IGNORECASE)
base64_images = [s for s in img_srcs if s.startswith("data:")]
base64_bytes = sum(len(s.encode()) for s in base64_images)
# Comments
comments = re.findall(r"<!--.*?-->", html, re.DOTALL)
comment_bytes = sum(len(c.encode()) for c in comments)
# Whitespace
stripped = re.sub(r"\s+", " ", html)
minified_bytes = len(stripped.encode())
whitespace_bytes = raw_bytes - minified_bytes
result = {
"file": str(path.name),
"raw_size_kb": round(raw_kb, 2),
"gmail_status": "CLIPPED" if raw_kb > GMAIL_CLIP_THRESHOLD_KB else "WARNING" if raw_kb > WARNING_THRESHOLD_KB else "OK",
"headroom_kb": round(GMAIL_CLIP_THRESHOLD_KB - raw_kb, 2),
"breakdown": {
"inline_styles_kb": round(style_kb, 2),
"style_blocks_kb": round(block_kb, 2),
"base64_images_kb": round(base64_bytes / 1024, 2),
"comments_kb": round(comment_bytes / 1024, 2),
"whitespace_kb": round(whitespace_bytes / 1024, 2),
"content_kb": round(minified_bytes / 1024, 2),
},
"element_counts": dict(sorted(tag_counts.items(), key=lambda x: -x[1])[:10]),
"total_elements": len(tags),
"image_count": len(img_srcs),
"base64_image_count": len(base64_images),
"recommendations": [],
}
# Recommendations
if raw_kb > GMAIL_CLIP_THRESHOLD_KB:
result["recommendations"].append(f"CRITICAL: Email is {raw_kb:.0f}KB -- Gmail will clip at 102KB. Reduce by {raw_kb - GMAIL_CLIP_THRESHOLD_KB:.0f}KB.")
if base64_bytes > 0:
result["recommendations"].append(f"Replace base64 images ({base64_bytes/1024:.1f}KB) with hosted URLs to reduce size.")
if comment_bytes > 500:
result["recommendations"].append(f"Remove HTML comments to save {comment_bytes/1024:.1f}KB.")
if whitespace_bytes > raw_bytes * 0.15:
result["recommendations"].append(f"Minify HTML to save ~{whitespace_bytes/1024:.1f}KB of whitespace.")
if style_kb > 10:
result["recommendations"].append("Large inline style payload. Consider consolidating repeated styles.")
if len(tags) > 500:
result["recommendations"].append(f"High element count ({len(tags)}). Simplify template structure.")
if raw_kb <= OPTIMAL_SIZE_KB:
result["recommendations"].append("Template size is optimal for fast rendering across all clients.")
return result
def format_human(results: list) -> str:
lines = ["\n" + "=" * 55, " EMAIL RENDER SIZE ANALYZER", "=" * 55]
for r in results:
status_icon = {"OK": "+", "WARNING": "!", "CLIPPED": "X"}
lines.append(f"\n File: {r['file']}")
lines.append(f" Size: {r['raw_size_kb']}KB [{status_icon.get(r['gmail_status'], '?')}] {r['gmail_status']}")
lines.append(f" Gmail Headroom: {r['headroom_kb']}KB remaining")
lines.append(f" Elements: {r['total_elements']} | Images: {r['image_count']}")
b = r["breakdown"]
lines.append(f"\n Size Breakdown:")
lines.append(f" Content: {b['content_kb']}KB")
lines.append(f" Inline Styles: {b['inline_styles_kb']}KB")
lines.append(f" Style Blocks: {b['style_blocks_kb']}KB")
lines.append(f" Whitespace: {b['whitespace_kb']}KB")
lines.append(f" Base64 Images: {b['base64_images_kb']}KB")
lines.append(f" Comments: {b['comments_kb']}KB")
if r["recommendations"]:
lines.append(f"\n Recommendations:")
for rec in r["recommendations"]:
lines.append(f" > {rec}")
lines.append("-" * 55)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Analyze email template render size and Gmail clip risk.")
parser.add_argument("file", nargs="?", help="HTML template file")
parser.add_argument("--dir", help="Directory of HTML templates")
parser.add_argument("--json", action="store_true", dest="json_output")
args = parser.parse_args()
files = []
if args.dir:
dirpath = Path(args.dir)
files = sorted(dirpath.glob("*.html"))
if not files:
print(f"No .html files found in {args.dir}", file=sys.stderr)
sys.exit(1)
elif args.file:
files = [Path(args.file)]
else:
parser.print_help()
sys.exit(1)
results = []
for f in files:
try:
results.append(analyze_file(str(f)))
except FileNotFoundError:
print(f"Warning: {f} not found, skipping", file=sys.stderr)
if args.json_output:
print(json.dumps(results if len(results) > 1 else results[0], indent=2))
else:
print(format_human(results))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Email Template Spam Score Checker
Analyzes email HTML templates for spam risk factors including
content-to-image ratio, link density, HTML complexity, and
deliverability best practices per 2025-2026 standards.
Usage:
python spam_score_checker.py template.html
python spam_score_checker.py template.html --json
"""
import argparse
import json
import re
import sys
from pathlib import Path
SPAM_WORDS = [
"free", "guarantee", "act now", "limited time", "urgent", "winner",
"click here", "buy now", "order now", "risk-free", "no obligation",
"100% free", "amazing deal", "cash", "earn money", "double your",
"congratulations", "you won", "apply now", "subscribe now",
]
REQUIRED_ELEMENTS = [
("unsubscribe_link", re.compile(r"unsubscribe|opt.out|manage.*preferences", re.IGNORECASE)),
("physical_address", re.compile(r"\d+\s+\w+\s+(st|street|ave|avenue|rd|road|blvd|dr|drive|suite|ste)", re.IGNORECASE)),
("plain_text_fallback", re.compile(r"content-type:\s*text/plain", re.IGNORECASE)),
("alt_text_on_images", re.compile(r'<img[^>]+alt\s*=\s*"[^"]+', re.IGNORECASE)),
]
def analyze_template(html: str) -> dict:
result = {
"overall_score": 10.0,
"checks": [],
"deductions": [],
"recommendations": [],
}
plain_text = re.sub(r"<[^>]+>", " ", html)
plain_text = re.sub(r"\s+", " ", plain_text).strip()
text_length = len(plain_text)
# Image analysis
images = re.findall(r"<img[^>]*>", html, re.IGNORECASE)
images_with_alt = len(re.findall(r'<img[^>]+alt\s*=\s*"[^"]+', html, re.IGNORECASE))
images_without_alt = len(images) - images_with_alt
image_area_hints = len(re.findall(r'<img[^>]*(?:width|height)', html, re.IGNORECASE))
# Text-to-image ratio (approximate)
html_length = len(html)
img_chars = sum(len(img) for img in images)
text_ratio = text_length / max(html_length, 1) * 100
if text_ratio >= 60:
result["checks"].append({"name": "Text-to-Image Ratio", "status": "PASS", "detail": f"{text_ratio:.0f}% text (target: 60%+)"})
elif text_ratio >= 40:
result["checks"].append({"name": "Text-to-Image Ratio", "status": "WARN", "detail": f"{text_ratio:.0f}% text"})
result["overall_score"] -= 1
result["deductions"].append("Low text-to-image ratio (-1)")
else:
result["checks"].append({"name": "Text-to-Image Ratio", "status": "FAIL", "detail": f"{text_ratio:.0f}% text (too image-heavy)"})
result["overall_score"] -= 2
result["deductions"].append("Very low text ratio (-2)")
# Image alt text
if images and images_without_alt > 0:
result["checks"].append({"name": "Image Alt Text", "status": "WARN", "detail": f"{images_without_alt} image(s) missing alt text"})
result["overall_score"] -= 0.5
elif images:
result["checks"].append({"name": "Image Alt Text", "status": "PASS", "detail": "All images have alt text"})
# Link analysis
links = re.findall(r'href\s*=\s*"([^"]*)"', html, re.IGNORECASE)
external_links = [l for l in links if l.startswith("http")]
shortener_links = [l for l in external_links if any(s in l for s in ["bit.ly", "tinyurl", "t.co", "goo.gl", "ow.ly"])]
if shortener_links:
result["checks"].append({"name": "URL Shorteners", "status": "FAIL", "detail": f"{len(shortener_links)} shortened URLs detected"})
result["overall_score"] -= 2
result["deductions"].append("URL shorteners trigger spam filters (-2)")
else:
result["checks"].append({"name": "URL Shorteners", "status": "PASS", "detail": "No URL shorteners"})
if len(external_links) > 10:
result["checks"].append({"name": "Link Count", "status": "WARN", "detail": f"{len(external_links)} links (high)"})
result["overall_score"] -= 1
else:
result["checks"].append({"name": "Link Count", "status": "PASS", "detail": f"{len(external_links)} links"})
# Spam words
lower = plain_text.lower()
found_spam = [w for w in SPAM_WORDS if w in lower]
if not found_spam:
result["checks"].append({"name": "Spam Trigger Words", "status": "PASS", "detail": "None detected"})
elif len(found_spam) <= 2:
result["checks"].append({"name": "Spam Trigger Words", "status": "WARN", "detail": f"Found: {', '.join(found_spam)}"})
result["overall_score"] -= 0.5
else:
result["checks"].append({"name": "Spam Trigger Words", "status": "FAIL", "detail": f"Found {len(found_spam)}: {', '.join(found_spam)}"})
result["overall_score"] -= 1.5
# Required elements
has_unsubscribe = bool(re.search(r"unsubscribe|opt.out|manage.*preferences", html, re.IGNORECASE))
has_address = bool(re.search(r"\d+\s+\w+\s+(st|street|ave|avenue|rd|road)", html, re.IGNORECASE))
if has_unsubscribe:
# Check for one-click unsubscribe (RFC 8058)
has_one_click = bool(re.search(r"list-unsubscribe|one-click", html, re.IGNORECASE))
detail = "One-click unsubscribe detected" if has_one_click else "Unsubscribe link found (add RFC 8058 one-click header)"
result["checks"].append({"name": "Unsubscribe", "status": "PASS", "detail": detail})
if not has_one_click:
result["recommendations"].append("Add RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers for Gmail/Yahoo compliance.")
else:
result["checks"].append({"name": "Unsubscribe", "status": "FAIL", "detail": "Missing unsubscribe mechanism"})
result["overall_score"] -= 2
result["deductions"].append("Missing unsubscribe (-2) -- required by CAN-SPAM, GDPR, and Gmail/Yahoo")
if has_address:
result["checks"].append({"name": "Physical Address", "status": "PASS", "detail": "Physical address detected"})
else:
result["checks"].append({"name": "Physical Address", "status": "WARN", "detail": "No physical address (CAN-SPAM requirement)"})
result["overall_score"] -= 0.5
# HTML validation
unclosed_tags = 0
for tag in ["div", "table", "tr", "td", "span", "p", "a"]:
opens = len(re.findall(f"<{tag}[\\s>]", html, re.IGNORECASE))
closes = len(re.findall(f"</{tag}>", html, re.IGNORECASE))
unclosed_tags += abs(opens - closes)
if unclosed_tags == 0:
result["checks"].append({"name": "HTML Structure", "status": "PASS", "detail": "Tags appear balanced"})
elif unclosed_tags <= 3:
result["checks"].append({"name": "HTML Structure", "status": "WARN", "detail": f"~{unclosed_tags} potentially unclosed tags"})
result["overall_score"] -= 0.5
else:
result["checks"].append({"name": "HTML Structure", "status": "FAIL", "detail": f"~{unclosed_tags} unclosed tags"})
result["overall_score"] -= 1
# Width check
wide_elements = re.findall(r'width\s*[:=]\s*"?(\d+)', html)
over_600 = [w for w in wide_elements if int(w) > 600]
if over_600:
result["checks"].append({"name": "Max Width", "status": "WARN", "detail": f"Elements wider than 600px detected"})
result["overall_score"] -= 0.5
result["recommendations"].append("Keep max container width at 600px for email client compatibility.")
# Dark mode support
has_dark_mode = bool(re.search(r"prefers-color-scheme:\s*dark", html, re.IGNORECASE))
if has_dark_mode:
result["checks"].append({"name": "Dark Mode", "status": "PASS", "detail": "Dark mode CSS detected"})
else:
result["checks"].append({"name": "Dark Mode", "status": "INFO", "detail": "No dark mode support (affects 30%+ of users)"})
result["recommendations"].append("Add prefers-color-scheme: dark media queries for dark mode support.")
# Subject line in meta
has_preview = bool(re.search(r"preview|preheader", html, re.IGNORECASE))
if has_preview:
result["checks"].append({"name": "Preview Text", "status": "PASS", "detail": "Preview/preheader text detected"})
else:
result["recommendations"].append("Add preview text (80-120 chars) that complements the subject line.")
result["overall_score"] = round(max(0, min(10, result["overall_score"])), 1)
result["grade"] = "A" if result["overall_score"] >= 9 else "B" if result["overall_score"] >= 7 else "C" if result["overall_score"] >= 5 else "F"
result["stats"] = {
"html_size_bytes": len(html.encode()),
"text_length": text_length,
"image_count": len(images),
"link_count": len(external_links),
"text_ratio_percent": round(text_ratio, 1),
}
return result
def format_human(result: dict) -> str:
lines = ["\n" + "=" * 55, " EMAIL TEMPLATE SPAM SCORE CHECKER", "=" * 55]
lines.append(f"\n Score: {result['overall_score']}/10 (Grade: {result['grade']})")
s = result["stats"]
lines.append(f" Size: {s['html_size_bytes']} bytes | Images: {s['image_count']} | Links: {s['link_count']} | Text: {s['text_ratio_percent']}%")
lines.append(f"\n Checks:")
for c in result["checks"]:
icon = {"PASS": "+", "WARN": "!", "FAIL": "X", "INFO": "i"}
lines.append(f" [{icon.get(c['status'], '?')}] {c['name']}: {c['detail']}")
if result["deductions"]:
lines.append(f"\n Deductions:")
for d in result["deductions"]:
lines.append(f" - {d}")
if result["recommendations"]:
lines.append(f"\n Recommendations:")
for r in result["recommendations"]:
lines.append(f" > {r}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Check email template for spam risk factors.")
parser.add_argument("file", help="HTML email template file")
parser.add_argument("--json", action="store_true", dest="json_output")
args = parser.parse_args()
try:
html = Path(args.file).read_text()
except FileNotFoundError:
print(f"Error: File not found: {args.file}", file=sys.stderr)
sys.exit(1)
result = analyze_template(html)
if args.json_output:
print(json.dumps(result, indent=2))
else:
print(format_human(result))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Email Template Validator
Validates email HTML templates for client compatibility, accessibility,
responsive design, and deliverability best practices.
Usage:
python template_validator.py template.html
python template_validator.py template.html --json
"""
import argparse
import json
import re
import sys
from pathlib import Path
def validate_template(html: str) -> dict:
result = {
"valid": True,
"score": 100,
"errors": [],
"warnings": [],
"info": [],
"compatibility": {},
"accessibility": {},
}
# --- Structure checks ---
has_html_tag = bool(re.search(r"<html", html, re.IGNORECASE))
has_head = bool(re.search(r"<head", html, re.IGNORECASE))
has_body = bool(re.search(r"<body", html, re.IGNORECASE))
has_doctype = bool(re.search(r"<!doctype", html, re.IGNORECASE))
has_meta_viewport = bool(re.search(r'name\s*=\s*"viewport"', html, re.IGNORECASE))
has_charset = bool(re.search(r'charset\s*=\s*"?utf-8', html, re.IGNORECASE))
has_lang = bool(re.search(r'<html[^>]+lang\s*=', html, re.IGNORECASE))
if not has_html_tag:
result["errors"].append("Missing <html> tag")
result["score"] -= 10
if not has_head:
result["warnings"].append("Missing <head> tag")
result["score"] -= 5
if not has_body:
result["errors"].append("Missing <body> tag")
result["score"] -= 10
if has_meta_viewport:
result["info"].append("Viewport meta tag present (good for mobile)")
if has_charset:
result["info"].append("UTF-8 charset declared")
if has_lang:
result["info"].append("Language attribute set on <html>")
else:
result["warnings"].append("Missing lang attribute on <html> tag (accessibility)")
result["score"] -= 3
# --- Outlook compatibility ---
uses_flexbox = bool(re.search(r"display\s*:\s*flex", html, re.IGNORECASE))
uses_grid = bool(re.search(r"display\s*:\s*grid", html, re.IGNORECASE))
uses_tables = bool(re.search(r"<table", html, re.IGNORECASE))
uses_css_vars = bool(re.search(r"var\(--", html))
uses_calc = bool(re.search(r"calc\(", html, re.IGNORECASE))
compat = {}
compat["outlook_safe"] = not uses_flexbox and not uses_grid and uses_tables
compat["uses_tables"] = uses_tables
compat["uses_flexbox"] = uses_flexbox
compat["uses_grid"] = uses_grid
if uses_flexbox:
result["warnings"].append("CSS flexbox detected -- breaks in Outlook (Windows). Use table layout.")
result["score"] -= 8
if uses_grid:
result["warnings"].append("CSS grid detected -- breaks in Outlook (Windows). Use table layout.")
result["score"] -= 8
if uses_css_vars:
result["warnings"].append("CSS custom properties (variables) not supported in many email clients.")
result["score"] -= 5
if uses_calc:
result["warnings"].append("calc() not supported in Outlook. Use fixed values.")
result["score"] -= 3
if not uses_tables:
result["warnings"].append("No <table> elements found. Table-based layout is most compatible for email.")
result["score"] -= 5
result["compatibility"] = compat
# --- Inline styles check ---
style_blocks = len(re.findall(r"<style", html, re.IGNORECASE))
inline_styles = len(re.findall(r'style\s*=\s*"', html, re.IGNORECASE))
if style_blocks > 0 and inline_styles == 0:
result["warnings"].append("Styles in <style> blocks only -- Gmail strips <head> styles. Use inline styles.")
result["score"] -= 10
elif inline_styles > 0:
result["info"].append(f"Inline styles detected ({inline_styles} elements) -- good for email client compatibility.")
# --- Image checks ---
images = re.findall(r"<img[^>]*>", html, re.IGNORECASE)
images_no_alt = [img for img in images if not re.search(r'alt\s*=\s*"[^"]+', img, re.IGNORECASE)]
images_no_dims = [img for img in images if not (re.search(r'width', img, re.IGNORECASE) and re.search(r'height', img, re.IGNORECASE))]
acc = {}
acc["total_images"] = len(images)
acc["images_missing_alt"] = len(images_no_alt)
acc["images_missing_dimensions"] = len(images_no_dims)
if images_no_alt:
result["warnings"].append(f"{len(images_no_alt)} image(s) missing alt text (accessibility + deliverability)")
result["score"] -= len(images_no_alt) * 2
if images_no_dims:
result["warnings"].append(f"{len(images_no_dims)} image(s) missing width/height (causes CLS)")
result["score"] -= len(images_no_dims)
result["accessibility"] = acc
# --- Responsive check ---
has_media_queries = bool(re.search(r"@media", html, re.IGNORECASE))
has_max_width = bool(re.search(r"max-width", html, re.IGNORECASE))
if has_media_queries:
result["info"].append("Media queries detected (responsive design)")
else:
result["warnings"].append("No media queries found. Template may not be responsive on mobile.")
result["score"] -= 5
# --- Container width ---
containers = re.findall(r'(?:max-)?width\s*[:=]\s*"?(\d+)(?:px)?', html)
wide = [int(w) for w in containers if int(w) > 600]
if wide:
result["warnings"].append(f"Container wider than 600px detected ({max(wide)}px). Breaks on Gmail mobile.")
result["score"] -= 5
# --- Dark mode ---
has_dark = bool(re.search(r"prefers-color-scheme:\s*dark", html, re.IGNORECASE))
if has_dark:
result["info"].append("Dark mode support detected")
else:
result["warnings"].append("No dark mode support. 30%+ of users use dark mode.")
result["score"] -= 3
# --- Size check ---
size_kb = len(html.encode()) / 1024
if size_kb > 102:
result["errors"].append(f"Template is {size_kb:.0f}KB. Gmail clips emails over 102KB.")
result["score"] -= 15
elif size_kb > 80:
result["warnings"].append(f"Template is {size_kb:.0f}KB. Approaching Gmail's 102KB clip threshold.")
result["score"] -= 5
else:
result["info"].append(f"Template size: {size_kb:.1f}KB (under 102KB Gmail limit)")
result["score"] = max(0, min(100, result["score"]))
result["valid"] = result["score"] >= 50 and not result["errors"]
result["grade"] = "A" if result["score"] >= 90 else "B" if result["score"] >= 75 else "C" if result["score"] >= 60 else "D" if result["score"] >= 40 else "F"
return result
def format_human(result: dict) -> str:
lines = ["\n" + "=" * 55, " EMAIL TEMPLATE VALIDATOR", "=" * 55]
lines.append(f"\n Score: {result['score']}/100 (Grade: {result['grade']})")
lines.append(f" Valid: {'Yes' if result['valid'] else 'No'}")
c = result["compatibility"]
lines.append(f"\n Compatibility:")
lines.append(f" Outlook Safe: {'Yes' if c.get('outlook_safe') else 'No'}")
lines.append(f" Uses Tables: {'Yes' if c.get('uses_tables') else 'No'}")
if result["errors"]:
lines.append(f"\n Errors:")
for e in result["errors"]:
lines.append(f" X {e}")
if result["warnings"]:
lines.append(f"\n Warnings:")
for w in result["warnings"]:
lines.append(f" ! {w}")
if result["info"]:
lines.append(f"\n Info:")
for i in result["info"]:
lines.append(f" + {i}")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Validate email HTML template for compatibility and deliverability.")
parser.add_argument("file", help="HTML template file")
parser.add_argument("--json", action="store_true", dest="json_output")
args = parser.parse_args()
try:
html = Path(args.file).read_text()
except FileNotFoundError:
print(f"Error: {args.file} not found", file=sys.stderr)
sys.exit(1)
result = validate_template(html)
if args.json_output:
print(json.dumps(result, indent=2))
else:
print(format_human(result))
if __name__ == "__main__":
main()
Related skills
FAQ
React Email or MJML?
React Email for TypeScript teams shipping SaaS; MJML for marketing teams needing maximum compatibility across Outlook, Gmail, Apple Mail, and legacy clients.
Which email providers are supported?
Resend, SendGrid, Postmark, and AWS SES via a unified send abstraction with per-provider adapters.