43 KiB

Email Template Editor

Overview

The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.

Key Features:

  • Split-Pane Editor — Side-by-side HTML and text editing
  • Variable Insertion Buttons — Click to insert {{VARIABLES}} at cursor position
  • Live Preview Rendering — See rendered HTML with sample data in real-time
  • Test Send Functionality — Send test emails with custom sample data
  • Auto-Save Drafts — Prevent data loss with automatic draft saving
  • Version Creation — Every save creates a new version with change notes
  • Responsive Layout — Desktop-optimized (mobile warning for small screens)
  • Keyboard Shortcuts — Ctrl+S to save, Ctrl+P to preview, Esc to close

Access Control:

  • Role Required: SUPER_ADMIN only
  • Route: /app/email-templates/:id/edit
  • Layout: Full-screen (no AppLayout sidebar)

Architecture

flowchart TB
    subgraph "Editor UI Components"
        Editor[EmailTemplateEditorPage]
        Toolbar[Editor Toolbar]
        HtmlEditor[HTML Editor Pane]
        TextEditor[Text Editor Pane]
        VarPanel[Variable Insertion Panel]
        Preview[Live Preview Pane]
        TestForm[Test Send Form]
    end

    subgraph "State Management"
        State[Component State]
        Draft[LocalStorage Draft]
        AutoSave[Auto-Save Timer]
    end

    subgraph "API Layer"
        GetTemplate[GET /api/email-templates/:id]
        UpdateTemplate[PUT /api/email-templates/:id]
        TestSend[POST /api/email-templates/:id/test]
    end

    subgraph "Backend Processing"
        Template[(EmailTemplate)]
        Variables[(EmailTemplateVariable)]
        Handlebars[Handlebars Compiler]
        EmailService[Email Service]
        TestLog[(EmailTemplateTestLog)]
    end

    Editor --> Toolbar
    Editor --> HtmlEditor
    Editor --> TextEditor
    Editor --> VarPanel
    Editor --> Preview
    Editor --> TestForm

    Editor --> State
    State --> Draft
    State --> AutoSave

    Editor -->|Load| GetTemplate
    GetTemplate --> Template
    GetTemplate --> Variables

    VarPanel -->|Insert| HtmlEditor
    VarPanel -->|Insert| TextEditor

    HtmlEditor -->|Debounce 300ms| Preview
    TextEditor --> State

    Preview --> Handlebars
    Handlebars -->|Render HTML| Preview

    Toolbar -->|Save Click| UpdateTemplate
    UpdateTemplate --> Template
    UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]

    TestForm --> TestSend
    TestSend --> EmailService
    EmailService -->|Send| SMTP[Nodemailer]
    SMTP --> TestLog

    AutoSave --> Draft

    style Editor fill:#4a90e2,color:#fff
    style Template fill:#50c878,color:#fff
    style Preview fill:#ffb347,color:#333

Data Flow:

  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

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

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