Rebuilding Our Email System: From Spaghetti to Components
The Email Mess
Like many startups, we had an email problem. Our email generation code looked like this:
// The old way (simplified horror)
function generateWelcomeEmail(user, business) {
return `
<html>
<head>
<style>
/* 200 lines of inline CSS */
</style>
</head>
<body>
<div style="max-width: 600px; margin: 0 auto; font-family: Arial;">
<h1 style="color: #2563EB; font-size: 24px;">Welcome to WebZum!</h1>
<!-- 400 more lines of HTML -->
</div>
</body>
</html>
`;
}
Every email type was a separate function with copy-pasted boilerplate. Changing the brand color meant editing 8 files. Adding a new email type meant copy-pasting 500 lines.
It was unsustainable.
The Component Architecture
We rebuilt emails as composable components, inspired by React but designed for email’s unique constraints (inline styles, limited CSS support, table-based layouts).
The Core Layer
src/lib/email/core/
├── brand.ts # Brand constants (colors, fonts, URLs)
├── styles.ts # Style generators (margins, padding patterns)
├── components.ts # Reusable components (buttons, headings, boxes)
├── layout.ts # Email structure (header, footer, wrapper)
└── utils.ts # Helper functions
Brand Constants
Every email uses the same brand values:
// brand.ts
export const BRAND = {
name: 'WebZum',
primary: '#2563EB',
primaryDark: '#1D4ED8',
white: '#FFFFFF',
background: '#F8FAFC',
highlightBg: '#F1F5F9',
accentBg: '#EFF6FF',
accentBorder: '#BFDBFE',
textDark: '#1E293B',
textBody: '#334155',
textMuted: '#64748B',
border: '#E2E8F0',
tagline: 'AI-Powered Websites for Local Businesses',
supportEmail: 'support@webzum.com',
logoUrl: 'https://webzum.com/logo.png',
};
export const FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
Now when we rebrand, we change one file.
Reusable Components
The components are functions that return HTML strings with proper inline styles:
// components.ts
export function heading(text: string, options: HeadingOptions = {}): string {
const { level = 1, center = false } = options;
const tag = `h${level}`;
const sizes: Record<number, string> = {
1: 'font-size: 24px; font-weight: 800; margin: 0 0 16px 0;',
2: 'font-size: 18px; font-weight: 700; margin: 24px 0 12px 0;',
3: 'font-size: 16px; font-weight: 600; margin: 20px 0 8px 0;',
};
const baseStyle = `${sizes[level]} color: ${BRAND.textDark}; line-height: 1.3; font-family: ${FONT_STACK};`;
const alignStyle = center ? ' text-align: center;' : '';
return `<${tag} style="${baseStyle}${alignStyle}">${text}</${tag}>`;
}
export function button(text: string, url: string): string {
return `<div style="text-align: center; margin: 24px 0;">
<a href="${url}" style="display: inline-block; background-color: ${BRAND.primary}; color: ${BRAND.white} !important; font-size: 16px; font-weight: 700; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-family: ${FONT_STACK};">${text}</a>
</div>`;
}
export function highlightBox(content: string, options: HighlightBoxOptions = {}): string {
const { accent = false } = options;
const bgColor = accent ? BRAND.accentBg : BRAND.highlightBg;
const borderStyle = accent ? `border: 1px solid ${BRAND.accentBorder};` : '';
return `<div style="background-color: ${bgColor}; ${borderStyle} border-radius: 8px; padding: 20px; margin: 20px 0;">${content}</div>`;
}
Using Components
Email templates now read like component composition:
import { heading, paragraph, button, highlightBox, list } from '../core/components';
import { wrapInLayout } from '../core/layout';
export function generateWelcomeEmail(context: EmailContext): string {
const content = [
heading(`Welcome to WebZum, ${context.businessName}!`),
paragraph(`Your AI-powered website is almost ready. Here's what happens next:`),
list([
'Your website is being generated (takes about 5 minutes)',
'You\'ll receive an email when it\'s ready to preview',
'Make any edits you want before going live'
]),
button('Check Your Website Status', context.dashboardUrl),
highlightBox([
heading('Need Help?', { level: 3 }),
paragraph('Our support team is here for you. Reply to this email anytime.', { muted: true })
].join('')),
].join('');
return wrapInLayout(content, { preheader: 'Your website is being built!' });
}
Compare that to 500 lines of raw HTML. Much better.
The Template Registry
Different emails need different content but share structure. We built a registry:
// email-generator.ts
const templateGenerators: Record<string, TemplateGenerator> = {
'site-live': generateSiteLiveEmail,
'brand-polish': generateBrandPolishEmail,
'fomo-offer': generateFomoOfferEmail,
'local-seo': generateLocalSeoEmail,
'social-proof': generateSocialProofEmail,
};
export function generateMarketingEmail(
type: MarketingEmailType,
context: EmailContext
): string {
const generator = templateGenerators[type];
if (!generator) {
throw new Error(`Unknown email template type: ${type}`);
}
return generator(context);
}
Adding a new email type? Create a function, add it to the registry. Done.
Marketing Templates
We built specialized marketing templates that drive engagement:
Site Live Email
Sent when a website completes generation:
// templates/marketing/site-live.ts
export function generateSiteLiveEmail(context: EmailContext): string {
const content = [
heading(`${context.businessName}, Your Website is Live! 🎉`),
paragraph(`Great news! Your professional website has been generated and is ready to preview.`),
// Website preview image (if available)
context.screenshotUrl && websitePreview(context.screenshotUrl, context.siteUrl),
button('Preview Your Website', context.previewUrl),
highlightBox([
heading('What\'s Included', { level: 3 }),
list([
'Mobile-responsive design',
'Contact form with email forwarding',
'Google-optimized content',
'Professional hero images',
'Custom color scheme'
])
].join(''), { accent: true }),
paragraph('Your preview link is active for 7 days. Upgrade anytime to keep your site live permanently.', { muted: true }),
].filter(Boolean).join('');
return wrapInLayout(content, {
preheader: 'Your professional website is ready to preview!'
});
}
FOMO Offer Email
Creates urgency for trial expirations:
// templates/marketing/fomo-offer.ts
export function generateFomoOfferEmail(context: EmailContext): string {
const daysLeft = context.daysUntilExpiration;
const urgencyMessage = daysLeft <= 1
? 'Last chance! Your preview expires tomorrow.'
: `${daysLeft} days left to upgrade.`;
const content = [
heading(`Don't Lose Your Website, ${context.businessName}`),
callout(urgencyMessage, { type: 'warning' }),
paragraph(`Your website preview has been getting attention. Don't let it disappear.`),
// Stats if available
context.pageViews && statsBox({
'Page Views': context.pageViews,
'Unique Visitors': context.uniqueVisitors,
'Contact Form Clicks': context.formClicks
}),
button('Keep My Website Live', context.upgradeUrl),
paragraph('Upgrade now and lock in our launch pricing.', { muted: true, center: true }),
].filter(Boolean).join('');
return wrapInLayout(content, {
preheader: `${urgencyMessage} Upgrade to keep your website.`
});
}
Local SEO Email
Educates users about search visibility:
// templates/marketing/local-seo.ts
export function generateLocalSeoEmail(context: EmailContext): string {
const content = [
heading('Get Found on Google: SEO Tips for Local Businesses'),
paragraph(`Hi ${context.ownerName || 'there'},`),
paragraph(`Your WebZum website is built with Google in mind. Here's how to maximize your visibility:`),
numberedList([
{
title: 'Claim Your Google Business Profile',
description: 'This free listing appears when people search for businesses like yours.'
},
{
title: 'Collect Customer Reviews',
description: 'More reviews = higher rankings. Ask happy customers to leave one.'
},
{
title: 'Keep Your Info Consistent',
description: 'Same business name, address, and phone everywhere online.'
}
]),
button('View Your Website', context.siteUrl),
highlightBox([
heading('Pro Tip', { level: 3 }),
paragraph('Your website already includes schema markup for local business search. Google can now understand your services, hours, and location automatically.', { muted: true })
].join('')),
].join('');
return wrapInLayout(content, {
preheader: 'Local SEO tips to get your business found on Google'
});
}
Context Extraction
Each email needs different data. We centralized context extraction:
// templates/marketing/context-extractor.ts
export function extractEmailContext(
businessEntry: BusinessEntry,
emailType: MarketingEmailType
): EmailContext {
const baseContext: EmailContext = {
businessName: businessEntry.businessName,
businessId: businessEntry.id,
ownerName: businessEntry.metadata?.ownerName,
ownerEmail: businessEntry.metadata?.ownerEmail,
siteUrl: `https://${businessEntry.subdomain}.webzum.com`,
previewUrl: `https://${businessEntry.subdomain}.webzum.com?preview=true`,
dashboardUrl: `https://webzum.com/build/${businessEntry.id}`,
upgradeUrl: `https://webzum.com/billing/subscribe/${businessEntry.id}`,
};
// Type-specific context
switch (emailType) {
case 'fomo-offer':
return {
...baseContext,
daysUntilExpiration: calculateDaysLeft(businessEntry.previewExpiresAt),
pageViews: businessEntry.analytics?.pageViews,
uniqueVisitors: businessEntry.analytics?.uniqueVisitors,
};
case 'site-live':
return {
...baseContext,
screenshotUrl: businessEntry.screenshotUrl,
generatedAt: businessEntry.completedAt,
};
default:
return baseContext;
}
}
Email Layout: The Wrapper
All emails share the same outer structure:
// layout.ts
export function wrapInLayout(content: string, options: LayoutOptions = {}): string {
const { preheader, footerLinks = true } = options;
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${BRAND.name}</title>
</head>
<body style="margin: 0; padding: 0; background-color: ${BRAND.background}; font-family: ${FONT_STACK};">
${preheader ? preheaderHidden(preheader) : ''}
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center" style="padding: 40px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px;">
<!-- Header -->
<tr>
<td style="padding-bottom: 24px; text-align: center;">
<img src="${BRAND.logoUrl}" alt="${BRAND.name}" width="120" style="max-width: 100%;">
</td>
</tr>
<!-- Content -->
<tr>
<td style="background-color: ${BRAND.white}; padding: 40px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding-top: 24px; text-align: center;">
${footer(footerLinks)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
Tables. Yes, tables. Email clients are stuck in 2005.
Testing
We wrote comprehensive tests for each component and template:
describe('Email Components', () => {
describe('heading', () => {
it('should render h1 with correct styles', () => {
const result = heading('Test Heading');
expect(result).toContain('<h1');
expect(result).toContain('font-size: 24px');
expect(result).toContain(BRAND.textDark);
});
it('should support different levels', () => {
const h2 = heading('H2 Heading', { level: 2 });
expect(h2).toContain('<h2');
expect(h2).toContain('font-size: 18px');
});
it('should support center alignment', () => {
const centered = heading('Centered', { center: true });
expect(centered).toContain('text-align: center');
});
});
describe('button', () => {
it('should render with correct URL and styles', () => {
const result = button('Click Me', 'https://example.com');
expect(result).toContain('href="https://example.com"');
expect(result).toContain(BRAND.primary);
expect(result).toContain('border-radius: 8px');
});
});
});
The Results
After the refactor:
| Metric | Before | After |
|---|---|---|
| Lines of code | 2,400 | 1,200 |
| Files | 8 | 17 |
| Time to add new email | 2 hours | 20 minutes |
| Brand consistency bugs | ~3/month | 0 |
| Test coverage | 0% | 94% |
More files but less code. Each file does one thing well.
What’s Next
- A/B testing infrastructure: Test different subject lines, CTAs
- Dynamic content blocks: AI-generated personalized sections
- Render preview: See emails before sending in the admin dashboard
- Dark mode support: For email clients that support it (few do, but still)
Shipped December 14, 2025. Emails that don’t suck, at scale.