244 lines
7.7 KiB
JavaScript
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;
|