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
keyfor fast lookups - Index on
categoryfor filtered queries - Index on
isActivefor 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
templateIdfor template-specific test history - Index on
createdAtfor 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 nameUSER_EMAIL— Participant's email addressCAMPAIGN_TITLE— Campaign nameCAMPAIGN_SLUG— URL-safe campaign identifierREPRESENTATIVE_NAME— Representative's full nameREPRESENTATIVE_EMAIL— Representative's email addressREPRESENTATIVE_TITLE— Representative's title (e.g., "MP for...")CUSTOM_MESSAGE— Participant's custom message to representativeRESPONSE_TEXT— Participant's response wall submissionVERIFICATION_LINK— Unique verification URLADMIN_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 nameUSER_EMAIL— Volunteer's email addressUSER_PHONE— Volunteer's phone number (optional)SHIFT_TITLE— Shift nameSHIFT_DATE— Shift date (formatted)SHIFT_TIME— Shift time range (e.g., "10:00 AM - 2:00 PM")SHIFT_LOCATION— Shift meeting locationCUT_NAME— Canvass area nameVISIT_COUNT— Number of doors knockedCONTACT_COUNT— Number of successful contactsSUPPORT_COUNT— Number of supporters identifiedCANCELLATION_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 nameUSER_EMAIL— User's email addressVERIFICATION_LINK— Unique verification URL (expires in 24h)RESET_LINK— Unique password reset URL (expires in 1h)SUPPORT_EMAIL— Platform support email addressSITE_NAME— Platform name (from SiteSettings)SITE_URL— Platform base URLLOGIN_URL— Direct link to login pageLOCKOUT_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 → truthyfalse,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
-
Navigate to Email Templates Page
- Admin sidebar → Email Templates
- Shows table with all templates grouped by category
-
Filter and Search
- Filter by category (INFLUENCE, MAP, SYSTEM)
- Search by template name or key
- Toggle "Show Inactive" to view disabled templates
-
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
-
Click "New Template" Button
- Opens template creation modal
-
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)
-
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)
-
Write Template Content
- Subject Line — Enter subject with optional {{VARIABLES}}
- HTML Content — Write HTML body with {{VARIABLES}}
- Text Content — Write plain text fallback
-
Save Template
- Click "Save" to create template
- Creates version 1 automatically
- Template is active by default
Editing Template
-
Open Template
- Email Templates page → click template
- Opens detail modal
-
Click "Edit" Button
- Opens EmailTemplateEditorPage in new tab
- Shows split-pane editor (HTML + Text)
-
Modify Content
- Edit subject line, HTML, or text content
- Use variable insertion buttons to add {{VARIABLES}}
- Preview rendered output with sample data
-
Add Change Notes
- Enter description of changes in "Change Notes" field
- Used for version history audit trail
-
Save Changes
- Click "Save" button
- Creates new version automatically
- Redirects to Email Templates page
Testing Template
-
Open Template Detail Modal
- Click template from list
-
Navigate to "Test Send" Tab
-
Enter Test Parameters
- Recipient Email — Your email address for test
- Sample Data — JSON object with variable values
- Pre-filled with variable sample values
-
Click "Send Test Email"
- Template is rendered with sample data
- Email sent via SMTP (or MailHog in test mode)
- Success/failure notification displayed
-
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
-
Open Template Detail Modal
-
Toggle "Active" Switch
- When inactive, template won't send emails
- Useful for disabling seasonal templates or broken templates
-
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-confirmationcanvass-session-summaryresponse-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:
- Template key typo (case-sensitive)
- Template is inactive (
isActive = false) - 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:
- Variable key typo in data object (case-sensitive)
- Variable not provided in data object
- Handlebars compilation failed silently
- 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:
- Required variable not provided in data object
- Variable value is
nullorundefined
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:
- Wrong
recipientEmailparameter - Email test mode disabled (
EMAIL_TEST_MODE=false) - 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:
- All emails captured at http://localhost:8025
- Never sent to real recipients
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:
- Email client doesn't support modern CSS
- External images blocked by email client
- 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:
- Use Litmus or Email on Acid
- Test in Gmail, Outlook, Apple Mail, Yahoo Mail
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 &→&,<→<,>→>
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
Related Documentation
Frontend Documentation
- EmailTemplatesPage.tsx — Email templates list page with CRUD table
- EmailTemplateEditorPage.tsx — Split-pane editor with preview
- Components:
- Variable insertion panel
- Live preview renderer
- Test send form
- Version comparison modal
Backend Documentation
- Email Templates Module — API routes and schemas
GET /api/email-templates— List templates (with filters)POST /api/email-templates— Create templatePUT /api/email-templates/:id— Update templateDELETE /api/email-templates/:id— Delete template (system templates protected)POST /api/email-templates/:id/test— Send test emailGET /api/email-templates/:id/versions— Version historyPOST /api/email-templates/:id/rollback/:versionNumber— Restore version
- Email Service — Core email sending logic
sendFromTemplate()— Load, validate, interpolate, sendsend()— 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
- editor.md — Email template editor interface
- variables.md — Template variable system
- versioning.md — Template version history
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