1618 lines
43 KiB
Markdown

# 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
```mermaid
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:**
1. **Load Template** — Fetch template + variables via GET API
2. **Restore Draft** — Load from localStorage if exists (unsaved changes)
3. **Edit Content** — Type in HTML/text editors, updates component state
4. **Insert Variable** — Click variable button → inserts `{{VAR}}` at cursor
5. **Preview Update** — Debounced (300ms) Handlebars compilation + iframe render
6. **Test Send** — Enter recipient + sample data → POST to test endpoint → email sent
7. **Save Template** — Click save → PUT API → create version → clear draft → redirect
8. **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:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
```typescript
<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:**
```typescript
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:**
```typescript
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:**
```typescript
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:**
1. Click template row in table
2. Opens template detail modal
3. Click "Edit" button in modal
4. Opens EmailTemplateEditorPage in same tab
**Direct URL:**
```
/app/email-templates/{id}/edit
```
**Route Definition:**
```typescript
// 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:**
```html
<!-- 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:**
1. Type `{{` in HTML editor
2. Type variable name (e.g., `USER_NAME`)
3. Type `}}`
**Button Method:**
1. Place cursor where you want variable
2. Click variable "Insert to HTML" button
3. `{{VARIABLE_KEY}}` inserted at cursor
4. Cursor moves to end of insertion
**Insertion Logic:**
```typescript
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:**
1. **Type in HTML Editor**
- `onChange` event fires
- Updates `htmlContent` state
- Triggers debounced preview render (300ms)
2. **Debounced Render**
- Waits 300ms after typing stops
- Compiles Handlebars template
- Interpolates with sample data
- Injects HTML into iframe
3. **Sample Data Changes**
- Edit sample data form fields
- Updates `sampleData` state
- Immediately triggers preview render (no debounce)
**Preview Error Handling:**
```typescript
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_PHONE` to `false` to 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:**
```typescript
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:**
```typescript
// 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:**
1. Debounce timer still running (300ms delay)
2. Handlebars compilation error (silent failure)
3. Iframe not re-rendering
**Solutions:**
**Wait for debounce:**
- Wait 300ms after typing stops
- Preview should update automatically
**Check browser console:**
```javascript
// Look for errors
Handlebars.compile error: ...
```
**Force preview update:**
```typescript
// Add button to manually trigger preview
<Button onClick={() => renderPreview(htmlContent, sampleData)}>
Refresh Preview
</Button>
```
**Check iframe contentDocument:**
```typescript
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:**
1. SMTP configuration incorrect
2. Email test mode disabled (sending to real SMTP)
3. Recipient email invalid
4. Template has compilation errors
**Solutions:**
**Check SMTP settings:**
```bash
# .env
EMAIL_TEST_MODE=true # Use MailHog
```
**Verify MailHog running:**
```bash
docker compose ps mailhog
# Should show "Up"
```
**Check test logs:**
```sql
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:**
```html
<p>Hello {{USER_NAME}}</p>
```
**Validate email address:**
```typescript
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:**
1. Textarea ref not set
2. Cursor position not captured correctly
3. State update timing issue
**Solutions:**
**Check ref exists:**
```typescript
console.log('HTML ref:', htmlEditorRef.current);
// Should be <textarea> element
```
**Debug cursor position:**
```typescript
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:**
1. localStorage not available (private browsing)
2. Draft key mismatch
3. localStorage quota exceeded
**Solutions:**
**Check localStorage:**
```javascript
// Browser console
localStorage.getItem('email-template-draft-cuid123');
// Should return JSON string
```
**Verify draft key:**
```typescript
console.log('Draft key:', `email-template-draft-${id}`);
```
**Clear old drafts:**
```javascript
// 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:**
```typescript
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](https://www.litmus.com/) API integration
- [Email on Acid](https://www.emailonacid.com/) 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:**
```typescript
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:**
```typescript
// 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` (or `Cmd+S` on Mac) — Save template
- `Ctrl+P` — Toggle preview pane
- `Esc` — Close modal
**Implementation:**
```typescript
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:**
```typescript
<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:**
```typescript
<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](../../frontend/pages/email-templates-page.md)** — Email templates list page
- **[App.tsx](../../frontend/app.md)** — Route definition for editor page
### Backend Documentation
- **[Email Templates Module](../../api/modules/email-templates.md)** — API routes
- `GET /api/email-templates/:id` — Load template + variables
- `PUT /api/email-templates/:id` — Update template (creates version)
- `POST /api/email-templates/:id/test` — Send test email
### Feature Documentation
- **[template-system.md](./template-system.md)** — Email template engine overview
- **[variables.md](./variables.md)** — Template variable system
- **[versioning.md](./versioning.md)** — Template version history
### Related Features
- **[Landing Page Editor](../pages/editor.md)** — Similar GrapesJS editor for pages
- **[Campaign Emails](../influence/campaign-emails.md)** — Uses email templates for advocacy emails