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(/ /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