WebZum Logo
WebZum

From Zero to Website Hero

Sign InSign Up
Back to Blog
emailarchitecturerefactoringmarketingstartup

Rebuilding Our Email System: From Spaghetti to Components

WebZum Team•December 14, 2025•8 min read
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.

Ready to Build Your Website?

Join hundreds of businesses using WebZum to create professional websites in minutes, not weeks.

Get Started Free
Live in 5 minutesNo credit card required
Home•Free Tools•Blog•Directory•About•Agencies•Partners
FAQ•Privacy•Terms•© 2026 WebZum