18# Email Template Builder
19
20**Tier:** POWERFUL
21**Category:** Engineering / Marketing
22**Tags:** email templates, React Email, MJML, responsive email, deliverability, transactional email, dark mode
23
24## Overview
25
26Build 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.
27
28This skill builds the email rendering and sending infrastructure. For writing email copy and designing sequences, use email-sequence.
29
30---
31
32## Clarify First
33
34Before building the templates, confirm these inputs. If any is unknown or vague, ASK — do not assume:
35
36- [ ] **Framework** — React Email or MJML, and whether the team uses React/TypeScript (decides the entire codebase; see the decision table below)
37- [ ] **Templates needed** — welcome, password reset, invoice, trial-expiring, digest, team-invite, etc. (determines which template files to scaffold)
38- [ ] **Sending provider** — Resend, SendGrid, Postmark, or SES (selects which provider adapter to build)
39- [ ] **Locales & dark mode** — required languages and dark-mode support (drives the i18n system and base-layout styles)
40
41Stop 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.
42
43## Architecture Decision: React Email vs MJML
44
45| Factor | React Email | MJML |
46|--------|-----------|------|
47| **Component reuse** | Full React component model | Partial (mj-attributes) |
48| **TypeScript** | Native | Requires build step |
49| **Preview server** | Built-in (email dev) | Requires separate setup |
50| **Email client compatibility** | Good (renders to tables) | Excellent (battle-tested) |
51| **Dark mode** | CSS media queries | CSS media queries |
52| **Learning curve** | Low (if you know React) | Low (HTML-like syntax) |
53| **Best for** | Teams already using React | Maximum email client compat |
54
55**Recommendation:** React Email for TypeScript teams shipping SaaS. MJML for marketing teams needing maximum compatibility across Outlook, Gmail, Apple Mail, and legacy clients.
56
57---
58
59## Project Structure
60
61```
62emails/
63├── components/
64│ ├── layout/
65│ │ ├── base-layout.tsx # Shared wrapper: header, footer, styles
66│ │ ├── button.tsx # CTA button component
67│ │ └── divider.tsx # Styled horizontal rule
68│ ├── blocks/
69│ │ ├── hero.tsx # Hero section with heading + text
70│ │ ├── feature-row.tsx # Icon + text feature highlight
71│ │ ├── testimonial.tsx # Quote + attribution
72│ │ └── pricing-table.tsx # Plan comparison
73├── templates/
74│ ├── welcome.tsx # Welcome / confirm email
75│ ├── password-reset.tsx # Password reset link
76│ ├── invoice.tsx # Payment receipt / invoice
77│ ├── trial-expiring.tsx # Trial expiration warning
78│ ├── weekly-digest.tsx # Activity summary
79│ └── team-invite.tsx # Team invitation
80├── lib/
81│ ├── send.ts # Unified send function
82│ ├── providers/
83│ │ ├── resend.ts # Resend adapter
84│ │ ├── sendgrid.ts # SendGrid adapter
85│ │ ├── postmark.ts # Postmark adapter
86│ │ └── ses.ts # AWS SES adapter
87│ ├── tracking.ts # UTM parameter injection
88│ └── render.ts # Template rendering
89├── i18n/
90│ ├── en.ts # English strings
91│ ├── de.ts # German strings
92│ └── types.ts # Typed translation keys
93└── package.json
94```
95
96---
97
98## Base Layout Component
99
100```tsx
101// emails/components/layout/base-layout.tsx
102import {
103 Body, Container, Head, Html, Img, Preview,
104 Section, Text, Hr, Font
105} from "@react-email/components";
106
107interface BaseLayoutProps {
108 preview: string;
109 locale?: string;
110 children: React.ReactNode;
111}
112
113export function BaseLayout({ preview, locale = "en", children }: BaseLayoutProps) {
114 return (
115 <Html lang={locale}>
116 <Head>
117 <Font
118 fontFamily="Inter"
119 fallbackFontFamily="Arial"
120 webFont={{
121 url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2",
122 format: "woff2",
123 }}
124 fontWeight={400}
125 fontStyle="normal"
126 />
127 <style>{`
128 @media (prefers-color-scheme: dark) {
129 .email-body { background-color: #111827 !important; }
130 .email-container { background-color: #1f2937 !important; }
131 .email-text { color: #e5e7eb !important; }
132 .email-heading { color: #f9fafb !important; }
133 .email-muted { color: #9ca3af !important; }
134 }
135 @media only screen and (max-width: 600px) {
136 .email-container { width: 100% !important; padding: 16px !important; }
137 }
138 `}</style>
139 </Head>
140 <Preview>{preview}</Preview>
141 <Body className="email-body" style={body}>
142 <Container className="email-container" style={container}>
143 <Section style={header}>
144 <Img
145 src={${process.env.ASSET_URL}/logo.png}
146 width={120} height={36} alt="[Product]"
147 />
148 </Section>
149 <Section style={content}>{children}</Section>
150 <Hr className="email-muted" style={divider} />
151 <Section style={footer}>
152 <Text className="email-muted" style={footerText}>
153 [Company] Inc. - [Address]
154 </Text>
155 <Text className="email-muted" style={footerText}>
156 <a href="{{unsubscribe_url}}" style={link}>Unsubscribe</a>
157 {" | "}
158 <a href="{{preferences_url}}" style={link}>Email Preferences</a>
159 {" | "}
160 <a href="{{privacy_url}}" style={link}>Privacy Policy</a>
161 </Text>
162 </Section>
163 </Container>
164 </Body>
165 </Html>
166 );
167}
168
169// Styles (inline for email client compatibility)
170const body = { backgroundColor: "#f3f4f6", fontFamily: "Inter, Arial, sans-serif", margin: 0, padding: "40px 0" };
171const container = { maxWidth: "600px", margin: "0 auto", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden" };
172const header = { padding: "24px 32px", borderBottom: "1px solid #e5e7eb" };
173const content = { padding: "32px" };
174const divider = { borderColor: "#e5e7eb", margin: "0 32px" };
175const footer = { padding: "24px 32px" };
176const footerText = { fontSize: "12px", color: "#6b7280", textAlign: "center" as const, margin: "4px 0", lineHeight: "1.6" };
177const link = { color: "#6b7280", textDecoration: "underline" };
178```
179
180---
181
182## Template Examples
183
184### Welcome Email
185
186```tsx
187// emails/templates/welcome.tsx
188import { Button, Heading, Text } from "@react-email/components";
189import { BaseLayout } from "../components/layout/base-layout";
190
191interface WelcomeProps {
192 name: string;
193 confirmUrl: string;
194 trialDays?: number;
195}
196
197export default function Welcome({ name, confirmUrl, trialDays = 14 }: WelcomeProps) {
198 return (
199 <BaseLayout preview={Welcome, ${name}! Confirm your email to get started.}>
200 <Heading className="email-heading" style={h1}>
201 Welcome to [Product], {name}
202 </Heading>
203 <Text className="email-text" style={text}>
204 You have {trialDays} days to explore everything -- no credit card required.
205 Confirm your email to activate your account:
206 </Text>
207 <Button href={confirmUrl} style={button}>
208 Confirm Email Address
209 </Button>
210 <Text className="email-muted" style={muted}>
211 Button not working? Paste this link in your browser:{" "}
212 <a href={confirmUrl} style={linkStyle}>{confirmUrl}</a>
213 </Text>
214 </BaseLayout>
215 );
216}
217
218const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px", lineHeight: "1.3" };
219const text = { fontSize: "16px", lineHeight: "1.6", color: "#374151", margin: "0 0 24px" };
220const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", fontSize: "16px", fontWeight: "600", padding: "12px 24px", textDecoration: "none", display: "inline-block" };
221const muted = { fontSize: "13px", color: "#6b7280", marginTop: "24px", lineHeight: "1.5" };
222const linkStyle = { color: "#4f46e5", wordBreak: "break-all" as const };
223```
224
225### Invoice Email
226
227```tsx
228// emails/templates/invoice.tsx
229import { Row, Column, Section, Heading, Text, Hr, Button } from "@react-email/components";
230import { BaseLayout } from "../components/layout/base-layout";
231
232interface LineItem { description: string; amount: number; }
233
234interface InvoiceProps {
235 name: string;
236 invoiceNumber: string;
237 date: string;
238 dueDate: string;
239 items: LineItem[];
240 total: number;
241 currency?: string;
242 downloadUrl: string;
243}
244
245export default function Invoice({
246 name, invoiceNumber, date, dueDate, items,
247 total, currency = "USD", downloadUrl,
248}: InvoiceProps) {
249 const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency });
250
251 return (
252 <BaseLayout preview={Invoice ${invoiceNumber} -- ${fmt.format(total / 100)}}>
253 <Heading className="email-heading" style={h1}>
254 Invoice #{invoiceNumber}
255 </Heading>
256 <Text className="email-text" style={text}>Hi {name},</Text>
257 <Text className="email-text" style={text}>
258 Here is your invoice. Thank you for your business.
259 </Text>
260
261 {/* Meta row */}
262 <Section style={metaBox}>
263 <Row>
264 <Column>
265 <Text style={metaLabel}>Invoice Date</Text>
266 <Text style={metaValue}>{date}</Text>
267 </Column>
268 <Column>
269 <Text style={metaLabel}>Due Date</Text>
270 <Text style={metaValue}>{dueDate}</Text>
271 </Column>
272 <Column>
273 <Text style={metaLabel}>Amount Due</Text>
274 <Text style={metaValueBold}>{fmt.format(total / 100)}</Text>
275 </Column>
276 </Row>
277 </Section>
278
279 {/* Line items */}
280 {items.map((item, i) => (
281 <Row key={i} style={i % 2 === 0 ? rowEven : rowOdd}>
282 <Column><Text style={cell}>{item.description}</Text></Column>
283 <Column><Text style={cellRight}>{fmt.format(item.amount / 100)}</Text></Column>
284 </Row>
285 ))}
286 <Hr style={divider} />
287 <Row>
288 <Column><Text style={totalLabel}>Total</Text></Column>
289 <Column><Text style={totalValue}>{fmt.format(total / 100)}</Text></Column>
290 </Row>
291
292 <Button href={downloadUrl} style={button}>
293 Download PDF
294 </Button>
295 </BaseLayout>
296 );
297}
298
299const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px" };
300const text = { fontSize: "15px", lineHeight: "1.6", color: "#374151", margin: "0 0 12px" };
301const metaBox = { backgroundColor: "#f9fafb", borderRadius: "8px", padding: "16px", margin: "16px 0" };
302const metaLabel = { fontSize: "11px", color: "#6b7280", fontWeight: "600", textTransform: "uppercase" as const, margin: "0 0 4px", letterSpacing: "0.05em" };
303const metaValue = { fontSize: "14px", color: "#111827", margin: "0" };
304const metaValueBold = { fontSize: "18px", fontWeight: "700", color: "#4f46e5", margin: "0" };
305const rowEven = { backgroundColor: "#ffffff" };
306const rowOdd = { backgroundColor: "#f9fafb" };
307const cell = { fontSize: "14px", color: "#374151", padding: "10px 12px" };
308const cellRight = { ...cell, textAlign: "right" as const };
309const divider = { borderColor: "#e5e7eb", margin: "8px 0" };
310const totalLabel = { fontSize: "16px", fontWeight: "700", color: "#111827", padding: "8px 12px" };
311const totalValue = { ...totalLabel, textAlign: "right" as const };
312const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", padding: "12px 24px", fontSize: "15px", fontWeight: "600", textDecoration: "none", display: "inline-block", marginTop: "16px" };
313```
314
315---
316
317## Multi-Provider Send Abstraction
318
319```typescript
320// emails/lib/send.ts
321import { render } from "@react-email/render";
322
323interface EmailPayload {
324 to: string;
325 subject: string;
326 template: React.ReactElement;
327 tags?: Record<string, string>;
328}
329
330interface EmailProvider {
331 send(payload: { to: string; subject: string; html: string; text: string; tags?: Record<string, string> }): Promise<{ id: string }>;
332}
333
334// Provider factory
335function getProvider(): EmailProvider {
336 const provider = process.env.EMAIL_PROVIDER || "resend";
337 switch (provider) {
338 case "resend": return require("./providers/resend").default;
339 case "sendgrid": return require("./providers/sendgrid").default;
340 case "postmark": return require("./providers/postmark").default;
341 case "ses": return require("./providers/ses").default;
342 default: throw new Error(Unknown email provider: ${provider});
343 }
344}
345
346export async function sendEmail(payload: EmailPayload) {
347 const html = addTracking(render(payload.template), { campaign: payload.tags?.type || "transactional" });
348 const text = render(payload.template, { plainText: true });
349
350 return getProvider().send({
351 to: payload.to,
352 subject: payload.subject,
353 html,
354 text,
355 tags: payload.tags,
356 });
357}
358```
359
360---
361
362## UTM Tracking Injection
363
364```typescript
365// emails/lib/tracking.ts
366interface TrackingConfig {
367 campaign: string;
368 source?: string;
369 medium?: string;
370}
371
372export function addTracking(html: string, config: TrackingConfig): string {
373 const params = new URLSearchParams({
374 utm_source: config.source || "email",
375 utm_medium: config.medium || "transactional",
376 utm_campaign: config.campaign,
377 }).toString();
378
379 // Add UTM to all internal links (skip unsubscribe and external)
380 return html.replace(
381 /href="(https?:\/\/(?:www\.)?yourdomain\.com[^"]*?)"/g,
382 (match, url) => {
383 const sep = url.includes("?") ? "&" : "?";
384 return href="${url}${sep}${params}";
385 }
386 );
387}
388```
389
390---
391
392## i18n System
393
394```typescript
395// emails/i18n/types.ts
396export interface EmailStrings {
397 welcome: {
398 preview: (name: string) => string;
399 heading: (name: string) => string;
400 body: (days: number) => string;
401 cta: string;
402 fallbackLink: string;
403 };
404 invoice: {
405 preview: (number: string, amount: string) => string;
406 heading: (number: string) => string;
407 greeting: (name: string) => string;
408 downloadCta: string;
409 };
410 common: {
411 unsubscribe: string;
412 preferences: string;
413 privacy: string;
414 };
415}
416
417// emails/i18n/en.ts
418import type { EmailStrings } from "./types";
419export const en: EmailStrings = {
420 welcome: {
421 preview: (name) => Welcome, ${name}! Confirm your email to get started.,
422 heading: (name) => Welcome to [Product], ${name},
423 body: (days) => You have ${days} days to explore everything -- no credit card required.,
424 cta: "Confirm Email Address",
425 fallbackLink: "Button not working? Paste this link in your browser:",
426 },
427 // ... other templates
428};
429
430// emails/i18n/de.ts
431import type { EmailStrings } from "./types";
432export const de: EmailStrings = {
433 welcome: {
434 preview: (name) => Willkommen, ${name}! Bestaetigen Sie Ihre E-Mail.,
435 heading: (name) => Willkommen bei [Product], ${name},
436 body: (days) => Sie haben ${days} Tage Zeit, alles zu erkunden -- keine Kreditkarte noetig.,
437 cta: "E-Mail-Adresse bestaetigen",
438 fallbackLink: "Button funktioniert nicht? Fuegen Sie diesen Link in Ihren Browser ein:",
439 },
440 // ... other templates
441};
442```
443
444---
445
446## Deliverability Checklist
447
448### DNS Records (Required)
449
450- [ ] **SPF**: v=spf1 include:_spf.provider.com ~all on sending domain
451- [ ] **DKIM**: Provider-specific CNAME records configured
452- [ ] **DMARC**: v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com
453- [ ] **Return-Path**: Matches sending domain (not provider default)
454
455### Content Rules
456
457- [ ] Sender uses own domain (not @gmail.com)
458- [ ] Subject under 50 characters, no ALL CAPS, no spam triggers
459- [ ] Text-to-image ratio: minimum 60% text
460- [ ] Plain text version included alongside HTML
461- [ ] Unsubscribe link in every email (CAN-SPAM, GDPR, one-click)
462- [ ] Physical mailing address in footer (CAN-SPAM requirement)
463- [ ] No URL shorteners (use full branded links)
464- [ ] Single primary CTA per email
465- [ ] All images have alt text
466- [ ] HTML validates (no broken/unclosed tags)
467
468### Infrastructure
469
470- [ ] Separate sending domains for transactional vs marketing
471- [ ] Warm up new sending domains gradually (start with 50/day, increase 2x weekly)
472- [ ] Monitor bounce rates (<2% hard bounces)
473- [ ] Process bounces and complaints automatically
474- [ ] Test with Mail-Tester.com before production sends (target: 9+/10)
475
476---
477
478## Email Client Compatibility
479
480### Known Quirks
481
482| Client | Quirk | Workaround |
483|--------|-------|-----------|
484| Outlook (Windows) | No CSS grid/flexbox, ignores margin on images | Use <table> layout (React Email handles this) |
485| Gmail | Strips <head> styles, limits CSS | Inline all styles (React Email handles this) |
486| Apple Mail | Best support, renders dark mode well | Standard approach works |
487| Yahoo Mail | Limited CSS support | Avoid advanced selectors |
488| Outlook.com | Strips background images | Use background-color as fallback |
489
490### Testing Matrix
491
492Test every template on these clients before production:
493
494| Priority | Client | Method |
495|----------|--------|--------|
496| Critical | Gmail (web) | Send test email |
497| Critical | Apple Mail (iOS) | Send test email |
498| Critical | Outlook (Windows, latest) | Litmus or Email on Acid |
499| High | Outlook.com (web) | Send test email |
500| High | Gmail (Android) | Send test email |
501| Medium | Yahoo Mail | Litmus |
502| Medium | Outlook (Mac) | Send test email |
503
504---
505
506## Dev Workflow
507
508```bash
509# Start preview server with hot reload
510npx email dev --dir emails/templates --port 3001
511
512# Export to static HTML (for testing with Litmus/Email on Acid)
513npx email export --dir emails/templates --outDir emails/dist
514
515# Send test email
516npx tsx emails/lib/send-test.ts --template welcome --to test@example.com
517
518# Validate HTML
519npx email lint --dir emails/templates
520```
521
522---
523
524## Common Pitfalls
525
526| Pitfall | Consequence | Prevention |
527|---------|-------------|------------|
528| Using CSS grid/flexbox | Layout breaks in Outlook | Use Row/Column from React Email (renders to tables) |
529| Container wider than 600px | Breaks on Gmail mobile | Max-width: 600px on container |
530| Missing plain text version | Lower deliverability score | Always generate plain text with render(template, { plainText: true }) |
531| Same domain for transactional + marketing | Marketing complaints tank transactional delivery | Separate sending domains/subdomains |
532| Skipping email warm-up | Emails go to spam | Start low, increase gradually over 2-4 weeks |
533| Dark mode ignoring | Unreadable emails for 30%+ of users | Add prefers-color-scheme: dark media queries with !important |
534
535---
536
537## Related Skills
538
539| Skill | Use When |
540|-------|----------|
541| **email-sequence** | Writing email copy and designing automation flows |
542| **analytics-tracking** | Setting up email engagement tracking and attribution |
543| **launch-strategy** | Coordinating email templates for product launches |
544
545---
546
547## Troubleshooting
548
549| Symptom | Likely Cause | Fix |
550|---------|-------------|-----|
551| Email clipped in Gmail | HTML over 102KB | Run render_size_analyzer.py. Remove comments, minify, replace base64 images. |
552| Layout broken in Outlook | CSS flexbox/grid used | Use table-based layout. Run template_validator.py for compatibility check. |
553| Styles stripped in Gmail | Styles in <head> only | Inline all CSS. React Email handles this automatically. |
554| Unreadable in dark mode | No dark mode CSS | Add prefers-color-scheme: dark media queries with !important. |
555| Low deliverability score | Missing unsubscribe, heavy images | Run spam_score_checker.py. Add RFC 8058 one-click unsubscribe headers. |
556| Images not loading | Blocked by email client defaults | Add descriptive alt text. Maintain 60%+ text-to-image ratio. |
557| Template renders differently across clients | Unsupported CSS properties | Test on Gmail, Apple Mail, Outlook (Windows) before production sends. |
558
559---
560
561## Success Criteria
562
563- Spam score of 9+/10 on mail-tester.com before production sends
564- Template renders correctly on Gmail, Apple Mail, and Outlook (Windows)
565- HTML under 80KB (well under Gmail's 102KB clip threshold)
566- Text-to-image ratio above 60%
567- Dark mode tested and readable for 30%+ of users
568- All images have alt text and explicit width/height dimensions
569- One-click unsubscribe (RFC 8058) implemented in all templates
570- Separate sending domains for transactional vs. marketing email
571
572---
573
574## Scope & Limitations
575
576**In Scope:** Email HTML/CSS template engineering, React Email and MJML components, multi-provider sending abstraction, i18n, dark mode, deliverability infrastructure, spam score optimization.
577
578**Out of Scope:** Email copy/sequence writing (use email-sequence), marketing automation workflows, email list management, A/B test statistical analysis.
579
580---
581
582## Python Automation Tools
583
584### 1. Spam Score Checker (scripts/spam_score_checker.py)
585Analyzes email HTML for spam risk: text-to-image ratio, link density, spam words, unsubscribe presence, HTML structure.
586
587```bash
588python scripts/spam_score_checker.py template.html
589python scripts/spam_score_checker.py template.html --json
590```
591
592### 2. Template Validator (scripts/template_validator.py)
593Validates email templates for client compatibility (Outlook, Gmail), accessibility, responsive design, and inline styles.
594
595```bash
596python scripts/template_validator.py template.html
597python scripts/template_validator.py template.html --json
598```
599
600### 3. Render Size Analyzer (scripts/render_size_analyzer.py)
601Analyzes template file size, estimates render weight, and checks against Gmail's 102KB clip threshold with detailed breakdown.
602
603```bash
604python scripts/render_size_analyzer.py template.html
605python scripts/render_size_analyzer.py --dir templates/ --json
606```
607