43 KiB
Email Template Editor
Overview
The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.
Key Features:
- Split-Pane Editor — Side-by-side HTML and text editing
- Variable Insertion Buttons — Click to insert {{VARIABLES}} at cursor position
- Live Preview Rendering — See rendered HTML with sample data in real-time
- Test Send Functionality — Send test emails with custom sample data
- Auto-Save Drafts — Prevent data loss with automatic draft saving
- Version Creation — Every save creates a new version with change notes
- Responsive Layout — Desktop-optimized (mobile warning for small screens)
- Keyboard Shortcuts — Ctrl+S to save, Ctrl+P to preview, Esc to close
Access Control:
- Role Required: SUPER_ADMIN only
- Route:
/app/email-templates/:id/edit - Layout: Full-screen (no AppLayout sidebar)
Architecture
flowchart TB
subgraph "Editor UI Components"
Editor[EmailTemplateEditorPage]
Toolbar[Editor Toolbar]
HtmlEditor[HTML Editor Pane]
TextEditor[Text Editor Pane]
VarPanel[Variable Insertion Panel]
Preview[Live Preview Pane]
TestForm[Test Send Form]
end
subgraph "State Management"
State[Component State]
Draft[LocalStorage Draft]
AutoSave[Auto-Save Timer]
end
subgraph "API Layer"
GetTemplate[GET /api/email-templates/:id]
UpdateTemplate[PUT /api/email-templates/:id]
TestSend[POST /api/email-templates/:id/test]
end
subgraph "Backend Processing"
Template[(EmailTemplate)]
Variables[(EmailTemplateVariable)]
Handlebars[Handlebars Compiler]
EmailService[Email Service]
TestLog[(EmailTemplateTestLog)]
end
Editor --> Toolbar
Editor --> HtmlEditor
Editor --> TextEditor
Editor --> VarPanel
Editor --> Preview
Editor --> TestForm
Editor --> State
State --> Draft
State --> AutoSave
Editor -->|Load| GetTemplate
GetTemplate --> Template
GetTemplate --> Variables
VarPanel -->|Insert| HtmlEditor
VarPanel -->|Insert| TextEditor
HtmlEditor -->|Debounce 300ms| Preview
TextEditor --> State
Preview --> Handlebars
Handlebars -->|Render HTML| Preview
Toolbar -->|Save Click| UpdateTemplate
UpdateTemplate --> Template
UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]
TestForm --> TestSend
TestSend --> EmailService
EmailService -->|Send| SMTP[Nodemailer]
SMTP --> TestLog
AutoSave --> Draft
style Editor fill:#4a90e2,color:#fff
style Template fill:#50c878,color:#fff
style Preview fill:#ffb347,color:#333
Data Flow:
- Load Template — Fetch template + variables via GET API
- Restore Draft — Load from localStorage if exists (unsaved changes)
- Edit Content — Type in HTML/text editors, updates component state
- Insert Variable — Click variable button → inserts
{{VAR}}at cursor - Preview Update — Debounced (300ms) Handlebars compilation + iframe render
- Test Send — Enter recipient + sample data → POST to test endpoint → email sent
- Save Template — Click save → PUT API → create version → clear draft → redirect
- Auto-Save Draft — Blur event → save to localStorage (not database)
Editor Components
Toolbar
Location: Top bar (sticky)
Elements:
- Template Name — Read-only display (left)
- Save Button — Saves changes and creates version (right)
- Preview Toggle — Show/hide live preview pane (right)
- Test Send Button — Opens test send modal (right)
- Back Button — Returns to EmailTemplatesPage (left)
Actions:
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/api/email-templates/${id}`, {
subjectLine,
htmlContent,
textContent,
changeNotes,
});
message.success('Template saved successfully');
localStorage.removeItem(`email-template-draft-${id}`);
navigate('/app/email-templates');
} catch (error) {
message.error('Failed to save template');
} finally {
setSaving(false);
}
};
HTML Editor Pane
Location: Left side (50% width) or full width when preview hidden
Features:
- Textarea or Monaco Editor — Syntax highlighting (Monaco upgrade path)
- Line Numbers — Visual line number gutter
- Auto-Resize — Grows to fit content (max 80vh)
- Tab Support — Tab key inserts 2 spaces (not focus change)
Implementation:
const [htmlContent, setHtmlContent] = useState('');
const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setHtmlContent(e.target.value);
debouncedPreview(e.target.value, sampleData);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Tab key support
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
setHtmlContent(
htmlContent.substring(0, start) + ' ' + htmlContent.substring(end)
);
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
};
Text Editor Pane
Location: Left side (50% width) or full width when preview hidden
Features:
- Plain Text Editing — No syntax highlighting needed
- Auto-Resize — Matches HTML editor height
- Variable Insertion — Same insertion panel as HTML editor
Implementation:
const [textContent, setTextContent] = useState('');
const textEditorRef = useRef<HTMLTextAreaElement>(null);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setTextContent(e.target.value);
};
Variable Insertion Panel
Location: Right sidebar (collapsible)
Features:
- Variable List — All template variables with labels
- Insert Buttons — Click to insert
{{VAR}}at cursor - Required Badge — Red badge for required variables
- Conditional Badge — Blue badge for conditional variables
- Sample Value Display — Shows example value below each variable
- Search/Filter — Filter variables by name (if many variables)
Implementation:
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const content = editorType === 'html' ? htmlContent : textContent;
const before = content.substring(0, start);
const after = content.substring(end);
const newContent = before + `{{${variableKey}}}` + after;
if (editorType === 'html') {
setHtmlContent(newContent);
} else {
setTextContent(newContent);
}
// Move cursor after inserted variable
setTimeout(() => {
const newPos = start + variableKey.length + 4; // 4 = {{ + }}
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
}, 0);
};
Variable List UI:
<Space direction="vertical" style={{ width: '100%' }}>
{variables
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((variable) => (
<Card key={variable.id} size="small">
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space>
<Text strong>{variable.label}</Text>
{variable.isRequired && <Tag color="red">Required</Tag>}
{variable.isConditional && <Tag color="blue">Conditional</Tag>}
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
{variable.description}
</Text>
{variable.sampleValue && (
<Text code style={{ fontSize: 11 }}>
Example: {variable.sampleValue}
</Text>
)}
<Space size="small">
<Button
size="small"
onClick={() => handleInsertVariable(variable.key, 'html')}
>
Insert to HTML
</Button>
<Button
size="small"
onClick={() => handleInsertVariable(variable.key, 'text')}
>
Insert to Text
</Button>
</Space>
</Space>
</Card>
))}
</Space>
Live Preview Pane
Location: Right side (50% width) when enabled
Features:
- Iframe Rendering — Isolated HTML preview
- Sample Data Form — Edit sample variable values
- Desktop/Mobile Toggle — Preview in different viewport sizes
- Debounced Updates — Renders 300ms after typing stops
- Error Display — Shows Handlebars compilation errors
Implementation:
import Handlebars from 'handlebars';
const [previewHtml, setPreviewHtml] = useState('');
const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
const previewRef = useRef<HTMLIFrameElement>(null);
const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
try {
const compiled = Handlebars.compile(html);
const rendered = compiled(data);
// Inject into iframe
if (previewRef.current?.contentDocument) {
const doc = previewRef.current.contentDocument;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
</style>
</head>
<body>${rendered}</body>
</html>
`);
doc.close();
}
setPreviewHtml(rendered);
} catch (error) {
console.error('Preview render error:', error);
setPreviewError(error.message);
}
}, []);
const debouncedPreview = useMemo(
() => debounce(renderPreview, 300),
[renderPreview]
);
// Update preview when HTML or sample data changes
useEffect(() => {
debouncedPreview(htmlContent, sampleData);
}, [htmlContent, sampleData, debouncedPreview]);
Sample Data Form:
const handleSampleDataChange = (variableKey: string, value: unknown) => {
setSampleData((prev) => ({
...prev,
[variableKey]: value,
}));
};
// Render form
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
<Title level={5}>Sample Data</Title>
{variables.map((variable) => (
<Form.Item key={variable.id} label={variable.label}>
<Input
value={sampleData[variable.key] as string || ''}
onChange={(e) => handleSampleDataChange(variable.key, e.target.value)}
placeholder={variable.sampleValue || ''}
/>
</Form.Item>
))}
</Space>
Test Send Form
Location: Modal dialog
Features:
- Recipient Email Input — Where to send test email
- Sample Data Editor — JSON editor or form fields
- Send Button — Triggers test send API call
- Success/Failure Notification — Shows send result
- Test Log Link — Link to test send history
Implementation:
const [testModalVisible, setTestModalVisible] = useState(false);
const [testRecipient, setTestRecipient] = useState('');
const [testData, setTestData] = useState<Record<string, unknown>>({});
const handleTestSend = async () => {
if (!testRecipient) {
message.error('Please enter recipient email');
return;
}
setTestSending(true);
try {
await api.post(`/api/email-templates/${id}/test`, {
recipientEmail: testRecipient,
testData,
});
message.success('Test email sent successfully');
setTestModalVisible(false);
} catch (error) {
message.error('Failed to send test email');
} finally {
setTestSending(false);
}
};
// Modal UI
<Modal
title="Send Test Email"
visible={testModalVisible}
onOk={handleTestSend}
onCancel={() => setTestModalVisible(false)}
confirmLoading={testSending}
okText="Send Test"
>
<Form layout="vertical">
<Form.Item label="Recipient Email" required>
<Input
type="email"
value={testRecipient}
onChange={(e) => setTestRecipient(e.target.value)}
placeholder="your-email@example.com"
/>
</Form.Item>
<Form.Item label="Sample Data">
<Space direction="vertical" style={{ width: '100%' }}>
{variables.map((variable) => (
<Input
key={variable.id}
addonBefore={variable.label}
value={testData[variable.key] as string || ''}
onChange={(e) =>
setTestData((prev) => ({ ...prev, [variable.key]: e.target.value }))
}
placeholder={variable.sampleValue || ''}
/>
))}
</Space>
</Form.Item>
</Form>
</Modal>
Admin Workflow
Opening Editor
From EmailTemplatesPage:
- Click template row in table
- Opens template detail modal
- Click "Edit" button in modal
- Opens EmailTemplateEditorPage in same tab
Direct URL:
/app/email-templates/{id}/edit
Route Definition:
// admin/src/App.tsx
<Route
path="/app/email-templates/:id/edit"
element={
<ProtectedRoute allowedRoles={[SUPER_ADMIN]}>
<EmailTemplateEditorPage />
</ProtectedRoute>
}
/>
Editing HTML Content
Step 1: Load Template
- Template data fetched via API on component mount
- HTML/text content populated in editors
- Variables loaded in insertion panel
Step 2: Edit HTML
- Type HTML with
{{VARIABLES}}placeholders - Use variable insertion buttons for convenience
- Preview updates automatically (300ms debounce)
Step 3: Insert Variables
- Click variable "Insert to HTML" button
{{VARIABLE_KEY}}inserted at cursor position- Cursor moves after inserted variable
Step 4: Preview Changes
- Live preview pane shows rendered HTML
- Edit sample data to test different values
- Check for formatting issues
Example Editing Session:
<!-- Initial HTML -->
<p>Dear {{USER_NAME}},</p>
<p>Thank you for signing up.</p>
<!-- Add shift details -->
<p>Dear {{USER_NAME}},</p>
<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
<ul>
<li>Date: {{SHIFT_DATE}}</li>
<li>Time: {{SHIFT_TIME}}</li>
</ul>
<!-- Add conditional phone -->
<p>Dear {{USER_NAME}},</p>
<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
<ul>
<li>Date: {{SHIFT_DATE}}</li>
<li>Time: {{SHIFT_TIME}}</li>
</ul>
{{#if HAS_PHONE}}
<p>We'll call you at {{USER_PHONE}} if there are any changes.</p>
{{/if}}
Using Variable Insertion
Keyboard Method:
- Type
{{in HTML editor - Type variable name (e.g.,
USER_NAME) - Type
}}
Button Method:
- Place cursor where you want variable
- Click variable "Insert to HTML" button
{{VARIABLE_KEY}}inserted at cursor- Cursor moves to end of insertion
Insertion Logic:
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const content = editorType === 'html' ? htmlContent : textContent;
// Replace selection with variable
const before = content.substring(0, start);
const after = content.substring(end);
const variable = `{{${variableKey}}}`;
const newContent = before + variable + after;
// Update state
if (editorType === 'html') {
setHtmlContent(newContent);
} else {
setTextContent(newContent);
}
// Move cursor to end of inserted variable
setTimeout(() => {
const newPos = start + variable.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
}, 0);
};
Live Preview
Preview Update Flow:
-
Type in HTML Editor
onChangeevent fires- Updates
htmlContentstate - Triggers debounced preview render (300ms)
-
Debounced Render
- Waits 300ms after typing stops
- Compiles Handlebars template
- Interpolates with sample data
- Injects HTML into iframe
-
Sample Data Changes
- Edit sample data form fields
- Updates
sampleDatastate - Immediately triggers preview render (no debounce)
Preview Error Handling:
const renderPreview = (html: string, data: Record<string, unknown>) => {
try {
const compiled = Handlebars.compile(html);
const rendered = compiled(data);
// Inject into iframe...
setPreviewError(null);
} catch (error) {
// Show error in preview pane
setPreviewError(error.message);
if (previewRef.current?.contentDocument) {
const doc = previewRef.current.contentDocument;
doc.open();
doc.write(`
<div style="color: red; padding: 20px;">
<h3>Preview Error</h3>
<pre>${error.message}</pre>
</div>
`);
doc.close();
}
}
};
Testing Template
Step 1: Click "Send Test" Button
- Opens test send modal
Step 2: Enter Recipient Email
- Your email address (or test account)
- Validates email format before sending
Step 3: Edit Sample Data
- Pre-filled with variable sample values
- Modify to test specific scenarios
- Example: Set
HAS_PHONEtofalseto test conditional block
Step 4: Click "Send Test"
- POST request to
/api/email-templates/:id/test - Email sent via SMTP (or MailHog in test mode)
- Success notification displayed
Step 5: Check Email
- Open email client (or MailHog at http://localhost:8025)
- Verify rendering, variables, formatting
- Test links, images, layout
Step 6: Review Test Log
- Navigate to "Test Logs" tab in template detail modal
- See test send history (recipient, timestamp, success/failure)
- Debug errors if send failed
Saving Changes
Step 1: Click "Save" Button
- Toolbar save button (or Ctrl+S keyboard shortcut)
Step 2: Enter Change Notes
- Modal prompts for change description
- Used for version history audit trail
- Optional but recommended
Step 3: Confirm Save
- PUT request to
/api/email-templates/:id - Creates new version automatically
- Clears localStorage draft
- Redirects to EmailTemplatesPage
Save Implementation:
const [saveModalVisible, setSaveModalVisible] = useState(false);
const [changeNotes, setChangeNotes] = useState('');
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/api/email-templates/${id}`, {
subjectLine,
htmlContent,
textContent,
changeNotes: changeNotes || undefined,
});
message.success('Template saved successfully');
// Clear draft
localStorage.removeItem(`email-template-draft-${id}`);
// Redirect
navigate('/app/email-templates');
} catch (error) {
message.error('Failed to save template');
} finally {
setSaving(false);
setSaveModalVisible(false);
}
};
// Keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
setSaveModalVisible(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
Code Examples
EmailTemplateEditorPage Component
Full Component Structure:
// admin/src/pages/EmailTemplateEditorPage.tsx
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Input, Space, Card, Tag, Typography, Modal, Form, message } from 'antd';
import { SaveOutlined, SendOutlined, ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
import Handlebars from 'handlebars';
import { debounce } from 'lodash';
import { api } from '@/lib/api';
import type { EmailTemplate, EmailTemplateVariable } from '@/types/api';
const { Title, Text } = Typography;
const { TextArea } = Input;
export default function EmailTemplateEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// State
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [template, setTemplate] = useState<EmailTemplate | null>(null);
const [variables, setVariables] = useState<EmailTemplateVariable[]>([]);
const [subjectLine, setSubjectLine] = useState('');
const [htmlContent, setHtmlContent] = useState('');
const [textContent, setTextContent] = useState('');
const [showPreview, setShowPreview] = useState(true);
const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
const [previewError, setPreviewError] = useState<string | null>(null);
const [testModalVisible, setTestModalVisible] = useState(false);
const [testRecipient, setTestRecipient] = useState('');
const [testSending, setTestSending] = useState(false);
const [saveModalVisible, setSaveModalVisible] = useState(false);
const [changeNotes, setChangeNotes] = useState('');
// Refs
const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
const textEditorRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLIFrameElement>(null);
// Load template
useEffect(() => {
const loadTemplate = async () => {
try {
const response = await api.get(`/api/email-templates/${id}`);
const { template: tmpl, variables: vars } = response.data;
setTemplate(tmpl);
setVariables(vars);
setSubjectLine(tmpl.subjectLine);
setHtmlContent(tmpl.htmlContent);
setTextContent(tmpl.textContent);
// Initialize sample data from variable sample values
const initialSampleData: Record<string, unknown> = {};
vars.forEach((v: EmailTemplateVariable) => {
if (v.sampleValue) {
initialSampleData[v.key] = v.sampleValue;
}
});
setSampleData(initialSampleData);
// Restore draft if exists
const draft = localStorage.getItem(`email-template-draft-${id}`);
if (draft) {
const { subjectLine: draftSubject, htmlContent: draftHtml, textContent: draftText } = JSON.parse(draft);
setSubjectLine(draftSubject);
setHtmlContent(draftHtml);
setTextContent(draftText);
message.info('Restored unsaved changes from draft');
}
setLoading(false);
} catch (error) {
message.error('Failed to load template');
navigate('/app/email-templates');
}
};
loadTemplate();
}, [id, navigate]);
// Auto-save draft to localStorage
useEffect(() => {
if (!loading && template) {
const draft = {
subjectLine,
htmlContent,
textContent,
};
localStorage.setItem(`email-template-draft-${id}`, JSON.stringify(draft));
}
}, [subjectLine, htmlContent, textContent, loading, template, id]);
// Preview rendering
const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
try {
const compiled = Handlebars.compile(html);
const rendered = compiled(data);
if (previewRef.current?.contentDocument) {
const doc = previewRef.current.contentDocument;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
line-height: 1.6;
}
</style>
</head>
<body>${rendered}</body>
</html>
`);
doc.close();
}
setPreviewError(null);
} catch (error: any) {
setPreviewError(error.message);
if (previewRef.current?.contentDocument) {
const doc = previewRef.current.contentDocument;
doc.open();
doc.write(`
<div style="color: red; padding: 20px;">
<h3>Preview Error</h3>
<pre>${error.message}</pre>
</div>
`);
doc.close();
}
}
}, []);
// Debounced preview
const debouncedPreview = useMemo(
() => debounce(renderPreview, 300),
[renderPreview]
);
// Update preview when HTML or sample data changes
useEffect(() => {
if (showPreview) {
debouncedPreview(htmlContent, sampleData);
}
}, [htmlContent, sampleData, showPreview, debouncedPreview]);
// Variable insertion
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const content = editorType === 'html' ? htmlContent : textContent;
const before = content.substring(0, start);
const after = content.substring(end);
const variable = `{{${variableKey}}}`;
const newContent = before + variable + after;
if (editorType === 'html') {
setHtmlContent(newContent);
} else {
setTextContent(newContent);
}
setTimeout(() => {
const newPos = start + variable.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
textarea.focus();
}, 0);
};
// Save template
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/api/email-templates/${id}`, {
subjectLine,
htmlContent,
textContent,
changeNotes: changeNotes || undefined,
});
message.success('Template saved successfully');
localStorage.removeItem(`email-template-draft-${id}`);
navigate('/app/email-templates');
} catch (error) {
message.error('Failed to save template');
} finally {
setSaving(false);
setSaveModalVisible(false);
}
};
// Test send
const handleTestSend = async () => {
if (!testRecipient) {
message.error('Please enter recipient email');
return;
}
setTestSending(true);
try {
await api.post(`/api/email-templates/${id}/test`, {
recipientEmail: testRecipient,
testData: sampleData,
});
message.success('Test email sent successfully');
setTestModalVisible(false);
} catch (error) {
message.error('Failed to send test email');
} finally {
setTestSending(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
setSaveModalVisible(true);
}
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
setShowPreview(!showPreview);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showPreview]);
if (loading) {
return <div style={{ padding: 24 }}>Loading...</div>;
}
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Toolbar */}
<div
style={{
padding: '12px 24px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/email-templates')}>
Back
</Button>
<Title level={4} style={{ margin: 0 }}>
{template?.name}
</Title>
</Space>
<Space>
<Button icon={<EyeOutlined />} onClick={() => setShowPreview(!showPreview)}>
{showPreview ? 'Hide' : 'Show'} Preview
</Button>
<Button icon={<SendOutlined />} onClick={() => setTestModalVisible(true)}>
Send Test
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={() => setSaveModalVisible(true)}>
Save
</Button>
</Space>
</div>
{/* Editor Area */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left: Editors */}
<div
style={{
flex: showPreview ? 1 : 2,
padding: 24,
overflowY: 'auto',
borderRight: '1px solid #f0f0f0',
}}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* Subject Line */}
<div>
<Text strong>Subject Line</Text>
<Input
value={subjectLine}
onChange={(e) => setSubjectLine(e.target.value)}
placeholder="Enter subject line with {{VARIABLES}}"
/>
</div>
{/* HTML Editor */}
<div>
<Text strong>HTML Content</Text>
<TextArea
ref={htmlEditorRef}
value={htmlContent}
onChange={(e) => setHtmlContent(e.target.value)}
placeholder="Enter HTML content with {{VARIABLES}}"
rows={20}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</div>
{/* Text Editor */}
<div>
<Text strong>Plain Text Content</Text>
<TextArea
ref={textEditorRef}
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="Enter plain text version"
rows={15}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</div>
</Space>
</div>
{/* Right: Preview + Variables */}
{showPreview && (
<div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* Variables Panel */}
<Card title="Variables" size="small">
<Space direction="vertical" style={{ width: '100%' }} size="small">
{variables
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((variable) => (
<Card key={variable.id} size="small" style={{ marginBottom: 8 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space>
<Text strong>{variable.label}</Text>
{variable.isRequired && <Tag color="red">Required</Tag>}
{variable.isConditional && <Tag color="blue">Conditional</Tag>}
</Space>
{variable.description && (
<Text type="secondary" style={{ fontSize: 12 }}>
{variable.description}
</Text>
)}
<Space size="small">
<Button size="small" onClick={() => handleInsertVariable(variable.key, 'html')}>
Insert to HTML
</Button>
<Button size="small" onClick={() => handleInsertVariable(variable.key, 'text')}>
Insert to Text
</Button>
</Space>
</Space>
</Card>
))}
</Space>
</Card>
{/* Preview */}
<Card title="Live Preview" size="small">
{previewError && (
<div style={{ color: 'red', marginBottom: 12 }}>
<Text strong>Error:</Text> {previewError}
</div>
)}
<iframe
ref={previewRef}
style={{
width: '100%',
height: 600,
border: '1px solid #d9d9d9',
borderRadius: 4,
}}
title="Email Preview"
/>
</Card>
</Space>
</div>
)}
</div>
{/* Save Modal */}
<Modal
title="Save Template"
visible={saveModalVisible}
onOk={handleSave}
onCancel={() => setSaveModalVisible(false)}
confirmLoading={saving}
okText="Save"
>
<Form layout="vertical">
<Form.Item label="Change Notes (optional)">
<TextArea
value={changeNotes}
onChange={(e) => setChangeNotes(e.target.value)}
placeholder="Describe what changed in this version"
rows={4}
/>
</Form.Item>
</Form>
</Modal>
{/* Test Send Modal */}
<Modal
title="Send Test Email"
visible={testModalVisible}
onOk={handleTestSend}
onCancel={() => setTestModalVisible(false)}
confirmLoading={testSending}
okText="Send Test"
>
<Form layout="vertical">
<Form.Item label="Recipient Email" required>
<Input
type="email"
value={testRecipient}
onChange={(e) => setTestRecipient(e.target.value)}
placeholder="your-email@example.com"
/>
</Form.Item>
<Form.Item label="Sample Data">
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Using sample data from preview. Edit values in the preview panel to change test data.
</Text>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{JSON.stringify(sampleData, null, 2)}
</pre>
</Form.Item>
</Form>
</Modal>
</div>
);
}
Troubleshooting
Problem: Preview not updating
Symptoms:
- Type in HTML editor but preview doesn't change
- Preview shows old content
Causes:
- Debounce timer still running (300ms delay)
- Handlebars compilation error (silent failure)
- Iframe not re-rendering
Solutions:
Wait for debounce:
- Wait 300ms after typing stops
- Preview should update automatically
Check browser console:
// Look for errors
Handlebars.compile error: ...
Force preview update:
// Add button to manually trigger preview
<Button onClick={() => renderPreview(htmlContent, sampleData)}>
Refresh Preview
</Button>
Check iframe contentDocument:
console.log('Iframe doc:', previewRef.current?.contentDocument);
// Should not be null
Problem: Test send fails
Symptoms:
- "Failed to send test email" error
- Email not received in inbox or MailHog
Causes:
- SMTP configuration incorrect
- Email test mode disabled (sending to real SMTP)
- Recipient email invalid
- Template has compilation errors
Solutions:
Check SMTP settings:
# .env
EMAIL_TEST_MODE=true # Use MailHog
Verify MailHog running:
docker compose ps mailhog
# Should show "Up"
Check test logs:
SELECT * FROM email_template_test_logs
WHERE template_id = 'xxx'
ORDER BY created_at DESC
LIMIT 5;
-- Look at error_message column
Test with minimal template:
<p>Hello {{USER_NAME}}</p>
Validate email address:
import validator from 'validator';
if (!validator.isEmail(testRecipient)) {
message.error('Invalid email address');
return;
}
Problem: Variable insertion doesn't work
Symptoms:
- Click "Insert to HTML" button but nothing happens
- Variable inserted in wrong location
Causes:
- Textarea ref not set
- Cursor position not captured correctly
- State update timing issue
Solutions:
Check ref exists:
console.log('HTML ref:', htmlEditorRef.current);
// Should be <textarea> element
Debug cursor position:
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
console.log('Cursor position:', textarea?.selectionStart, textarea?.selectionEnd);
// Rest of insertion logic...
};
Manual workaround:
- Type
{{VARIABLE_KEY}}manually instead of using button
Problem: Draft not restored on reload
Symptoms:
- Unsaved changes lost after browser refresh
- No "Restored draft" message
Causes:
- localStorage not available (private browsing)
- Draft key mismatch
- localStorage quota exceeded
Solutions:
Check localStorage:
// Browser console
localStorage.getItem('email-template-draft-cuid123');
// Should return JSON string
Verify draft key:
console.log('Draft key:', `email-template-draft-${id}`);
Clear old drafts:
// Browser console
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith('email-template-draft-')) {
localStorage.removeItem(key);
}
}
Future Enhancements
Monaco Editor Integration
Current: Basic HTML textarea Future: Monaco Editor with syntax highlighting, IntelliSense, error detection
Benefits:
- Syntax highlighting for HTML
- Auto-completion for HTML tags and Handlebars syntax
- Error squiggles for invalid HTML
- Multi-cursor editing
- Code folding
Implementation:
import Editor from '@monaco-editor/react';
<Editor
height="600px"
language="html"
value={htmlContent}
onChange={(value) => setHtmlContent(value || '')}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
}}
/>
Drag-Drop Block Builder
Current: Manual HTML editing Future: Visual block builder (like GrapesJS)
Benefits:
- No HTML knowledge required
- Pre-built email blocks (header, footer, CTA button)
- Drag-drop interface
- Mobile-responsive by default
Implementation:
- Use GrapesJS (same as landing page editor)
- Custom blocks for email-safe components
- Export to HTML for template storage
Email Client Previews
Current: Single iframe preview Future: Multi-client previews (Gmail, Outlook, Apple Mail)
Benefits:
- Test rendering across email clients
- Catch client-specific CSS issues
- Preview dark mode rendering
Services:
- Litmus API integration
- Email on Acid screenshots
- Self-hosted preview using email client CSS emulation
A/B Testing Support
Current: Single template version Future: A/B testing with variant templates
Features:
- Create template variants (A, B, C)
- Split traffic across variants
- Track open rates, click rates
- Auto-promote winning variant
Implementation:
- EmailTemplateVariant model (templateId, variantName, weight, stats)
- Random variant selection on send
- Tracking pixel in email HTML
- Analytics dashboard
Performance
Auto-Save Timing
Current Implementation:
- Save to localStorage on blur (when focus leaves editor)
- No automatic interval-based saves
Performance Impact:
- Negligible (localStorage write is < 1ms)
- No network requests (local only)
Alternative: Interval-Based Auto-Save:
useEffect(() => {
const interval = setInterval(() => {
if (htmlContent || textContent) {
localStorage.setItem(`email-template-draft-${id}`, JSON.stringify({
subjectLine,
htmlContent,
textContent,
savedAt: new Date().toISOString(),
}));
}
}, 10000); // Every 10 seconds
return () => clearInterval(interval);
}, [id, subjectLine, htmlContent, textContent]);
Preview Rendering Performance
Debounce Delay:
- Current: 300ms
- Too short: Preview updates too frequently (distracting)
- Too long: Preview feels laggy
Handlebars Compilation:
- Fast (< 1ms for typical templates)
- May slow down for very large templates (> 100KB)
Iframe Rendering:
- Browser-native rendering (very fast)
- No performance concerns
Optimization for Large Templates:
// Skip preview for very large HTML
const renderPreview = (html: string, data: Record<string, unknown>) => {
if (html.length > 100000) { // 100KB
setPreviewError('Template too large for live preview. Use test send instead.');
return;
}
// Normal preview rendering...
};
Accessibility
Keyboard Shortcuts
Implemented:
Ctrl+S(orCmd+Son Mac) — Save templateCtrl+P— Toggle preview paneEsc— Close modal
Implementation:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Save
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
setSaveModalVisible(true);
}
// Preview toggle
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
e.preventDefault();
setShowPreview(!showPreview);
}
// Close modal
if (e.key === 'Escape') {
setSaveModalVisible(false);
setTestModalVisible(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showPreview]);
Screen Reader Support
Form Labels:
<Form.Item label="Recipient Email" required>
<Input
type="email"
aria-label="Test email recipient address"
aria-required="true"
value={testRecipient}
onChange={(e) => setTestRecipient(e.target.value)}
/>
</Form.Item>
Button Descriptions:
<Button
icon={<SaveOutlined />}
onClick={() => setSaveModalVisible(true)}
aria-label="Save template and create new version"
>
Save
</Button>
<Button
size="small"
onClick={() => handleInsertVariable(variable.key, 'html')}
aria-label={`Insert ${variable.label} variable into HTML editor`}
>
Insert to HTML
</Button>
Related Documentation
Frontend Documentation
- EmailTemplatesPage.tsx — Email templates list page
- App.tsx — Route definition for editor page
Backend Documentation
- Email Templates Module — API routes
GET /api/email-templates/:id— Load template + variablesPUT /api/email-templates/:id— Update template (creates version)POST /api/email-templates/:id/test— Send test email
Feature Documentation
- template-system.md — Email template engine overview
- variables.md — Template variable system
- versioning.md — Template version history
Related Features
- Landing Page Editor — Similar GrapesJS editor for pages
- Campaign Emails — Uses email templates for advocacy emails