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 '; inUnorderedList = false; } if (inOrderedList) { html += ''; inOrderedList = false; } }; const flushParagraph = () => { if (currentParagraph.length > 0) { html += '

' + currentParagraph.join('
') + '

'; 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 += ''; inOrderedList = false; } if (!inUnorderedList) { html += ''; inUnorderedList = false; } if (!inOrderedList) { html += '
    '; inOrderedList = true; } html += '
  1. ' + this.escapeHtml(orderedMatch[2]) + '
  2. '; 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; 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();