# 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
```mermaid
flowchart TB
subgraph "Email Service Layer"
Service[EmailService
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
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](https://handlebarsjs.com/) for powerful variable interpolation with support for basic variables, conditional blocks, loops, and helpers.
### Basic Variables
**Syntax:** `{{VARIABLE_NAME}}`
**Example Template:**
```html
Dear {{USER_NAME}},
Thank you for signing up for {{SHIFT_TITLE}} on {{SHIFT_DATE}}.
We'll see you at {{SHIFT_LOCATION}} at {{SHIFT_TIME}}.
If you have any questions, email us at {{SUPPORT_EMAIL}}.
``` **Sample Data:** ```json { "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:** ```htmlDear Jane Smith,
Thank you for signing up for Door Knocking - Downtown on Saturday, March 15, 2026.
We'll see you at Campaign Office (123 Main St) at 10:00 AM - 2:00 PM.
If you have any questions, email us at volunteer@example.org.
``` --- ### Conditional Blocks **Syntax:** `{{#if CONDITION}} ... {{else}} ... {{/if}}` **Example Template:** ```htmlDear {{USER_NAME}},
Your shift confirmation for {{SHIFT_TITLE}} is below.
{{#if HAS_PHONE}}We'll call you at {{USER_PHONE}} if there are any changes.
{{else}}We recommend adding a phone number to your profile for shift updates.
{{/if}} {{#if IS_CUT_ASSIGNED}}You've been assigned to canvass {{CUT_NAME}}.
{{/if}} ``` **Sample Data:** ```json { "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:** ```htmlDear John Doe,
Your shift confirmation for Canvassing - North District is below.
We'll call you at (555) 123-4567 if there are any changes.
You've been assigned to canvass North District - Zone A.
``` **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:** ```htmlDear {{USER_NAME}},
Your email will be sent to the following representatives:
Your custom message:
{{CUSTOM_MESSAGE}}``` **Sample Data:** ```json { "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:** ```html
Dear Alice Johnson,
Your email will be sent to the following representatives:
Your custom message:
Please support Bill C-123 to address climate change.``` **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:** ```html
Dear {{USER_NAME}},
``` **Sample Data:** ```json { "USER_NAME": "Bob Wilson", "FORMATTED_MESSAGE": "This is bold and italic text.
Dear Bob Wilson,
``` **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`:** ```typescript 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: `Dear {{USER_NAME}},
Thank you for signing up for {{SHIFT_TITLE}}!
Details:
We'll call you at {{USER_PHONE}} if there are any changes.
{{/if}}See you there!
`, 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:** ```bash docker compose exec api npx prisma db seed ``` --- ### Step 3: Define Variables **Add variables in same seed script:** ```typescript 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:** ```typescript 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`: ```markdown ## 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:** ```typescript 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:** ```typescript 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):** ```typescript 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:** ```typescript // 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: RecordYour shift is scheduled for {{formatDate SHIFT_DATE "MMMM D, YYYY"}}.
You've knocked on {{DOOR_COUNT}} {{pluralize DOOR_COUNT "door" "doors"}}.
Campaign budget: {{currency CAMPAIGN_BUDGET}}
``` --- ### Error Handling **Custom Error Classes:** ```typescript // 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:** ```typescript 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:** ```sql SELECT * FROM email_templates WHERE key = 'shift-reminder'; ``` **Check active status:** ```sql SELECT key, is_active FROM email_templates WHERE key = 'shift-reminder'; ``` **Activate template:** ```sql 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:** ```typescript // 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:** ```typescript console.log('Template data:', JSON.stringify(options.data, null, 2)); ``` **Test Handlebars compilation:** ```typescript const Handlebars = require('handlebars'); const template = Handlebars.compile('Hello {{USER_NAME}}!'); console.log(template({ USER_NAME: 'Test' })); // Should output: "Hello Test!" ``` **Verify template content:** ```sql 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:** ```sql 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:** ```typescript 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):** ```sql 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:** ```bash # .env EMAIL_TEST_MODE=true ``` **Check recipient email:** ```typescript 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:** ```typescript 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):** ```htmlImportant message
Important message
``` **Use tables for layout (not flexbox/grid):** ```html|
Content here |
User message: {{USER_MESSAGE}}