Files
rentall-app/backend/services/email/core/TemplateManager.js
2026-01-14 23:42:04 -05:00

244 lines
7.7 KiB
JavaScript

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<void>}
*/
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<string>} 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<void>}
*/
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<string>} 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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Village Share</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; border-bottom: 2px solid #e9ecef; padding-bottom: 20px; margin-bottom: 30px; }
.logo { font-size: 24px; font-weight: bold; color: #333; }
.content { line-height: 1.6; color: #555; }
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Village Share</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h2>{{title}}</h2>
<p>{{message}}</p>
</div>
<div class="footer">
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
</div>
</div>
</body>
</html>
`;
}
}
module.exports = TemplateManager;