43 KiB

Email Template System

Overview

The Email Template System provides centralized management of all transactional and campaign emails sent by Changemaker Lite. It enables administrators to create, edit, and maintain email templates with variable interpolation, version control, and testing capabilities.

Key Features:

  • Centralized Management — All email templates stored in database, editable via admin GUI
  • Variable Interpolation{{VAR}} syntax powered by Handlebars template engine
  • Three Categories — INFLUENCE (campaign emails), MAP (shift/canvass emails), SYSTEM (platform emails)
  • Dual Format Support — HTML + plain text versions for all templates
  • System Templates — Protected templates with deletion prevention for critical platform emails
  • Version Control — Automatic version history on every save with rollback capability
  • Test Send — Preview rendered emails before deploying to production
  • Variable Validation — Required vs optional variables with runtime validation

Use Cases:

  • Advocacy Campaigns — Custom email templates for representative outreach
  • Shift Notifications — Confirmation and reminder emails for volunteer shifts
  • User Onboarding — Welcome emails, verification emails, password resets
  • Response Moderation — Notification emails when responses are approved/rejected
  • Canvass Summaries — End-of-session reports sent to volunteers

Architecture

flowchart TB
    subgraph "Email Service Layer"
        Service[EmailService<br/>email.service.ts]
        Service --> Load[Load Template by Key]
        Service --> Validate[Validate Required Variables]
        Service --> Interpolate[Handlebars Interpolation]
    end

    subgraph "Database Models"
        Template[(EmailTemplate)]
        Variables[(EmailTemplateVariable)]
        Versions[(EmailTemplateVersion)]
        TestLogs[(EmailTemplateTestLog)]

        Template -->|1:N| Variables
        Template -->|1:N| Versions
        Template -->|1:N| TestLogs
    end

    subgraph "Output Channels"
        HTML[HTML Email]
        Text[Plain Text Email]
        Preview[Preview Rendering]
    end

    Load --> Template
    Template --> Variables
    Validate --> Variables

    Interpolate --> HTML
    Interpolate --> Text
    Interpolate --> Preview

    HTML --> SMTP[Nodemailer SMTP]
    Text --> SMTP
    Preview --> Admin[Admin GUI]

    SMTP --> Sent[Email Sent]
    Sent --> TestLogs

    Service -.->|Test Mode| MailHog[MailHog<br/>Dev Capture]

    style Service fill:#4a90e2,color:#fff
    style Template fill:#50c878,color:#fff
    style SMTP fill:#ff6b6b,color:#fff

Component Responsibilities:

  • EmailService — Core email sending logic with template loading and interpolation
  • EmailTemplate — Template metadata (key, name, category, content, active status)
  • EmailTemplateVariable — Variable definitions (key, label, required/optional, sample values)
  • EmailTemplateVersion — Version history snapshots with change notes
  • EmailTemplateTestLog — Test send audit trail with success/failure logging
  • Handlebars Engine — Template compilation and variable interpolation
  • Nodemailer — SMTP transport for production email delivery
  • MailHog — Development email capture (when EMAIL_TEST_MODE=true)

Database Models

EmailTemplate

Core template storage with metadata and content.

Field Type Description
id String (CUID) Primary key
key String (unique) Programmatic identifier (e.g., "shift-signup-confirmation")
name String Display name for admin GUI
description String (optional) Template purpose and usage notes
category Enum INFLUENCE, MAP, or SYSTEM
subjectLine String Email subject (supports {{VARIABLES}})
htmlContent Text HTML email body with Handlebars syntax
textContent Text Plain text fallback version
isSystem Boolean If true, cannot be deleted (critical platform emails)
isActive Boolean If false, template is disabled and won't send
createdAt DateTime Creation timestamp
updatedAt DateTime Last modification timestamp
createdByUserId String (optional) User who created template
updatedByUserId String (optional) User who last modified template

Relations:

  • variables — EmailTemplateVariable[] (1:N)
  • versions — EmailTemplateVersion[] (1:N)
  • testLogs — EmailTemplateTestLog[] (1:N)

Indexes:

  • Unique index on key for fast lookups
  • Index on category for filtered queries
  • Index on isActive for production template queries

EmailTemplateVariable

Variable definitions for template interpolation.

Field Type Description
id String (CUID) Primary key
templateId String Foreign key to EmailTemplate
key String Variable name (e.g., "USER_NAME")
label String Display label for admin GUI
description String (optional) Variable purpose and usage notes
isRequired Boolean If true, must be provided in data object
isConditional Boolean If true, used in {{#if}} blocks (truthy/falsy)
sampleValue String (optional) Example value for testing and preview
sortOrder Int Display order in editor variable panel
createdAt DateTime Creation timestamp

Relations:

  • template — EmailTemplate (N:1)

Constraints:

  • Unique index on (templateId, key) to prevent duplicate variables

EmailTemplateVersion

Version history snapshots for audit trail and rollback.

Field Type Description
id String (CUID) Primary key
templateId String Foreign key to EmailTemplate
versionNumber Int Auto-incremented version number (1, 2, 3...)
subjectLine String Subject at time of version
htmlContent Text HTML content snapshot
textContent Text Plain text content snapshot
changeNotes String (optional) Admin-provided change description
createdByUserId String (optional) User who created this version
createdAt DateTime Version creation timestamp

Relations:

  • template — EmailTemplate (N:1)
  • createdBy — User (N:1)

Constraints:

  • Unique index on (templateId, versionNumber) for version lookup
  • Auto-increment logic in service layer (finds max + 1)

EmailTemplateTestLog

Test send audit trail for debugging and compliance.

Field Type Description
id String (CUID) Primary key
templateId String Foreign key to EmailTemplate
recipientEmail String Email address test was sent to
testData JSON Sample variable data used for interpolation
success Boolean Whether send succeeded
errorMessage String (optional) Error details if send failed
messageId String (optional) SMTP message ID if send succeeded
sentByUserId String (optional) User who triggered test send
createdAt DateTime Test send timestamp

Relations:

  • template — EmailTemplate (N:1)
  • sentBy — User (N:1)

Indexes:

  • Index on templateId for template-specific test history
  • Index on createdAt for chronological queries

Template Categories

INFLUENCE Category

Purpose: Advocacy campaign emails sent to representatives or response notifications to participants.

System Templates:

Key Name Description
campaign-email Campaign Email to Representative Main advocacy email template sent on behalf of participants
response-verification Response Verification Email Email asking participants to verify their response submission
response-approved Response Approval Notification Email notifying participant their response is published on wall
response-rejected Response Rejection Notification Email notifying participant their response was rejected (with reason)

Common Variables:

  • USER_NAME — Participant's full name
  • USER_EMAIL — Participant's email address
  • CAMPAIGN_TITLE — Campaign name
  • CAMPAIGN_SLUG — URL-safe campaign identifier
  • REPRESENTATIVE_NAME — Representative's full name
  • REPRESENTATIVE_EMAIL — Representative's email address
  • REPRESENTATIVE_TITLE — Representative's title (e.g., "MP for...")
  • CUSTOM_MESSAGE — Participant's custom message to representative
  • RESPONSE_TEXT — Participant's response wall submission
  • VERIFICATION_LINK — Unique verification URL
  • ADMIN_NOTES — Moderator's rejection reason

MAP Category

Purpose: Location-based emails for volunteer shifts, canvassing sessions, and shift management.

System Templates:

Key Name Description
shift-signup-confirmation Shift Signup Confirmation Email confirming volunteer's shift registration
shift-reminder Shift Reminder Email sent 24 hours before shift starts
shift-cancellation Shift Cancellation Notice Email notifying volunteer of shift cancellation
canvass-session-summary Canvass Session Summary End-of-session report with visit statistics

Common Variables:

  • USER_NAME — Volunteer's full name
  • USER_EMAIL — Volunteer's email address
  • USER_PHONE — Volunteer's phone number (optional)
  • SHIFT_TITLE — Shift name
  • SHIFT_DATE — Shift date (formatted)
  • SHIFT_TIME — Shift time range (e.g., "10:00 AM - 2:00 PM")
  • SHIFT_LOCATION — Shift meeting location
  • CUT_NAME — Canvass area name
  • VISIT_COUNT — Number of doors knocked
  • CONTACT_COUNT — Number of successful contacts
  • SUPPORT_COUNT — Number of supporters identified
  • CANCELLATION_REASON — Why shift was cancelled

SYSTEM Category

Purpose: Core platform emails for user management, authentication, and system notifications.

System Templates:

Key Name Description
user-welcome Welcome Email Email sent to new user registrations
password-reset Password Reset Email Email with password reset link
email-verification Email Verification Email address verification for new accounts
account-locked Account Locked Notice Security notification for locked accounts

Common Variables:

  • USER_NAME — User's full name
  • USER_EMAIL — User's email address
  • VERIFICATION_LINK — Unique verification URL (expires in 24h)
  • RESET_LINK — Unique password reset URL (expires in 1h)
  • SUPPORT_EMAIL — Platform support email address
  • SITE_NAME — Platform name (from SiteSettings)
  • SITE_URL — Platform base URL
  • LOGIN_URL — Direct link to login page
  • LOCKOUT_REASON — Why account was locked

Variable Interpolation

The template system uses Handlebars for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.

Basic Variables

Syntax: {{VARIABLE_NAME}}

Example Template:

<p>Dear {{USER_NAME}},</p>

<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong> on {{SHIFT_DATE}}.</p>

<p>We'll see you at {{SHIFT_LOCATION}} at {{SHIFT_TIME}}.</p>

<p>If you have any questions, email us at {{SUPPORT_EMAIL}}.</p>

Sample Data:

{
  "USER_NAME": "Jane Smith",
  "SHIFT_TITLE": "Door Knocking - Downtown",
  "SHIFT_DATE": "Saturday, March 15, 2026",
  "SHIFT_LOCATION": "Campaign Office (123 Main St)",
  "SHIFT_TIME": "10:00 AM - 2:00 PM",
  "SUPPORT_EMAIL": "volunteer@example.org"
}

Rendered Output:

<p>Dear Jane Smith,</p>

<p>Thank you for signing up for <strong>Door Knocking - Downtown</strong> on Saturday, March 15, 2026.</p>

<p>We'll see you at Campaign Office (123 Main St) at 10:00 AM - 2:00 PM.</p>

<p>If you have any questions, email us at volunteer@example.org.</p>

Conditional Blocks

Syntax: {{#if CONDITION}} ... {{else}} ... {{/if}}

Example Template:

<p>Dear {{USER_NAME}},</p>

<p>Your shift confirmation for {{SHIFT_TITLE}} is below.</p>

{{#if HAS_PHONE}}
<p><strong>We'll call you at {{USER_PHONE}} if there are any changes.</strong></p>
{{else}}
<p>We recommend adding a phone number to your profile for shift updates.</p>
{{/if}}

{{#if IS_CUT_ASSIGNED}}
<p>You've been assigned to canvass <strong>{{CUT_NAME}}</strong>.</p>
{{/if}}

Sample Data:

{
  "USER_NAME": "John Doe",
  "SHIFT_TITLE": "Canvassing - North District",
  "HAS_PHONE": true,
  "USER_PHONE": "(555) 123-4567",
  "IS_CUT_ASSIGNED": true,
  "CUT_NAME": "North District - Zone A"
}

Rendered Output:

<p>Dear John Doe,</p>

<p>Your shift confirmation for Canvassing - North District is below.</p>

<p><strong>We'll call you at (555) 123-4567 if there are any changes.</strong></p>

<p>You've been assigned to canvass <strong>North District - Zone A</strong>.</p>

Truthy/Falsy Values:

  • true, non-empty strings, non-zero numbers → truthy
  • false, null, undefined, 0, "" → falsy

Loops (Each Blocks)

Syntax: {{#each ARRAY}} ... {{/each}}

Example Template:

<p>Dear {{USER_NAME}},</p>

<p>Your email will be sent to the following representatives:</p>

<ul>
{{#each REPRESENTATIVES}}
  <li>
    <strong>{{name}}</strong> ({{title}})<br>
    Email: {{email}}
  </li>
{{/each}}
</ul>

<p>Your custom message:</p>
<blockquote>{{CUSTOM_MESSAGE}}</blockquote>

Sample Data:

{
  "USER_NAME": "Alice Johnson",
  "REPRESENTATIVES": [
    {
      "name": "Jane Doe",
      "title": "MP for Downtown",
      "email": "jane.doe@parliament.ca"
    },
    {
      "name": "John Smith",
      "title": "City Councillor Ward 3",
      "email": "john.smith@city.ca"
    }
  ],
  "CUSTOM_MESSAGE": "Please support Bill C-123 to address climate change."
}

Rendered Output:

<p>Dear Alice Johnson,</p>

<p>Your email will be sent to the following representatives:</p>

<ul>
  <li>
    <strong>Jane Doe</strong> (MP for Downtown)<br>
    Email: jane.doe@parliament.ca
  </li>
  <li>
    <strong>John Smith</strong> (City Councillor Ward 3)<br>
    Email: john.smith@city.ca
  </li>
</ul>

<p>Your custom message:</p>
<blockquote>Please support Bill C-123 to address climate change.</blockquote>

Loop Variables:

  • {{@index}} — 0-based index
  • {{@first}} — true if first item
  • {{@last}} — true if last item

Raw HTML (Unescaped)

Syntax: {{{VARIABLE_NAME}}} (triple braces)

By default, Handlebars escapes HTML to prevent XSS attacks. Use triple braces for trusted HTML content.

Example Template:

<p>Dear {{USER_NAME}},</p>

<div class="message-content">
  {{{FORMATTED_MESSAGE}}}
</div>

Sample Data:

{
  "USER_NAME": "Bob Wilson",
  "FORMATTED_MESSAGE": "<p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>"
}

Rendered Output:

<p>Dear Bob Wilson,</p>

<div class="message-content">
  <p>This is <strong>bold</strong> and <em>italic</em> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>
</div>

Security Warning: Only use {{{...}}} for content generated by the application, never for user-submitted content without sanitization.


Admin Workflow

Viewing Templates

  1. Navigate to Email Templates Page

    • Admin sidebar → Email Templates
    • Shows table with all templates grouped by category
  2. Filter and Search

    • Filter by category (INFLUENCE, MAP, SYSTEM)
    • Search by template name or key
    • Toggle "Show Inactive" to view disabled templates
  3. Template Details

    • Click template row to view details modal
    • See subject line, category, active status, system flag
    • View variable list with required/optional labels
    • Access version history tab
    • Access test send tab

Creating Template

  1. Click "New Template" Button

    • Opens template creation modal
  2. Enter Template Metadata

    • Key — Programmatic identifier (lowercase-with-dashes)
    • Name — Display name for admin GUI
    • Description — Template purpose and usage notes
    • Category — Select INFLUENCE, MAP, or SYSTEM
    • System Flag — Check if template is critical (prevents deletion)
  3. Define Variables

    • Click "Add Variable" in variables section
    • Enter variable key (UPPERCASE_WITH_UNDERSCORES)
    • Enter label and description
    • Toggle required/conditional flags
    • Provide sample value for testing
    • Set sort order (drag to reorder)
  4. Write Template Content

    • Subject Line — Enter subject with optional {{VARIABLES}}
    • HTML Content — Write HTML body with {{VARIABLES}}
    • Text Content — Write plain text fallback
  5. Save Template

    • Click "Save" to create template
    • Creates version 1 automatically
    • Template is active by default

Editing Template

  1. Open Template

    • Email Templates page → click template
    • Opens detail modal
  2. Click "Edit" Button

    • Opens EmailTemplateEditorPage in new tab
    • Shows split-pane editor (HTML + Text)
  3. Modify Content

    • Edit subject line, HTML, or text content
    • Use variable insertion buttons to add {{VARIABLES}}
    • Preview rendered output with sample data
  4. Add Change Notes

    • Enter description of changes in "Change Notes" field
    • Used for version history audit trail
  5. Save Changes

    • Click "Save" button
    • Creates new version automatically
    • Redirects to Email Templates page

Testing Template

  1. Open Template Detail Modal

    • Click template from list
  2. Navigate to "Test Send" Tab

  3. Enter Test Parameters

    • Recipient Email — Your email address for test
    • Sample Data — JSON object with variable values
    • Pre-filled with variable sample values
  4. Click "Send Test Email"

    • Template is rendered with sample data
    • Email sent via SMTP (or MailHog in test mode)
    • Success/failure notification displayed
  5. Check Test Log

    • View test send history in "Test Logs" tab
    • See timestamp, recipient, success status, error messages
    • Review sample data used for each test

Activating/Deactivating Template

  1. Open Template Detail Modal

  2. Toggle "Active" Switch

    • When inactive, template won't send emails
    • Useful for disabling seasonal templates or broken templates
  3. Confirm Action

    • System templates require additional confirmation
    • Deactivating system template may break critical platform functions

Developer Workflow (Adding New Template)

Step 1: Define Template Key

Choose a descriptive, unique key using lowercase with dashes:

Good Keys:

  • shift-signup-confirmation
  • canvass-session-summary
  • response-verification

Bad Keys:

  • template1 (not descriptive)
  • ShiftSignup (wrong case)
  • shift_signup (use dashes, not underscores)

Step 2: Create Template via Seed Script

Add to api/prisma/seed.ts:

await prisma.emailTemplate.upsert({
  where: { key: 'shift-signup-confirmation' },
  update: {},
  create: {
    key: 'shift-signup-confirmation',
    name: 'Shift Signup Confirmation',
    description: 'Email sent to volunteers when they sign up for a shift',
    category: 'MAP',
    isSystem: true,
    isActive: true,
    subjectLine: 'Confirmed: {{SHIFT_TITLE}} on {{SHIFT_DATE}}',
    htmlContent: `
      <p>Dear {{USER_NAME}},</p>

      <p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>!</p>

      <p><strong>Details:</strong></p>
      <ul>
        <li><strong>Date:</strong> {{SHIFT_DATE}}</li>
        <li><strong>Time:</strong> {{SHIFT_TIME}}</li>
        <li><strong>Location:</strong> {{SHIFT_LOCATION}}</li>
      </ul>

      {{#if HAS_PHONE}}
      <p>We'll call you at {{USER_PHONE}} if there are any changes.</p>
      {{/if}}

      <p>See you there!</p>
    `,
    textContent: `
Dear {{USER_NAME}},

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

Details:
- Date: {{SHIFT_DATE}}
- Time: {{SHIFT_TIME}}
- Location: {{SHIFT_LOCATION}}

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

See you there!
    `,
  },
});

Run Seed:

docker compose exec api npx prisma db seed

Step 3: Define Variables

Add variables in same seed script:

const template = await prisma.emailTemplate.findUnique({
  where: { key: 'shift-signup-confirmation' },
});

const variables = [
  {
    key: 'USER_NAME',
    label: 'User Name',
    description: 'Full name of the volunteer',
    isRequired: true,
    isConditional: false,
    sampleValue: 'John Doe',
    sortOrder: 1,
  },
  {
    key: 'SHIFT_TITLE',
    label: 'Shift Title',
    description: 'Name of the shift',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Door Knocking - Downtown',
    sortOrder: 2,
  },
  {
    key: 'SHIFT_DATE',
    label: 'Shift Date',
    description: 'Formatted shift date',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Saturday, March 15, 2026',
    sortOrder: 3,
  },
  {
    key: 'SHIFT_TIME',
    label: 'Shift Time',
    description: 'Shift time range',
    isRequired: true,
    isConditional: false,
    sampleValue: '10:00 AM - 2:00 PM',
    sortOrder: 4,
  },
  {
    key: 'SHIFT_LOCATION',
    label: 'Shift Location',
    description: 'Meeting location for shift',
    isRequired: true,
    isConditional: false,
    sampleValue: 'Campaign Office (123 Main St)',
    sortOrder: 5,
  },
  {
    key: 'HAS_PHONE',
    label: 'Has Phone',
    description: 'Whether user provided phone number',
    isRequired: false,
    isConditional: true,
    sampleValue: 'true',
    sortOrder: 6,
  },
  {
    key: 'USER_PHONE',
    label: 'User Phone',
    description: 'User phone number (optional)',
    isRequired: false,
    isConditional: false,
    sampleValue: '(555) 123-4567',
    sortOrder: 7,
  },
];

for (const variable of variables) {
  await prisma.emailTemplateVariable.upsert({
    where: {
      templateId_key: {
        templateId: template!.id,
        key: variable.key,
      },
    },
    update: {},
    create: {
      templateId: template!.id,
      ...variable,
    },
  });
}

Step 4: Use in Code

Send email from template:

import { emailService } from '@/services/email.service';

await emailService.sendFromTemplate('shift-signup-confirmation', {
  recipientEmail: volunteer.email,
  data: {
    USER_NAME: volunteer.name,
    SHIFT_TITLE: shift.title,
    SHIFT_DATE: dayjs(shift.startTime).format('dddd, MMMM D, YYYY'),
    SHIFT_TIME: `${dayjs(shift.startTime).format('h:mm A')} - ${dayjs(shift.endTime).format('h:mm A')}`,
    SHIFT_LOCATION: shift.location,
    HAS_PHONE: !!volunteer.phone,
    USER_PHONE: volunteer.phone || '',
  },
});

Step 5: Document Template

Add to API documentation:

Create entry in mkdocs/docs/v2/api/email-templates.md:

## shift-signup-confirmation

**Category:** MAP
**System:** Yes

Sent when volunteer signs up for a shift.

**Required Variables:**
- USER_NAME, SHIFT_TITLE, SHIFT_DATE, SHIFT_TIME, SHIFT_LOCATION

**Optional Variables:**
- HAS_PHONE (conditional), USER_PHONE

**Usage:**
\```typescript
await emailService.sendFromTemplate('shift-signup-confirmation', { ... });
\```

Code Examples

Send Email from Template

Basic Usage:

import { emailService } from '@/services/email.service';

await emailService.sendFromTemplate('user-welcome', {
  recipientEmail: 'newuser@example.com',
  data: {
    USER_NAME: 'Alice Smith',
    USER_EMAIL: 'newuser@example.com',
    VERIFICATION_LINK: 'https://cmlite.org/verify/abc123',
    SITE_NAME: 'Changemaker Lite',
    SITE_URL: 'https://cmlite.org',
  },
});

With Conditional Variables:

await emailService.sendFromTemplate('response-verification', {
  recipientEmail: participant.email,
  data: {
    USER_NAME: participant.name,
    CAMPAIGN_TITLE: campaign.title,
    RESPONSE_TEXT: response.content,
    VERIFICATION_LINK: `https://cmlite.org/responses/verify/${response.verificationToken}`,
    HAS_CUSTOM_MESSAGE: !!response.customMessage,
    CUSTOM_MESSAGE: response.customMessage || '',
  },
});

With Loops (Array Variables):

await emailService.sendFromTemplate('campaign-email', {
  recipientEmail: 'representative@parliament.ca',
  data: {
    USER_NAME: participant.name,
    USER_EMAIL: participant.email,
    CAMPAIGN_TITLE: campaign.title,
    CUSTOM_MESSAGE: emailData.customMessage,
    REPRESENTATIVES: emailData.representatives.map(rep => ({
      name: rep.name,
      title: rep.title,
      email: rep.email,
    })),
  },
});

Template Service Implementation

Core sendFromTemplate Method:

// api/src/services/email.service.ts

import Handlebars from 'handlebars';
import { prisma } from '@/config/database';
import { EmailTemplateNotFoundError, MissingRequiredVariableError } from '@/utils/errors';

class EmailService {
  async sendFromTemplate(
    templateKey: string,
    options: {
      recipientEmail: string;
      data: Record<string, unknown>;
      attachments?: Array<{ filename: string; path: string }>;
    }
  ) {
    // 1. Load template with variables
    const template = await prisma.emailTemplate.findUnique({
      where: { key: templateKey, isActive: true },
      include: { variables: true },
    });

    if (!template) {
      throw new EmailTemplateNotFoundError(`Template not found or inactive: ${templateKey}`);
    }

    // 2. Validate required variables
    const requiredVars = template.variables.filter(v => v.isRequired);
    const missingVars: string[] = [];

    for (const variable of requiredVars) {
      if (options.data[variable.key] === undefined || options.data[variable.key] === null) {
        missingVars.push(variable.key);
      }
    }

    if (missingVars.length > 0) {
      throw new MissingRequiredVariableError(
        `Missing required variables for template ${templateKey}: ${missingVars.join(', ')}`
      );
    }

    // 3. Compile Handlebars templates
    const compiledSubject = Handlebars.compile(template.subjectLine);
    const compiledHtml = Handlebars.compile(template.htmlContent);
    const compiledText = Handlebars.compile(template.textContent);

    // 4. Interpolate variables
    const subject = compiledSubject(options.data);
    const html = compiledHtml(options.data);
    const text = compiledText(options.data);

    // 5. Send via Nodemailer
    const result = await this.send({
      to: options.recipientEmail,
      subject,
      html,
      text,
      attachments: options.attachments,
    });

    return result;
  }

  private async send(options: {
    to: string;
    subject: string;
    html: string;
    text: string;
    attachments?: Array<{ filename: string; path: string }>;
  }) {
    // Nodemailer implementation
    // See api/src/services/email.service.ts for full implementation
  }
}

export const emailService = new EmailService();

Handlebars Helper Registration

Register custom helpers for common formatting:

// api/src/services/email.service.ts

import Handlebars from 'handlebars';
import dayjs from 'dayjs';

// Date formatting helper
Handlebars.registerHelper('formatDate', (date: string | Date, format: string) => {
  return dayjs(date).format(format);
});

// Currency formatting helper
Handlebars.registerHelper('currency', (amount: number) => {
  return new Intl.NumberFormat('en-CA', {
    style: 'currency',
    currency: 'CAD',
  }).format(amount);
});

// Pluralize helper
Handlebars.registerHelper('pluralize', (count: number, singular: string, plural: string) => {
  return count === 1 ? singular : plural;
});

Usage in Templates:

<p>Your shift is scheduled for {{formatDate SHIFT_DATE "MMMM D, YYYY"}}.</p>

<p>You've knocked on {{DOOR_COUNT}} {{pluralize DOOR_COUNT "door" "doors"}}.</p>

<p>Campaign budget: {{currency CAMPAIGN_BUDGET}}</p>

Error Handling

Custom Error Classes:

// api/src/utils/errors.ts

export class EmailTemplateNotFoundError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'EmailTemplateNotFoundError';
  }
}

export class MissingRequiredVariableError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'MissingRequiredVariableError';
  }
}

export class TemplateCompilationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'TemplateCompilationError';
  }
}

Service Error Handling:

try {
  await emailService.sendFromTemplate('shift-reminder', {
    recipientEmail: volunteer.email,
    data: { ... },
  });
} catch (error) {
  if (error instanceof EmailTemplateNotFoundError) {
    logger.error('Template not found', { templateKey: 'shift-reminder' });
    // Fallback to default email or skip send
  } else if (error instanceof MissingRequiredVariableError) {
    logger.error('Missing required variables', { error: error.message });
    // Log to Sentry, notify admin
  } else {
    logger.error('Email send failed', { error });
    throw error;
  }
}

Troubleshooting

Problem: Template not found

Symptoms:

  • EmailTemplateNotFoundError: Template not found or inactive: shift-reminder
  • Email not sent, exception thrown

Causes:

  1. Template key typo (case-sensitive)
  2. Template is inactive (isActive = false)
  3. Template doesn't exist in database

Solutions:

Check template exists:

SELECT * FROM email_templates WHERE key = 'shift-reminder';

Check active status:

SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder';

Activate template:

UPDATE email_templates SET is_active = true WHERE key = 'shift-reminder';

Create template via admin GUI or seed script (see Developer Workflow above)


Problem: Variable not replaced (shows {{VAR}} in email)

Symptoms:

  • Rendered email shows {{USER_NAME}} instead of "John Doe"
  • Variables appear as raw text in subject or body

Causes:

  1. Variable key typo in data object (case-sensitive)
  2. Variable not provided in data object
  3. Handlebars compilation failed silently
  4. Using wrong interpolation syntax

Solutions:

Check variable key matches exactly:

// Template uses {{USER_NAME}}
// Data must have USER_NAME (not userName or user_name)
data: {
  USER_NAME: 'John Doe',  // ✓ Correct
  userName: 'John Doe',   // ✗ Wrong case
  user_name: 'John Doe',  // ✗ Wrong format
}

Console log data object:

console.log('Template data:', JSON.stringify(options.data, null, 2));

Test Handlebars compilation:

const Handlebars = require('handlebars');
const template = Handlebars.compile('Hello {{USER_NAME}}!');
console.log(template({ USER_NAME: 'Test' })); // Should output: "Hello Test!"

Verify template content:

SELECT subject_line, html_content FROM email_templates WHERE key = 'shift-reminder';

Problem: Missing required variable error

Symptoms:

  • MissingRequiredVariableError: Missing required variables for template shift-reminder: SHIFT_DATE, SHIFT_TIME
  • Email not sent, exception thrown

Causes:

  1. Required variable not provided in data object
  2. Variable value is null or undefined

Solutions:

Check EmailTemplateVariable.isRequired:

SELECT key, label, is_required
FROM email_template_variables
WHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder');

Provide all required variables:

await emailService.sendFromTemplate('shift-reminder', {
  recipientEmail: volunteer.email,
  data: {
    USER_NAME: volunteer.name,
    SHIFT_DATE: dayjs(shift.startTime).format('MMMM D, YYYY'),  // ✓ Required
    SHIFT_TIME: dayjs(shift.startTime).format('h:mm A'),         // ✓ Required
  },
});

Temporary fix (set isRequired = false):

UPDATE email_template_variables
SET is_required = false
WHERE template_id = (SELECT id FROM email_templates WHERE key = 'shift-reminder')
  AND key = 'SHIFT_TIME';

Long-term fix: Update code to always provide required variables


Problem: Email sent to wrong recipient

Symptoms:

  • Test email sent to production recipient
  • User receives email meant for another user

Causes:

  1. Wrong recipientEmail parameter
  2. Email test mode disabled (EMAIL_TEST_MODE=false)
  3. Variable interpolation pulled wrong user data

Solutions:

Enable test mode in development:

# .env
EMAIL_TEST_MODE=true

Check recipient email:

console.log('Sending email to:', options.recipientEmail);

Use MailHog in dev:

Verify user data query:

const volunteer = await prisma.user.findUnique({ where: { id: volunteerId } });
console.log('Volunteer email:', volunteer.email);

Problem: HTML rendering broken in email client

Symptoms:

  • Email looks correct in preview but broken in Gmail/Outlook
  • Images not loading
  • Styles not applied

Causes:

  1. Email client doesn't support modern CSS
  2. External images blocked by email client
  3. Invalid HTML structure

Solutions:

Use inline styles (not CSS classes):

<!-- ✗ Won't work in many email clients -->
<p class="highlight">Important message</p>

<!-- ✓ Use inline styles -->
<p style="background-color: #ffeb3b; padding: 10px; font-weight: bold;">Important message</p>

Use tables for layout (not flexbox/grid):

<!-- ✓ Email-safe layout -->
<table width="100%" cellpadding="0" cellspacing="0">
  <tr>
    <td style="padding: 20px;">
      <p>Content here</p>
    </td>
  </tr>
</table>

Embed images as data URIs or use absolute URLs:

<!-- ✓ Absolute URL -->
<img src="https://cmlite.org/logo.png" alt="Logo">

<!-- ✓ Data URI (small images only) -->
<img src="data:image/png;base64,iVBORw0KG..." alt="Icon">

Test in multiple email clients:


Performance Considerations

Template Loading

Current Implementation:

  • Templates fetched from database on every send
  • Includes variable definitions in same query
  • No caching layer

Performance Impact:

  • Single database query per email send (~10ms)
  • Acceptable for low-volume sends (< 100/min)
  • May bottleneck for high-volume campaigns (> 1000/min)

Optimization Options:

1. In-Memory Caching:

// api/src/services/email.service.ts

private templateCache = new Map<string, EmailTemplate & { variables: EmailTemplateVariable[] }>();
private cacheExpiry = new Map<string, number>();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async loadTemplate(key: string) {
  const now = Date.now();

  // Check cache
  if (this.templateCache.has(key) && this.cacheExpiry.get(key)! > now) {
    return this.templateCache.get(key)!;
  }

  // Load from database
  const template = await prisma.emailTemplate.findUnique({
    where: { key, isActive: true },
    include: { variables: true },
  });

  if (!template) throw new EmailTemplateNotFoundError(`Template not found: ${key}`);

  // Cache template
  this.templateCache.set(key, template);
  this.cacheExpiry.set(key, now + this.CACHE_TTL);

  return template;
}

2. Redis Caching:

import { redis } from '@/config/redis';

async loadTemplate(key: string) {
  // Try Redis cache
  const cached = await redis.get(`email-template:${key}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // Load from database
  const template = await prisma.emailTemplate.findUnique({ ... });

  // Cache in Redis (5 min TTL)
  await redis.setex(`email-template:${key}`, 300, JSON.stringify(template));

  return template;
}

3. Cache Invalidation:

// When template is updated
await redis.del(`email-template:${template.key}`);
this.templateCache.delete(template.key);

Handlebars Compilation

Performance:

  • Handlebars compilation is fast (~1ms per template)
  • No significant bottleneck for typical templates

Large Templates:

  • Templates > 100KB may take 5-10ms to compile
  • Solution: Pre-compile templates and cache compiled functions

Pre-Compilation:

private compiledCache = new Map<string, {
  subject: HandlebarsTemplateDelegate;
  html: HandlebarsTemplateDelegate;
  text: HandlebarsTemplateDelegate;
}>();

async sendFromTemplate(templateKey: string, options: { ... }) {
  const template = await this.loadTemplate(templateKey);

  // Check compiled cache
  let compiled = this.compiledCache.get(templateKey);

  if (!compiled) {
    compiled = {
      subject: Handlebars.compile(template.subjectLine),
      html: Handlebars.compile(template.htmlContent),
      text: Handlebars.compile(template.textContent),
    };
    this.compiledCache.set(templateKey, compiled);
  }

  // Interpolate
  const subject = compiled.subject(options.data);
  const html = compiled.html(options.data);
  const text = compiled.text(options.data);

  // Send...
}

Bulk Email Sending

Problem: Sending 1000+ emails sequentially is slow (1-2 seconds per email)

Solution: Use BullMQ job queue for async batch processing

Queue Implementation:

// api/src/services/email-queue.service.ts

import { Queue, Worker } from 'bullmq';
import { redis } from '@/config/redis';

const emailQueue = new Queue('email-queue', {
  connection: redis,
});

// Add email job
export async function queueEmail(templateKey: string, options: { ... }) {
  await emailQueue.add('send-template', {
    templateKey,
    recipientEmail: options.recipientEmail,
    data: options.data,
  });
}

// Process email jobs
const emailWorker = new Worker('email-queue', async (job) => {
  const { templateKey, recipientEmail, data } = job.data;
  await emailService.sendFromTemplate(templateKey, { recipientEmail, data });
}, {
  connection: redis,
  concurrency: 10, // Process 10 emails in parallel
});

Usage:

// Queue 1000 emails
for (const volunteer of volunteers) {
  await queueEmail('shift-reminder', {
    recipientEmail: volunteer.email,
    data: { ... },
  });
}

Security Considerations

XSS (Cross-Site Scripting) in Email Clients

Risk: Admin-authored templates may contain malicious JavaScript

Handlebars Auto-Escaping:

  • By default, {{VAR}} escapes HTML entities
  • &&amp;, <&lt;, >&gt;

Raw HTML (Unescaped):

  • {{{VAR}}} (triple braces) renders raw HTML
  • Use ONLY for trusted, application-generated content
  • NEVER use for user-submitted content without sanitization

Example:

<!-- Safe: auto-escaped -->
<p>User message: {{USER_MESSAGE}}</p>

<!-- Unsafe: unescaped (only use for trusted content) -->
<div>{{{FORMATTED_CONTENT}}}</div>

Sanitization:

import DOMPurify from 'isomorphic-dompurify';

const sanitizedMessage = DOMPurify.sanitize(userInput, {
  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li'],
  ALLOWED_ATTR: [],
});

await emailService.sendFromTemplate('response-notification', {
  recipientEmail: admin.email,
  data: {
    USER_MESSAGE: sanitizedMessage, // Safe to use {{{...}}}
  },
});

Email Address Validation

Risk: Invalid email addresses cause SMTP errors or bounce emails

Validation Before Sending:

import validator from 'validator';

if (!validator.isEmail(options.recipientEmail)) {
  throw new Error('Invalid recipient email address');
}

Bounce Handling:

  • Monitor bounce notifications from SMTP provider
  • Mark bounced emails in database
  • Disable sending to repeatedly bounced addresses

Rate Limiting Template Test Sends

Risk: Admin spamming test sends

Rate Limit Implementation:

// api/src/modules/email-templates/email-templates.routes.ts

import rateLimit from 'express-rate-limit';

const testSendLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 10, // 10 requests per minute
  message: 'Too many test sends. Please wait before trying again.',
  standardHeaders: true,
  legacyHeaders: false,
});

router.post('/:id/test', testSendLimiter, requireRole(SUPER_ADMIN), async (req, res) => {
  // Test send implementation...
});

Template Injection Attacks

Risk: Admin injects malicious Handlebars helpers or expressions

Handlebars Security:

  • Handlebars does NOT execute JavaScript (unlike eval)
  • Helpers are pre-registered by application (admin can't add custom helpers)
  • No access to Node.js globals or require()

Safe:

{{USER_NAME}}
{{#if HAS_PHONE}}{{USER_PHONE}}{{/if}}
{{#each ITEMS}}{{name}}{{/each}}

Already Prevented by Handlebars:

<!-- These do NOT execute, render as literal text -->
{{require('fs').readFileSync('/etc/passwd')}}
{{process.env.DATABASE_URL}}

Best Practice: Still review templates before activating


Frontend Documentation

Backend Documentation

  • Email Templates Module — API routes and schemas
    • GET /api/email-templates — List templates (with filters)
    • POST /api/email-templates — Create template
    • PUT /api/email-templates/:id — Update template
    • DELETE /api/email-templates/:id — Delete template (system templates protected)
    • POST /api/email-templates/:id/test — Send test email
    • GET /api/email-templates/:id/versions — Version history
    • POST /api/email-templates/:id/rollback/:versionNumber — Restore version
  • Email Service — Core email sending logic
    • sendFromTemplate() — Load, validate, interpolate, send
    • send() — Low-level Nodemailer wrapper
    • Handlebars helper registration

Database Documentation

  • Email Templates Models — Schema definitions
    • EmailTemplate model
    • EmailTemplateVariable model
    • EmailTemplateVersion model
    • EmailTemplateTestLog model
    • Indexes and constraints

Feature Documentation

Configuration

  • Environment Variables — Email-related env vars
    • EMAIL_TEST_MODE — Enable MailHog capture
    • SMTP settings (host, port, user, password)
  • Site Settings — Site-wide email settings
    • Default from name/email
    • SMTP override settings