Terminal.skills
Use Cases/Build an Email Template Builder

Build an Email Template Builder

Build a visual email template builder with drag-and-drop blocks, responsive design, MJML rendering, variable interpolation, preview across clients, and version management.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Mia leads marketing at a 25-person e-commerce company. They send 15 different transactional and marketing emails. Each template is an HTML file that only a developer can edit. Requesting a "change the button color" takes 3 days through the dev queue. Emails look broken on Outlook (60% of their B2B customers). They tried Mailchimp's builder but can't integrate it with their transactional email system. They need a template builder that marketing can use without developers, renders correctly across all clients, and integrates with their sending infrastructure.

Step 1: Build the Template Engine

typescript
// src/email/builder.ts — Email template builder with blocks, MJML, and multi-client rendering
import { pool } from "../db";
import { Redis } from "ioredis";
import mjml2html from "mjml";

const redis = new Redis(process.env.REDIS_URL!);

interface EmailTemplate {
  id: string;
  name: string;
  category: "transactional" | "marketing" | "notification";
  subject: string;
  preheader: string;
  blocks: TemplateBlock[];
  globalStyles: {
    fontFamily: string;
    primaryColor: string;
    backgroundColor: string;
    textColor: string;
    linkColor: string;
    borderRadius: number;
  };
  variables: Array<{ name: string; defaultValue: string; description: string }>;
  version: number;
  status: "draft" | "published";
  createdBy: string;
  updatedAt: string;
}

type BlockType = "header" | "text" | "image" | "button" | "divider" | "columns" | "product" | "footer" | "social" | "spacer";

interface TemplateBlock {
  id: string;
  type: BlockType;
  order: number;
  content: Record<string, any>;
  style: Record<string, any>;
  condition?: string;          // {{#if variable}} — conditional rendering
}

// Convert template blocks to MJML
function blocksToMJML(template: EmailTemplate, data: Record<string, any> = {}): string {
  const { globalStyles: gs } = template;

  const bodyContent = template.blocks
    .filter((block) => {
      if (!block.condition) return true;
      const match = block.condition.match(/\{\{#if\s+(\w+)\}\}/);
      return match ? !!data[match[1]] : true;
    })
    .map((block) => blockToMJML(block, gs, data))
    .join("\n");

  return `
<mjml>
  <mj-head>
    <mj-attributes>
      <mj-all font-family="${gs.fontFamily}" color="${gs.textColor}" />
      <mj-text font-size="16px" line-height="1.5" />
      <mj-button background-color="${gs.primaryColor}" border-radius="${gs.borderRadius}px" font-size="16px" />
    </mj-attributes>
    <mj-style>
      a { color: ${gs.linkColor}; }
    </mj-style>
    ${template.preheader ? `<mj-preview>${interpolate(template.preheader, data)}</mj-preview>` : ""}
  </mj-head>
  <mj-body background-color="${gs.backgroundColor}">
    ${bodyContent}
  </mj-body>
</mjml>`;
}

function blockToMJML(block: TemplateBlock, gs: any, data: Record<string, any>): string {
  const c = block.content;
  const s = block.style || {};

  switch (block.type) {
    case "header":
      return `
<mj-section background-color="${s.backgroundColor || gs.primaryColor}" padding="20px">
  <mj-column>
    ${c.logoUrl ? `<mj-image src="${c.logoUrl}" width="${c.logoWidth || '150px'}" align="${c.align || 'center'}" />` : ""}
    ${c.title ? `<mj-text font-size="${s.fontSize || '24px'}" font-weight="bold" color="${s.textColor || '#FFFFFF'}" align="center">${interpolate(c.title, data)}</mj-text>` : ""}
  </mj-column>
</mj-section>`;

    case "text":
      return `
<mj-section padding="${s.padding || '10px 25px'}">
  <mj-column>
    <mj-text font-size="${s.fontSize || '16px'}" color="${s.color || gs.textColor}" align="${s.align || 'left'}">
      ${interpolate(c.html || c.text || "", data)}
    </mj-text>
  </mj-column>
</mj-section>`;

    case "image":
      return `
<mj-section padding="${s.padding || '10px 0'}">
  <mj-column>
    <mj-image src="${interpolate(c.src || '', data)}" alt="${c.alt || ''}" width="${c.width || '100%'}"
      ${c.href ? `href="${interpolate(c.href, data)}"` : ''} border-radius="${s.borderRadius || 0}px" />
  </mj-column>
</mj-section>`;

    case "button":
      return `
<mj-section padding="${s.padding || '10px 25px'}">
  <mj-column>
    <mj-button href="${interpolate(c.href || '#', data)}" background-color="${s.backgroundColor || gs.primaryColor}"
      color="${s.textColor || '#FFFFFF'}" border-radius="${s.borderRadius || gs.borderRadius}px"
      font-size="${s.fontSize || '16px'}" padding="12px 30px" width="${c.fullWidth ? '100%' : 'auto'}">
      ${interpolate(c.text || 'Click Here', data)}
    </mj-button>
  </mj-column>
</mj-section>`;

    case "divider":
      return `
<mj-section padding="0 25px">
  <mj-column><mj-divider border-color="${s.color || '#EEEEEE'}" border-width="${s.width || '1px'}" /></mj-column>
</mj-section>`;

    case "columns":
      const cols = (c.columns || []).map((col: any) => `
  <mj-column>
    ${col.image ? `<mj-image src="${interpolate(col.image, data)}" width="100%" />` : ""}
    ${col.title ? `<mj-text font-size="18px" font-weight="bold">${interpolate(col.title, data)}</mj-text>` : ""}
    ${col.text ? `<mj-text>${interpolate(col.text, data)}</mj-text>` : ""}
    ${col.buttonText ? `<mj-button href="${interpolate(col.buttonUrl || '#', data)}">${interpolate(col.buttonText, data)}</mj-button>` : ""}
  </mj-column>`).join("\n");
      return `<mj-section padding="${s.padding || '10px 25px'}">${cols}</mj-section>`;

    case "product":
      return `
<mj-section padding="10px 25px">
  <mj-column width="40%">
    <mj-image src="${interpolate(c.imageUrl || '', data)}" width="100%" border-radius="8px" />
  </mj-column>
  <mj-column width="60%">
    <mj-text font-size="18px" font-weight="bold">${interpolate(c.name || '', data)}</mj-text>
    <mj-text color="#666">${interpolate(c.description || '', data)}</mj-text>
    <mj-text font-size="20px" font-weight="bold" color="${gs.primaryColor}">${interpolate(c.price || '', data)}</mj-text>
    ${c.buttonText ? `<mj-button href="${interpolate(c.buttonUrl || '#', data)}">${interpolate(c.buttonText, data)}</mj-button>` : ""}
  </mj-column>
</mj-section>`;

    case "footer":
      return `
<mj-section background-color="${s.backgroundColor || '#F5F5F5'}" padding="20px 25px">
  <mj-column>
    <mj-text font-size="12px" color="#999999" align="center">
      ${interpolate(c.text || '', data)}
    </mj-text>
    <mj-text font-size="12px" color="#999999" align="center">
      <a href="{{unsubscribeUrl}}" style="color:#999">Unsubscribe</a>
    </mj-text>
  </mj-column>
</mj-section>`;

    case "spacer":
      return `<mj-section padding="0"><mj-column><mj-spacer height="${s.height || '20px'}" /></mj-column></mj-section>`;

    default:
      return "";
  }
}

// Render template to HTML
export async function renderTemplate(
  templateId: string,
  data: Record<string, any>
): Promise<{ html: string; text: string; subject: string }> {
  const { rows: [tmpl] } = await pool.query(
    "SELECT * FROM email_templates WHERE id = $1", [templateId]
  );
  if (!tmpl) throw new Error("Template not found");

  const template: EmailTemplate = {
    ...tmpl,
    blocks: JSON.parse(tmpl.blocks),
    globalStyles: JSON.parse(tmpl.global_styles),
    variables: JSON.parse(tmpl.variables || "[]"),
  };

  // Apply defaults for missing variables
  for (const v of template.variables) {
    if (data[v.name] === undefined) data[v.name] = v.defaultValue;
  }

  const mjmlContent = blocksToMJML(template, data);
  const { html, errors } = mjml2html(mjmlContent, { validationLevel: "soft" });

  if (errors.length > 0) {
    console.warn("MJML warnings:", errors.map((e) => e.message));
  }

  // Generate plain text version
  const text = htmlToText(html);

  return {
    html,
    text,
    subject: interpolate(template.subject, data),
  };
}

function interpolate(template: string, data: Record<string, any>): string {
  return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, key) => {
    const parts = key.split(".");
    let val: any = data;
    for (const p of parts) val = val?.[p];
    return val !== undefined && val !== null ? String(val) : "";
  });
}

function htmlToText(html: string): string {
  return html
    .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
    .replace(/<br\s*\/?>/gi, "\n")
    .replace(/<\/p>/gi, "\n\n")
    .replace(/<[^>]+>/g, "")
    .replace(/&nbsp;/g, " ")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

Results

  • Template changes: 3 days → 5 minutes — marketing edits blocks in visual builder; no developer queue; button color change is a dropdown pick
  • Outlook rendering fixed — MJML generates battle-tested HTML with fallbacks; emails look correct in Outlook, Gmail, Apple Mail, and 40+ clients
  • 10 block types cover 100% of needs — header, text, image, button, columns, product card, footer, social; marketing builds any email without custom HTML
  • Variable system{{customer.name}}, {{order.total}}, {{unsubscribeUrl}} injected at send time; one template serves thousands of personalized emails
  • Version history — every publish creates a new version; rollback to previous design in one click; A/B test different versions