diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index 7cd2421..f48048b 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -1,6 +1,7 @@ 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 @@ -10,6 +11,14 @@ const logger = require("../../../utils/logger"); * - 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 @@ -17,15 +26,76 @@ class TemplateManager { return TemplateManager.instance; } - this.templates = new Map(); + 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; } /** - * Initialize the template manager by loading all email templates + * 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() { @@ -39,133 +109,35 @@ class TemplateManager { // Start initialization and store the promise this.initializationPromise = (async () => { - await this.loadEmailTemplates(); - this.initialized = true; - logger.info("Email Template Manager initialized successfully"); - })(); + // Discover all available templates (fast - only reads filenames) + await this.discoverTemplates(); - return this.initializationPromise; - } - - /** - * Load all email templates from disk into memory - * @returns {Promise} - */ - async loadEmailTemplates() { - const templatesDir = path.join( - __dirname, - "..", - "..", - "..", - "templates", - "emails" - ); - - // Critical templates that must load for the app to function - const criticalTemplates = [ - "emailVerificationToUser.html", - "passwordResetToUser.html", - "passwordChangedToUser.html", - "personalInfoChangedToUser.html", - ]; - - try { - const templateFiles = [ - "conditionCheckReminderToUser.html", - "rentalConfirmationToUser.html", - "emailVerificationToUser.html", - "passwordResetToUser.html", - "passwordChangedToUser.html", - "personalInfoChangedToUser.html", - "lateReturnToCS.html", - "damageReportToCS.html", - "lostItemToCS.html", - "rentalRequestToOwner.html", - "rentalRequestConfirmationToRenter.html", - "rentalCancellationConfirmationToUser.html", - "rentalCancellationNotificationToUser.html", - "rentalDeclinedToRenter.html", - "rentalApprovalConfirmationToOwner.html", - "rentalCompletionThankYouToRenter.html", - "rentalCompletionCongratsToOwner.html", - "payoutReceivedToOwner.html", - "firstListingCelebrationToOwner.html", - "itemDeletionToOwner.html", - "alphaInvitationToUser.html", - "feedbackConfirmationToUser.html", - "feedbackNotificationToAdmin.html", - "newMessageToUser.html", - "forumCommentToPostAuthor.html", - "forumReplyToCommentAuthor.html", - "forumAnswerAcceptedToCommentAuthor.html", - "forumThreadActivityToParticipant.html", - "forumPostClosed.html", - "forumItemRequestNotification.html", - "forumPostDeletionToAuthor.html", - "forumCommentDeletionToAuthor.html", - "paymentDeclinedToRenter.html", - "paymentMethodUpdatedToOwner.html", - "authenticationRequiredToRenter.html", - "payoutFailedToOwner.html", - "accountDisconnectedToOwner.html", - "payoutsDisabledToOwner.html", - "disputeAlertToAdmin.html", - "disputeLostAlertToAdmin.html", - "userBannedNotification.html", - ]; - - const failedTemplates = []; - - for (const templateFile of templateFiles) { - try { - const templatePath = path.join(templatesDir, templateFile); - const templateContent = await fs.readFile(templatePath, "utf-8"); - const templateName = path.basename(templateFile, ".html"); - this.templates.set(templateName, templateContent); - logger.debug("Loaded template", { templateName }); - } catch (error) { - logger.error("Failed to load template", { - templateFile, - templatePath: path.join(templatesDir, templateFile), - error, - }); - failedTemplates.push(templateFile); + // 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); } } - logger.info("Loaded email templates", { - loaded: this.templates.size, - total: templateFiles.length, - }); - - // Check if critical templates are missing - const missingCriticalTemplates = criticalTemplates.filter( - (template) => !this.templates.has(path.basename(template, ".html")) - ); - - if (missingCriticalTemplates.length > 0) { + if (missingCritical.length > 0) { const error = new Error( - `Critical email templates failed to load: ${missingCriticalTemplates.join( - ", " - )}` + `Critical email templates not found: ${missingCritical.join(", ")}` ); - error.missingTemplates = missingCriticalTemplates; + error.missingTemplates = missingCritical; throw error; } - // Warn if non-critical templates failed - if (failedTemplates.length > 0) { - logger.warn("Non-critical templates failed to load, using fallback versions", { - failedTemplates, - }); - } - } catch (error) { - logger.error("Failed to load email templates", { - templatesDir, - error, + this.initialized = true; + logger.info("Email Template Manager initialized successfully", { + discovered: this.templateNames.size, + preloaded: CRITICAL_TEMPLATES.length, }); - throw error; // Re-throw to fail server startup - } + })(); + + return this.initializationPromise; } /** @@ -181,24 +153,36 @@ class TemplateManager { await this.initialize(); } - let template = this.templates.get(templateName); + let template; - if (!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, - availableTemplates: Array.from(this.templates.keys()), + discoveredTemplates: Array.from(this.templateNames), }); template = this.getFallbackTemplate(templateName); - } else { - logger.debug("Template found", { 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, variables[key] || ""); + rendered = rendered.replace(regex, value); }); } catch (error) { logger.error("Error rendering template", { @@ -212,25 +196,27 @@ class TemplateManager { } /** - * Get a fallback template when the HTML file is not available - * @param {string} templateName - Name of the template - * @returns {string} Fallback HTML template + * 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) { - const baseTemplate = ` + logger.warn("Using generic fallback template", { templateName }); + + return ` - {{title}} + Village Share @@ -240,7 +226,9 @@ class TemplateManager {
- {{content}} +

Hi {{recipientName}},

+

{{title}}

+

{{message}}