const fs = require("fs").promises; const path = require("path"); const logger = require("../../../utils/logger"); const { escapeHtml } = require("./emailUtils"); /** * TemplateManager handles loading, caching, and rendering email templates * This class is responsible for: * - Loading HTML email templates from disk * - Caching templates in memory for performance * - Rendering templates with variable substitution * - Providing fallback templates when files can't be loaded */ // Critical templates that must be preloaded at startup for auth flows const CRITICAL_TEMPLATES = [ "emailVerificationToUser", "passwordResetToUser", "passwordChangedToUser", "personalInfoChangedToUser", ]; class TemplateManager { constructor() { // Singleton pattern - return existing instance if already created if (TemplateManager.instance) { return TemplateManager.instance; } this.templates = new Map(); // Cached template content this.templateNames = new Set(); // Discovered template names this.initialized = false; this.initializationPromise = null; this.templatesDir = path.join( __dirname, "..", "..", "..", "templates", "emails" ); TemplateManager.instance = this; } /** * Discover all available templates by scanning the templates directory * Only reads filenames, not content (for fast startup) * @returns {Promise} */ async discoverTemplates() { try { const files = await fs.readdir(this.templatesDir); for (const file of files) { if (file.endsWith(".html")) { this.templateNames.add(file.replace(".html", "")); } } logger.info("Discovered email templates", { count: this.templateNames.size, }); } catch (error) { logger.error("Failed to discover email templates", { templatesDir: this.templatesDir, error, }); throw error; } } /** * Load a single template from disk (lazy loading) * @param {string} templateName - Name of the template (without .html extension) * @returns {Promise} Template content */ async loadTemplate(templateName) { // Return cached template if already loaded if (this.templates.has(templateName)) { return this.templates.get(templateName); } const templatePath = path.join(this.templatesDir, `${templateName}.html`); try { const content = await fs.readFile(templatePath, "utf-8"); this.templates.set(templateName, content); logger.debug("Loaded template", { templateName }); return content; } catch (error) { logger.error("Failed to load template", { templateName, templatePath, error, }); throw error; } } /** * Initialize the template manager by discovering templates and preloading critical ones * @returns {Promise} */ async initialize() { // If already initialized, return immediately if (this.initialized) return; // If initialization is in progress, wait for it if (this.initializationPromise) { return this.initializationPromise; } // Start initialization and store the promise this.initializationPromise = (async () => { // Discover all available templates (fast - only reads filenames) await this.discoverTemplates(); // Preload critical templates for auth flows const missingCritical = []; for (const templateName of CRITICAL_TEMPLATES) { if (!this.templateNames.has(templateName)) { missingCritical.push(templateName); } else { await this.loadTemplate(templateName); } } if (missingCritical.length > 0) { const error = new Error( `Critical email templates not found: ${missingCritical.join(", ")}` ); error.missingTemplates = missingCritical; throw error; } this.initialized = true; logger.info("Email Template Manager initialized successfully", { discovered: this.templateNames.size, preloaded: CRITICAL_TEMPLATES.length, }); })(); return this.initializationPromise; } /** * Render a template with the provided variables * @param {string} templateName - Name of the template to render * @param {Object} variables - Variables to substitute in the template * @returns {Promise} Rendered HTML */ async renderTemplate(templateName, variables = {}) { // Ensure service is initialized before rendering if (!this.initialized) { logger.debug("Template manager not initialized yet, initializing now..."); await this.initialize(); } let template; // Check if template exists in our discovered templates if (this.templateNames.has(templateName)) { // Lazy load the template if not already cached template = await this.loadTemplate(templateName); } else { logger.error("Template not found, using fallback", { templateName, discoveredTemplates: Array.from(this.templateNames), }); template = this.getFallbackTemplate(templateName); } let rendered = template; try { Object.keys(variables).forEach((key) => { // Variables ending in 'Html' or 'Section' contain trusted HTML content // (e.g., refundSection, stripeSection, earningsSection) - don't escape these const isTrustedHtml = key.endsWith("Html") || key.endsWith("Section"); let value = variables[key] || ""; // Escape HTML in user-provided values to prevent XSS if (!isTrustedHtml && typeof value === "string") { value = escapeHtml(value); } const regex = new RegExp(`{{${key}}}`, "g"); rendered = rendered.replace(regex, value); }); } catch (error) { logger.error("Error rendering template", { templateName, variableKeys: Object.keys(variables), error, }); } return rendered; } /** * Get a generic fallback template when the HTML file is not available * This is used as a last resort when a template cannot be loaded * @param {string} templateName - Name of the template (for logging) * @returns {string} Generic fallback HTML template */ getFallbackTemplate(templateName) { logger.warn("Using generic fallback template", { templateName }); return ` Village Share

Hi {{recipientName}},

{{title}}

{{message}}

`; } } module.exports = TemplateManager;