lazy loading email templates
This commit is contained in:
@@ -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<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() {
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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 `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{title}}</title>
|
||||
<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; }
|
||||
.button { display: inline-block; background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
|
||||
.footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #e9ecef; text-align: center; font-size: 12px; color: #6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -240,7 +226,9 @@ class TemplateManager {
|
||||
<div class="logo">Village Share</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
{{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>
|
||||
@@ -249,315 +237,6 @@ class TemplateManager {
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const templates = {
|
||||
conditionCheckReminderToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
<p><strong>Rental Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Deadline:</strong> {{deadline}}</p>
|
||||
<p>Please complete this condition check as soon as possible to ensure proper documentation.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p>Thank you for using Village Share!</p>
|
||||
`
|
||||
),
|
||||
|
||||
emailVerificationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Verify Your Email Address</h2>
|
||||
<p>Thank you for registering with Village Share! Please verify your email address by clicking the button below.</p>
|
||||
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
|
||||
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
|
||||
<p><strong>This link will expire in 24 hours.</strong></p>
|
||||
`
|
||||
),
|
||||
|
||||
passwordResetToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
|
||||
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
|
||||
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
|
||||
<p><strong>This link will expire in 1 hour.</strong></p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`
|
||||
),
|
||||
|
||||
passwordChangedToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Your Password Has Been Changed</h2>
|
||||
<p>This is a confirmation that the password for your Village Share account ({{email}}) has been successfully changed.</p>
|
||||
<p><strong>Changed on:</strong> {{timestamp}}</p>
|
||||
<p>For your security, all existing sessions have been logged out.</p>
|
||||
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalRequestToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
<h2>New Rental Request for {{itemName}}</h2>
|
||||
<p>{{renterName}} would like to rent your item.</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
|
||||
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
|
||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||
<p><strong>Intended Use:</strong> {{intendedUse}}</p>
|
||||
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
|
||||
<p>Please respond to this request within 24 hours.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalRequestConfirmationToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
<h2>Your Rental Request Has Been Submitted!</h2>
|
||||
<p>Your request to rent <strong>{{itemName}}</strong> has been sent to the owner.</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
|
||||
<p>{{paymentMessage}}</p>
|
||||
<p>You'll receive an email notification once the owner responds to your request.</p>
|
||||
<p><a href="{{viewRentalsUrl}}" class="button">View My Rentals</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Rental Cancelled Successfully</h2>
|
||||
<p>This confirms that your rental for <strong>{{itemName}}</strong> has been cancelled.</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
|
||||
{{refundSection}}
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationNotificationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Rental Cancellation Notice</h2>
|
||||
<p>{{cancellationMessage}}</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
|
||||
{{additionalInfo}}
|
||||
<p>If you have any questions or concerns, please reach out to our support team.</p>
|
||||
`
|
||||
),
|
||||
|
||||
payoutReceivedToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
<h2 style="color: #28a745;">Earnings Received: \${{payoutAmount}}</h2>
|
||||
<p>Great news! Your earnings from the rental of <strong>{{itemName}}</strong> have been transferred to your account.</p>
|
||||
<h3>Rental Details</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Transfer ID:</strong> {{stripeTransferId}}</p>
|
||||
<h3>Earnings Breakdown</h3>
|
||||
<p><strong>Rental Amount:</strong> \${{totalAmount}}</p>
|
||||
<p><strong>Community Upkeep Fee (10%):</strong> -\${{platformFee}}</p>
|
||||
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
|
||||
<p>Funds are typically available in your bank account within 2-3 business days.</p>
|
||||
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
|
||||
<p>Thank you for being a valued member of the Village Share community!</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalDeclinedToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
<h2>Rental Request Declined</h2>
|
||||
<p>Thank you for your interest in renting <strong>{{itemName}}</strong>. Unfortunately, the owner is unable to accept your rental request at this time.</p>
|
||||
<h3>Request Details</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||
{{ownerMessage}}
|
||||
<div class="info-box">
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<p>{{paymentMessage}}</p>
|
||||
<p>We encourage you to explore other similar items available for rent on Village Share. There are many great options waiting for you!</p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
|
||||
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalApprovalConfirmationToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
<h2>You've Approved the Rental Request!</h2>
|
||||
<p>You've successfully approved the rental request for <strong>{{itemName}}</strong>.</p>
|
||||
<h3>Rental Details</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Renter:</strong> {{renterName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
|
||||
{{stripeSection}}
|
||||
<h3>What's Next?</h3>
|
||||
<ul>
|
||||
<li>Coordinate with the renter on pickup details</li>
|
||||
<li>Take photos of the item's condition before handoff</li>
|
||||
<li>Provide any care instructions or usage tips</li>
|
||||
</ul>
|
||||
<p><a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalCompletionThankYouToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
<h2>Thank You for Returning On Time!</h2>
|
||||
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the Village Share community!</p>
|
||||
<h3>Rental Summary</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Returned On:</strong> {{returnedDate}}</p>
|
||||
{{reviewSection}}
|
||||
<p><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalCompletionCongratsToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
<h2>Congratulations on Completing a Rental!</h2>
|
||||
<p><strong>{{itemName}}</strong> has been successfully returned on time. Great job!</p>
|
||||
<h3>Rental Summary</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Renter:</strong> {{renterName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
{{earningsSection}}
|
||||
{{stripeSection}}
|
||||
<p><a href="{{owningUrl}}" class="button">View My Listings</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
feedbackConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{userName}},</p>
|
||||
<h2>Thank You for Your Feedback!</h2>
|
||||
<p>We've received your feedback and our team will review it carefully.</p>
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; font-style: italic;">
|
||||
{{feedbackText}}
|
||||
</div>
|
||||
<p><strong>Submitted:</strong> {{submittedAt}}</p>
|
||||
<p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
|
||||
<p>If your feedback requires a response, our team will reach out to you directly.</p>
|
||||
`
|
||||
),
|
||||
|
||||
feedbackNotificationToAdmin: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>New Feedback Received</h2>
|
||||
<p><strong>From:</strong> {{userName}} ({{userEmail}})</p>
|
||||
<p><strong>User ID:</strong> {{userId}}</p>
|
||||
<p><strong>Submitted:</strong> {{submittedAt}}</p>
|
||||
<h3>Feedback Content</h3>
|
||||
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0;">
|
||||
{{feedbackText}}
|
||||
</div>
|
||||
<h3>Technical Context</h3>
|
||||
<p><strong>Feedback ID:</strong> {{feedbackId}}</p>
|
||||
<p><strong>Page URL:</strong> {{url}}</p>
|
||||
<p><strong>User Agent:</strong> {{userAgent}}</p>
|
||||
<p>Please review this feedback and take appropriate action if needed.</p>
|
||||
`
|
||||
),
|
||||
|
||||
paymentDeclinedToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterFirstName}},</p>
|
||||
<h2>Payment Issue with Your Rental Request</h2>
|
||||
<p>The owner tried to approve your rental for <strong>{{itemName}}</strong>, but there was an issue processing your payment.</p>
|
||||
<h3>What Happened</h3>
|
||||
<p>{{declineReason}}</p>
|
||||
<div class="info-box">
|
||||
<p><strong>What You Can Do</strong></p>
|
||||
<p>Please update your payment method so the owner can complete the approval of your rental request.</p>
|
||||
</div>
|
||||
<p>Once you update your payment method, the owner will be notified and can try to approve your rental again.</p>
|
||||
`
|
||||
),
|
||||
|
||||
paymentMethodUpdatedToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerFirstName}},</p>
|
||||
<h2>Payment Method Updated</h2>
|
||||
<p>The renter has updated their payment method for the rental of <strong>{{itemName}}</strong>.</p>
|
||||
<div class="info-box">
|
||||
<p><strong>Ready to Approve</strong></p>
|
||||
<p>You can now try approving the rental request again. The renter's new payment method will be charged when you approve.</p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{{approvalUrl}}" class="button">Review & Approve Rental</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
userBannedNotification: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{userName}},</p>
|
||||
<h2>Your Account Has Been Suspended</h2>
|
||||
<p>Your Village Share account has been suspended by our moderation team.</p>
|
||||
<div style="background-color: #f8d7da; border-left: 4px solid #dc3545; padding: 20px; margin: 20px 0;">
|
||||
<p><strong>Reason for Suspension:</strong></p>
|
||||
<p>{{banReason}}</p>
|
||||
</div>
|
||||
<p>You have been logged out of all devices and cannot log in to your account.</p>
|
||||
<p>If you believe this suspension was made in error, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a>.</p>
|
||||
`
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
templates[templateName] ||
|
||||
baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>{{title}}</h2>
|
||||
<p>{{message}}</p>
|
||||
`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,9 +90,26 @@ function formatCurrency(amount, currency = "USD") {
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters to prevent XSS attacks
|
||||
* Converts characters that could be interpreted as HTML into safe entities
|
||||
* @param {*} str - Value to escape (will be converted to string)
|
||||
* @returns {string} HTML-escaped string safe for insertion into HTML
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
htmlToPlainText,
|
||||
formatEmailDate,
|
||||
formatShortDate,
|
||||
formatCurrency,
|
||||
escapeHtml,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,22 @@
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
readFile: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
},
|
||||
// Include sync methods needed by logger
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
mkdirSync: jest.fn(),
|
||||
statSync: jest.fn().mockReturnValue({ isDirectory: () => true }),
|
||||
readdirSync: jest.fn().mockReturnValue([]),
|
||||
unlinkSync: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the logger to avoid file system issues in tests
|
||||
jest.mock('../../../../utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Clear singleton between tests
|
||||
@@ -15,6 +30,12 @@ beforeEach(() => {
|
||||
describe('TemplateManager', () => {
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Helper to set up common mocks
|
||||
const setupMocks = (templateFiles = []) => {
|
||||
fs.readdir.mockResolvedValue(templateFiles.map((t) => `${t}.html`));
|
||||
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
|
||||
};
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a new instance', () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
@@ -22,6 +43,7 @@ describe('TemplateManager', () => {
|
||||
const manager = new TemplateManager();
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager.templates).toBeInstanceOf(Map);
|
||||
expect(manager.templateNames).toBeInstanceOf(Set);
|
||||
expect(manager.initialized).toBe(false);
|
||||
});
|
||||
|
||||
@@ -34,10 +56,89 @@ describe('TemplateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverTemplates', () => {
|
||||
it('should discover all HTML templates in directory', async () => {
|
||||
const templateFiles = [
|
||||
'emailVerificationToUser.html',
|
||||
'passwordResetToUser.html',
|
||||
'rentalConfirmationToUser.html',
|
||||
'README.md', // Should be ignored
|
||||
];
|
||||
fs.readdir.mockResolvedValue(templateFiles);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.discoverTemplates();
|
||||
|
||||
expect(manager.templateNames.size).toBe(3); // Only .html files
|
||||
expect(manager.templateNames.has('emailVerificationToUser')).toBe(true);
|
||||
expect(manager.templateNames.has('passwordResetToUser')).toBe(true);
|
||||
expect(manager.templateNames.has('rentalConfirmationToUser')).toBe(true);
|
||||
expect(manager.templateNames.has('README')).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if directory read fails', async () => {
|
||||
fs.readdir.mockRejectedValue(new Error('Directory not found'));
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await expect(manager.discoverTemplates()).rejects.toThrow('Directory not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTemplate', () => {
|
||||
it('should load template from disk on first access', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>Template Content</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const content = await manager.loadTemplate('testTemplate');
|
||||
|
||||
expect(content).toBe('<html>Template Content</html>');
|
||||
expect(fs.readFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return cached template on subsequent access', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>Template Content</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.loadTemplate('testTemplate');
|
||||
await manager.loadTemplate('testTemplate');
|
||||
await manager.loadTemplate('testTemplate');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledTimes(1); // Only read once
|
||||
});
|
||||
|
||||
it('should throw error if template file not found', async () => {
|
||||
fs.readFile.mockRejectedValue(new Error('ENOENT: no such file'));
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await expect(manager.loadTemplate('nonExistent')).rejects.toThrow('ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should load all templates on initialization', async () => {
|
||||
// Mock fs.readFile to return template content
|
||||
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
|
||||
const criticalTemplates = [
|
||||
'emailVerificationToUser',
|
||||
'passwordResetToUser',
|
||||
'passwordChangedToUser',
|
||||
'personalInfoChangedToUser',
|
||||
];
|
||||
|
||||
it('should discover templates and preload critical ones', async () => {
|
||||
setupMocks(criticalTemplates);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
@@ -46,26 +147,28 @@ describe('TemplateManager', () => {
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.initialized).toBe(true);
|
||||
expect(fs.readFile).toHaveBeenCalled();
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(1);
|
||||
// Should have loaded all 4 critical templates
|
||||
expect(fs.readFile).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should not re-initialize if already initialized', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
|
||||
setupMocks(criticalTemplates);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
const callCount = fs.readFile.mock.calls.length;
|
||||
const readCallCount = fs.readFile.mock.calls.length;
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(fs.readFile.mock.calls.length).toBe(callCount);
|
||||
expect(fs.readFile.mock.calls.length).toBe(readCallCount);
|
||||
});
|
||||
|
||||
it('should wait for existing initialization if in progress', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
|
||||
setupMocks(criticalTemplates);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
@@ -74,50 +177,45 @@ describe('TemplateManager', () => {
|
||||
// Start two initializations concurrently
|
||||
await Promise.all([manager.initialize(), manager.initialize()]);
|
||||
|
||||
// Should only load templates once
|
||||
const uniquePaths = new Set(fs.readFile.mock.calls.map((call) => call[0]));
|
||||
expect(uniquePaths.size).toBeLessThanOrEqual(fs.readFile.mock.calls.length);
|
||||
// Should only discover once
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw error if critical templates fail to load', async () => {
|
||||
// All template files fail to load
|
||||
fs.readFile.mockRejectedValue(new Error('File not found'));
|
||||
it('should throw error if critical templates are not found', async () => {
|
||||
// Only discover non-critical templates
|
||||
setupMocks(['rentalConfirmationToUser', 'feedbackToUser']);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await expect(manager.initialize()).rejects.toThrow('Critical email templates failed to load');
|
||||
await expect(manager.initialize()).rejects.toThrow('Critical email templates not found');
|
||||
});
|
||||
|
||||
it('should succeed if critical templates load but non-critical fail', async () => {
|
||||
const criticalTemplates = [
|
||||
'emailVerificationToUser',
|
||||
'passwordResetToUser',
|
||||
'passwordChangedToUser',
|
||||
'personalInfoChangedToUser',
|
||||
];
|
||||
|
||||
fs.readFile.mockImplementation((path) => {
|
||||
const isCritical = criticalTemplates.some((t) => path.includes(t));
|
||||
if (isCritical) {
|
||||
return Promise.resolve('<html>Template content</html>');
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
it('should succeed if critical templates exist even if non-critical are missing', async () => {
|
||||
// Discover critical templates plus some others
|
||||
setupMocks([...criticalTemplates, 'rentalConfirmationToUser']);
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
// Should not throw since critical templates loaded
|
||||
await expect(manager.initialize()).resolves.not.toThrow();
|
||||
expect(manager.initialized).toBe(true);
|
||||
expect(manager.templateNames.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
const criticalTemplates = [
|
||||
'emailVerificationToUser',
|
||||
'passwordResetToUser',
|
||||
'passwordChangedToUser',
|
||||
'personalInfoChangedToUser',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
fs.readdir.mockResolvedValue([...criticalTemplates, 'testTemplate'].map((t) => `${t}.html`));
|
||||
fs.readFile.mockResolvedValue('<html>Hello {{name}}, your email is {{email}}</html>');
|
||||
});
|
||||
|
||||
@@ -128,9 +226,6 @@ describe('TemplateManager', () => {
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
// Manually set a template for testing
|
||||
manager.templates.set('testTemplate', '<html>Hello {{name}}, your email is {{email}}</html>');
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
@@ -139,14 +234,32 @@ describe('TemplateManager', () => {
|
||||
expect(result).toBe('<html>Hello John, your email is john@example.com</html>');
|
||||
});
|
||||
|
||||
it('should replace all occurrences of a variable', async () => {
|
||||
it('should lazy load non-critical templates on first render', async () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
manager.templates.set('testTemplate', '<html>{{name}} {{name}} {{name}}</html>');
|
||||
// At this point, only critical templates are loaded
|
||||
const initialLoadCount = fs.readFile.mock.calls.length;
|
||||
expect(initialLoadCount).toBe(4); // Only critical templates
|
||||
|
||||
// Render a non-critical template
|
||||
await manager.renderTemplate('testTemplate', { name: 'John', email: 'test@example.com' });
|
||||
|
||||
// Should have loaded one more template
|
||||
expect(fs.readFile.mock.calls.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should replace all occurrences of a variable', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{name}} {{name}} {{name}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
name: 'John',
|
||||
@@ -155,15 +268,15 @@ describe('TemplateManager', () => {
|
||||
expect(result).toBe('<html>John John John</html>');
|
||||
});
|
||||
|
||||
it('should replace missing variables with empty string', async () => {
|
||||
it('should not replace unspecified variables', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>Hello {{name}}, {{missing}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
manager.templates.set('testTemplate', '<html>Hello {{name}}, {{missing}}</html>');
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
name: 'John',
|
||||
});
|
||||
@@ -171,7 +284,7 @@ describe('TemplateManager', () => {
|
||||
expect(result).toBe('<html>Hello John, {{missing}}</html>');
|
||||
});
|
||||
|
||||
it('should use fallback template when template not found', async () => {
|
||||
it('should use fallback template when template not discovered', async () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
@@ -181,11 +294,13 @@ describe('TemplateManager', () => {
|
||||
const result = await manager.renderTemplate('nonExistentTemplate', {
|
||||
title: 'Test Title',
|
||||
message: 'Test Message',
|
||||
recipientName: 'John',
|
||||
});
|
||||
|
||||
// Should return fallback template content
|
||||
expect(result).toContain('Test Title');
|
||||
expect(result).toContain('Test Message');
|
||||
expect(result).toContain('John');
|
||||
expect(result).toContain('Village Share');
|
||||
});
|
||||
|
||||
@@ -196,86 +311,143 @@ describe('TemplateManager', () => {
|
||||
|
||||
expect(manager.initialized).toBe(false);
|
||||
|
||||
await manager.renderTemplate('someTemplate', {});
|
||||
await manager.renderTemplate('testTemplate', {});
|
||||
|
||||
expect(manager.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty variables object', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>No variables</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
manager.templates.set('testTemplate', '<html>No variables</html>');
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {});
|
||||
|
||||
expect(result).toBe('<html>No variables</html>');
|
||||
});
|
||||
|
||||
it('should handle null or undefined variable values', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>Hello {{name}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
manager.templates.set('testTemplate', '<html>Hello {{name}}</html>');
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
name: null,
|
||||
});
|
||||
|
||||
expect(result).toBe('<html>Hello </html>');
|
||||
});
|
||||
|
||||
it('should escape HTML in variables to prevent XSS', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>Hello {{name}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
name: '<script>alert("XSS")</script>',
|
||||
});
|
||||
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).toContain('</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('should escape all HTML special characters', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
content: '<div class="test" data-attr=\'value\'>©</div>',
|
||||
});
|
||||
|
||||
expect(result).toContain('<div');
|
||||
expect(result).toContain('"test"');
|
||||
expect(result).toContain(''value'');
|
||||
expect(result).toContain('&copy;');
|
||||
expect(result).not.toContain('<div');
|
||||
});
|
||||
|
||||
it('should NOT escape variables ending in Section (trusted HTML)', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{refundSection}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
refundSection: '<p>Your refund of <strong>$50.00</strong> is processing.</p>',
|
||||
});
|
||||
|
||||
// Should contain actual HTML, not escaped
|
||||
expect(result).toContain('<p>Your refund');
|
||||
expect(result).toContain('<strong>$50.00</strong>');
|
||||
expect(result).not.toContain('<p>');
|
||||
});
|
||||
|
||||
it('should NOT escape variables ending in Html (trusted HTML)', async () => {
|
||||
fs.readFile.mockResolvedValue('<html>{{messageHtml}}</html>');
|
||||
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
const result = await manager.renderTemplate('testTemplate', {
|
||||
messageHtml: '<a href="https://example.com">Click here</a>',
|
||||
});
|
||||
|
||||
// Should contain actual HTML link, not escaped
|
||||
expect(result).toContain('<a href="https://example.com">');
|
||||
expect(result).not.toContain('<a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFallbackTemplate', () => {
|
||||
it('should return specific fallback for known templates', async () => {
|
||||
it('should return generic fallback template', () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const fallback = manager.getFallbackTemplate('emailVerificationToUser');
|
||||
|
||||
expect(fallback).toContain('Verify Your Email');
|
||||
expect(fallback).toContain('{{verificationUrl}}');
|
||||
});
|
||||
|
||||
it('should return specific fallback for password reset', async () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const fallback = manager.getFallbackTemplate('passwordResetToUser');
|
||||
|
||||
expect(fallback).toContain('Reset Your Password');
|
||||
expect(fallback).toContain('{{resetUrl}}');
|
||||
});
|
||||
|
||||
it('should return specific fallback for rental request', async () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const fallback = manager.getFallbackTemplate('rentalRequestToOwner');
|
||||
|
||||
expect(fallback).toContain('New Rental Request');
|
||||
expect(fallback).toContain('{{itemName}}');
|
||||
});
|
||||
|
||||
it('should return generic fallback for unknown templates', async () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const fallback = manager.getFallbackTemplate('unknownTemplate');
|
||||
const fallback = manager.getFallbackTemplate('anyTemplate');
|
||||
|
||||
expect(fallback).toContain('{{title}}');
|
||||
expect(fallback).toContain('{{message}}');
|
||||
expect(fallback).toContain('{{recipientName}}');
|
||||
expect(fallback).toContain('Village Share');
|
||||
});
|
||||
|
||||
it('should return same fallback for all template names', () => {
|
||||
const TemplateManager = require('../../../../services/email/core/TemplateManager');
|
||||
TemplateManager.instance = null;
|
||||
const manager = new TemplateManager();
|
||||
|
||||
const fallback1 = manager.getFallbackTemplate('emailVerificationToUser');
|
||||
const fallback2 = manager.getFallbackTemplate('rentalRequestToOwner');
|
||||
const fallback3 = manager.getFallbackTemplate('unknownTemplate');
|
||||
|
||||
// All fallbacks should be the same generic template
|
||||
expect(fallback1).toBe(fallback2);
|
||||
expect(fallback2).toBe(fallback3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user