--- name: email-template-builder description: > Build email template systems with React Email or MJML: responsive layouts, dark mode, multi-provider sending, and spam score optimization. Use when setting up transactional email, building email design systems, or debugging deliverability. license: MIT + Commons Clause metadata: version: 1.0.0 author: borghei category: marketing domain: email-engineering tier: POWERFUL updated: 2026-03-09 frameworks: react-email, mjml, email-deliverability, responsive-email --- # 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. --- ## Clarify First Before building the templates, confirm these inputs. If any is unknown or vague, ASK — do not assume: - [ ] **Framework** — React Email or MJML, and whether the team uses React/TypeScript (decides the entire codebase; see the decision table below) - [ ] **Templates needed** — welcome, password reset, invoice, trial-expiring, digest, team-invite, etc. (determines which template files to scaffold) - [ ] **Sending provider** — Resend, SendGrid, Postmark, or SES (selects which provider adapter to build) - [ ] **Locales & dark mode** — required languages and dark-mode support (drives the i18n system and base-layout styles) Stop rule: ask only the 2-3 that most change the output. If the user says "just draft it," proceed and list your assumptions at the top of the artifact. ## 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 ```tsx // 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 ( {preview}
[Product]
{children}

[Company] Inc. - [Address] Unsubscribe {" | "} Email Preferences {" | "} Privacy Policy
); } // 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 ```tsx // 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 ( Welcome to [Product], {name} You have {trialDays} days to explore everything -- no credit card required. Confirm your email to activate your account: Button not working? Paste this link in your browser:{" "} {confirmUrl} ); } 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 ```tsx // 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 ( Invoice #{invoiceNumber} Hi {name}, Here is your invoice. Thank you for your business. {/* Meta row */}
Invoice Date {date} Due Date {dueDate} Amount Due {fmt.format(total / 100)}
{/* Line items */} {items.map((item, i) => ( {item.description} {fmt.format(item.amount / 100)} ))}
Total {fmt.format(total / 100)}
); } 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 ```typescript // emails/lib/send.ts import { render } from "@react-email/render"; interface EmailPayload { to: string; subject: string; template: React.ReactElement; tags?: Record; } interface EmailProvider { send(payload: { to: string; subject: string; html: string; text: string; tags?: Record }): 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 ```typescript // 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 ```typescript // 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 ~all` on 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 `` layout (React Email handles this) | | Gmail | Strips `` 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 ```bash # 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 `` 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. ```bash python scripts/spam_score_checker.py template.html python scripts/spam_score_checker.py template.html --json ``` ### 2. Template Validator (`scripts/template_validator.py`) Validates email templates for client compatibility (Outlook, Gmail), accessibility, responsive design, and inline styles. ```bash python scripts/template_validator.py template.html python scripts/template_validator.py template.html --json ``` ### 3. 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. ```bash python scripts/render_size_analyzer.py template.html python scripts/render_size_analyzer.py --dir templates/ --json ```