freealberta/influence/app/services/emailTemplates.js

262 lines
8.8 KiB
JavaScript

const fs = require('fs').promises;
const path = require('path');
class EmailTemplateService {
constructor() {
this.templatesDir = path.join(__dirname, '../templates/email');
this.cache = new Map();
}
async loadTemplate(templateName, type = 'html') {
const cacheKey = `${templateName}.${type}`;
// Check cache first
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
try {
const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
const template = await fs.readFile(templatePath, 'utf-8');
// Cache the template
this.cache.set(cacheKey, template);
return template;
} catch (error) {
console.error(`Failed to load email template ${templateName}.${type}:`, error);
throw new Error(`Email template not found: ${templateName}.${type}`);
}
}
processTemplate(template, variables) {
if (!template) return '';
let processed = template;
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
const value = variables[varName];
// Check if value exists and is not empty string
if (value !== undefined && value !== null && value !== '') {
// Recursively process the content inside the conditional block
return this.processTemplate(content, variables);
}
return '';
});
// Process MESSAGE content with special handling for placeholders and formatting
if (variables.MESSAGE) {
let messageContent = String(variables.MESSAGE);
// Replace user-facing placeholders in the message body
// [Representative Name] -> actual recipient name
if (variables.RECIPIENT_NAME) {
messageContent = messageContent.replace(/\[Representative Name\]/gi, variables.RECIPIENT_NAME);
}
// [Your Name] -> actual sender/user name
if (variables.SENDER_NAME || variables.USER_NAME) {
const userName = variables.SENDER_NAME || variables.USER_NAME;
messageContent = messageContent.replace(/\[Your Name\]/gi, userName);
}
// [Your Postal Code] -> actual postal code
if (variables.POSTAL_CODE) {
messageContent = messageContent.replace(/\[Your Postal Code\]/gi, variables.POSTAL_CODE);
}
// Convert message content to HTML with proper formatting
messageContent = this.formatMessageToHtml(messageContent);
// Update the variable with formatted content
variables = { ...variables, MESSAGE: messageContent };
}
// Replace variables {{VARIABLE}}
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
const value = variables[varName];
// Return the value or empty string if undefined
return value !== undefined && value !== null ? String(value) : '';
});
return processed;
}
/**
* Convert plain text message to HTML with proper formatting
* - Bullet points (lines starting with - or *) become <ul><li> lists
* - Numbered lists (lines starting with 1. 2. etc) become <ol><li> lists
* - Empty lines become paragraph breaks
* - Single line breaks become <br>
*/
formatMessageToHtml(text) {
if (!text) return '';
const lines = text.split('\n');
let html = '';
let inUnorderedList = false;
let inOrderedList = false;
let currentParagraph = [];
const closeLists = () => {
if (inUnorderedList) {
html += '</ul>';
inUnorderedList = false;
}
if (inOrderedList) {
html += '</ol>';
inOrderedList = false;
}
};
const flushParagraph = () => {
if (currentParagraph.length > 0) {
html += '<p>' + currentParagraph.join('<br>') + '</p>';
currentParagraph = [];
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// Check for unordered list item (- or * at start)
const unorderedMatch = trimmedLine.match(/^[-*]\s+(.+)$/);
if (unorderedMatch) {
flushParagraph();
if (inOrderedList) {
html += '</ol>';
inOrderedList = false;
}
if (!inUnorderedList) {
html += '<ul>';
inUnorderedList = true;
}
html += '<li>' + this.escapeHtml(unorderedMatch[1]) + '</li>';
continue;
}
// Check for ordered list item (1. 2. etc at start)
const orderedMatch = trimmedLine.match(/^(\d+)[.)]\s+(.+)$/);
if (orderedMatch) {
flushParagraph();
if (inUnorderedList) {
html += '</ul>';
inUnorderedList = false;
}
if (!inOrderedList) {
html += '<ol>';
inOrderedList = true;
}
html += '<li>' + this.escapeHtml(orderedMatch[2]) + '</li>';
continue;
}
// Close any open lists when we encounter non-list content
closeLists();
// Empty line = paragraph break
if (trimmedLine === '') {
flushParagraph();
continue;
}
// Regular text line - add to current paragraph
currentParagraph.push(this.escapeHtml(trimmedLine));
}
// Close any remaining lists
closeLists();
// Flush any remaining paragraph content
flushParagraph();
return html;
}
/**
* Escape HTML special characters to prevent XSS
*/
escapeHtml(text) {
const htmlEscapes = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
}
async render(templateName, variables) {
try {
// Load both HTML and text versions
const [htmlTemplate, textTemplate] = await Promise.all([
this.loadTemplate(templateName, 'html'),
this.loadTemplate(templateName, 'txt')
]);
// Add default variables
const defaultVariables = {
APP_NAME: process.env.APP_NAME || 'BNKops Influence Campaign',
TIMESTAMP: new Date().toLocaleString(),
...variables
};
// Use processTemplate which handles conditionals properly
return {
html: this.processTemplate(htmlTemplate, defaultVariables),
text: this.processTemplate(textTemplate, defaultVariables)
};
} catch (error) {
console.error('Failed to render email template:', error);
throw error;
}
}
// Get available template names
async getAvailableTemplates() {
try {
const files = await fs.readdir(this.templatesDir);
const templates = new Set();
files.forEach(file => {
const ext = path.extname(file);
const name = path.basename(file, ext);
if (ext === '.html' || ext === '.txt') {
templates.add(name);
}
});
return Array.from(templates);
} catch (error) {
console.error('Failed to get available templates:', error);
return [];
}
}
// Clear template cache (useful for development)
clearCache() {
this.cache.clear();
console.log('Email template cache cleared');
}
// Check if template exists
async templateExists(templateName) {
try {
const htmlPath = path.join(this.templatesDir, `${templateName}.html`);
const txtPath = path.join(this.templatesDir, `${templateName}.txt`);
// Check if at least one format exists
const [htmlExists, txtExists] = await Promise.all([
fs.access(htmlPath).then(() => true).catch(() => false),
fs.access(txtPath).then(() => true).catch(() => false)
]);
return htmlExists || txtExists;
} catch (error) {
return false;
}
}
}
module.exports = new EmailTemplateService();