Skip to content

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:

  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:

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:

  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:

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

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
  2. onChange event fires
  3. Updates htmlContent state
  4. Triggers debounced preview render (300ms)

  5. Debounced Render

  6. Waits 300ms after typing stops
  7. Compiles Handlebars template
  8. Interpolates with sample data
  9. Injects HTML into iframe

  10. Sample Data Changes

  11. Edit sample data form fields
  12. Updates sampleData state
  13. 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_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:

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: 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:

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

# .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: 1. Textarea ref not set 2. Cursor position not captured correctly 3. 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: 1. localStorage not available (private browsing) 2. Draft key mismatch 3. 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 (or Cmd+S on Mac) — Save template - Ctrl+P — Toggle preview pane - Esc — 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>


Frontend Documentation

Backend Documentation

  • Email Templates Module — 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