# 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(null); const handleHtmlChange = (e: React.ChangeEvent) => { setHtmlContent(e.target.value); debouncedPreview(e.target.value, sampleData); }; const handleKeyDown = (e: React.KeyboardEvent) => { // 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(null); const handleTextChange = (e: React.ChangeEvent) => { 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 {variables .sort((a, b) => a.sortOrder - b.sortOrder) .map((variable) => ( {variable.label} {variable.isRequired && Required} {variable.isConditional && Conditional} {variable.description} {variable.sampleValue && ( Example: {variable.sampleValue} )} ))} ``` --- ### 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>({}); const previewRef = useRef(null); const renderPreview = useCallback((html: string, data: Record) => { 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(` ${rendered} `); 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 Sample Data {variables.map((variable) => ( handleSampleDataChange(variable.key, e.target.value)} placeholder={variable.sampleValue || ''} /> ))} ``` --- ### 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>({}); 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 setTestModalVisible(false)} confirmLoading={testSending} okText="Send Test" >
setTestRecipient(e.target.value)} placeholder="your-email@example.com" /> {variables.map((variable) => ( setTestData((prev) => ({ ...prev, [variable.key]: e.target.value })) } placeholder={variable.sampleValue || ''} /> ))}
``` --- ## 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 } /> ``` --- ### 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

Dear {{USER_NAME}},

Thank you for signing up.

Dear {{USER_NAME}},

Thank you for signing up for {{SHIFT_TITLE}}.

  • Date: {{SHIFT_DATE}}
  • Time: {{SHIFT_TIME}}

Dear {{USER_NAME}},

Thank you for signing up for {{SHIFT_TITLE}}.

  • Date: {{SHIFT_DATE}}
  • Time: {{SHIFT_TIME}}
{{#if HAS_PHONE}}

We'll call you at {{USER_PHONE}} if there are any changes.

{{/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) => { 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(`

Preview Error

${error.message}
`); 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(null); const [variables, setVariables] = useState([]); const [subjectLine, setSubjectLine] = useState(''); const [htmlContent, setHtmlContent] = useState(''); const [textContent, setTextContent] = useState(''); const [showPreview, setShowPreview] = useState(true); const [sampleData, setSampleData] = useState>({}); const [previewError, setPreviewError] = useState(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(null); const textEditorRef = useRef(null); const previewRef = useRef(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 = {}; 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) => { try { const compiled = Handlebars.compile(html); const rendered = compiled(data); if (previewRef.current?.contentDocument) { const doc = previewRef.current.contentDocument; doc.open(); doc.write(` ${rendered} `); doc.close(); } setPreviewError(null); } catch (error: any) { setPreviewError(error.message); if (previewRef.current?.contentDocument) { const doc = previewRef.current.contentDocument; doc.open(); doc.write(`

Preview Error

${error.message}
`); 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
Loading...
; } return (
{/* Toolbar */}
{template?.name}
{/* Editor Area */}
{/* Left: Editors */}
{/* Subject Line */}
Subject Line setSubjectLine(e.target.value)} placeholder="Enter subject line with {{VARIABLES}}" />
{/* HTML Editor */}
HTML Content