Files
rentall-app/backend/services/emailService.js
2025-10-15 15:19:23 -04:00

745 lines
24 KiB
JavaScript

const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses");
const fs = require("fs").promises;
const path = require("path");
const { getAWSConfig } = require("../config/aws");
const { User } = require("../models");
class EmailService {
constructor() {
this.sesClient = null;
this.initialized = false;
this.templates = new Map();
}
async initialize() {
if (this.initialized) return;
try {
// Use centralized AWS configuration with credential profiles
const awsConfig = getAWSConfig();
this.sesClient = new SESClient(awsConfig);
await this.loadEmailTemplates();
this.initialized = true;
console.log("SES Email Service initialized successfully");
} catch (error) {
console.error("Failed to initialize SES Email Service:", error);
throw error;
}
}
async loadEmailTemplates() {
const templatesDir = path.join(__dirname, "..", "templates", "emails");
try {
const templateFiles = [
"conditionCheckReminder.html",
"rentalConfirmation.html",
"emailVerification.html",
"passwordReset.html",
"passwordChanged.html",
"lateReturnCS.html",
"damageReportCS.html",
"lostItemCS.html",
"rentalRequest.html",
];
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);
} catch (error) {
console.warn(`Template ${templateFile} not found, will use fallback`);
}
}
console.log(`Loaded ${this.templates.size} email templates`);
} catch (error) {
console.warn("Templates directory not found, using fallback templates");
}
}
/**
* Convert HTML to plain text for email fallback
* Strips HTML tags and formats content for plain text email clients
*/
htmlToPlainText(html) {
return (
html
// Remove style and script tags and their content
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
// Convert common HTML elements to text equivalents
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<\/li>/gi, "\n")
.replace(/<\/h[1-6]>/gi, "\n\n")
.replace(/<li>/gi, "• ")
// Remove remaining HTML tags
.replace(/<[^>]+>/g, "")
// Decode HTML entities
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Remove emojis and special characters that don't render well in plain text
.replace(/[\u{1F600}-\u{1F64F}]/gu, "") // Emoticons
.replace(/[\u{1F300}-\u{1F5FF}]/gu, "") // Misc Symbols and Pictographs
.replace(/[\u{1F680}-\u{1F6FF}]/gu, "") // Transport and Map
.replace(/[\u{2600}-\u{26FF}]/gu, "") // Misc symbols
.replace(/[\u{2700}-\u{27BF}]/gu, "") // Dingbats
.replace(/[\u{FE00}-\u{FE0F}]/gu, "") // Variation Selectors
.replace(/[\u{1F900}-\u{1F9FF}]/gu, "") // Supplemental Symbols and Pictographs
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, "") // Symbols and Pictographs Extended-A
// Clean up excessive whitespace
.replace(/\n\s*\n\s*\n/g, "\n\n")
.trim()
);
}
async sendEmail(to, subject, htmlContent, textContent = null) {
if (!this.initialized) {
await this.initialize();
}
if (!process.env.EMAIL_ENABLED || process.env.EMAIL_ENABLED !== "true") {
console.log("Email sending disabled in environment");
return { success: true, messageId: "disabled" };
}
// Auto-generate plain text from HTML if not provided
if (!textContent) {
textContent = this.htmlToPlainText(htmlContent);
}
// Use friendly sender name format for better recognition
const fromName = process.env.SES_FROM_NAME || "RentAll";
const fromEmail = process.env.SES_FROM_EMAIL;
const source = `${fromName} <${fromEmail}>`;
const params = {
Source: source,
Destination: {
ToAddresses: Array.isArray(to) ? to : [to],
},
Message: {
Subject: {
Data: subject,
Charset: "UTF-8",
},
Body: {
Html: {
Data: htmlContent,
Charset: "UTF-8",
},
Text: {
Data: textContent,
Charset: "UTF-8",
},
},
},
};
if (process.env.SES_REPLY_TO_EMAIL) {
params.ReplyToAddresses = [process.env.SES_REPLY_TO_EMAIL];
}
try {
const command = new SendEmailCommand(params);
const result = await this.sesClient.send(command);
console.log(
`Email sent successfully to ${to}, MessageId: ${result.MessageId}`
);
return { success: true, messageId: result.MessageId };
} catch (error) {
console.error("Failed to send email:", error);
return { success: false, error: error.message };
}
}
renderTemplate(templateName, variables = {}) {
let template = this.templates.get(templateName);
if (!template) {
template = this.getFallbackTemplate(templateName);
}
let rendered = template;
Object.keys(variables).forEach((key) => {
const regex = new RegExp(`{{${key}}}`, "g");
rendered = rendered.replace(regex, variables[key] || "");
});
return rendered;
}
getFallbackTemplate(templateName) {
const baseTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</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>
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll</div>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p>
</div>
</div>
</body>
</html>
`;
const templates = {
conditionCheckReminder: 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>
`
),
rentalConfirmation: 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 RentAll!</p>
`
),
emailVerification: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2>
<p>Thank you for registering with RentAll! 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>
`
),
passwordReset: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2>
<p>We received a request to reset the password for your RentAll 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>
`
),
passwordChanged: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your RentAll 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>
`
),
rentalRequest: 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>Renter Notes:</strong> {{rentalNotes}}</p>
<p><a href="{{approveUrl}}" class="button">Review & Respond</a></p>
<p>Please respond to this request within 24 hours.</p>
`
),
};
return (
templates[templateName] ||
baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
`
)
);
}
async sendConditionCheckReminder(userEmail, notification, rental) {
const variables = {
title: notification.title,
message: notification.message,
itemName: rental?.item?.name || "Unknown Item",
deadline: notification.metadata?.deadline
? new Date(notification.metadata.deadline).toLocaleDateString()
: "Not specified",
};
const htmlContent = this.renderTemplate(
"conditionCheckReminder",
variables
);
return await this.sendEmail(
userEmail,
`RentAll: ${notification.title}`,
htmlContent
);
}
async sendRentalConfirmation(
userEmail,
notification,
rental,
recipientName = null
) {
const itemName = rental?.item?.name || "Unknown Item";
const variables = {
recipientName: recipientName || "there",
title: notification.title,
message: notification.message,
itemName: itemName,
startDate: rental?.startDateTime
? new Date(rental.startDateTime).toLocaleDateString()
: "Not specified",
endDate: rental?.endDateTime
? new Date(rental.endDateTime).toLocaleDateString()
: "Not specified",
};
const htmlContent = this.renderTemplate("rentalConfirmation", variables);
// Use clear, transactional subject line with item name
const subject = `Rental Confirmation - ${itemName}`;
return await this.sendEmail(userEmail, subject, htmlContent);
}
async sendVerificationEmail(user, verificationToken) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const verificationUrl = `${frontendUrl}/verify-email?token=${verificationToken}`;
const variables = {
recipientName: user.firstName || "there",
verificationUrl: verificationUrl,
};
const htmlContent = this.renderTemplate("emailVerification", variables);
return await this.sendEmail(
user.email,
"Verify Your Email - RentAll",
htmlContent
);
}
async sendPasswordResetEmail(user, resetToken) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
const variables = {
recipientName: user.firstName || "there",
resetUrl: resetUrl,
};
const htmlContent = this.renderTemplate("passwordReset", variables);
return await this.sendEmail(
user.email,
"Reset Your Password - RentAll",
htmlContent
);
}
async sendPasswordChangedEmail(user) {
const timestamp = new Date().toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short",
});
const variables = {
recipientName: user.firstName || "there",
email: user.email,
timestamp: timestamp,
};
const htmlContent = this.renderTemplate("passwordChanged", variables);
return await this.sendEmail(
user.email,
"Password Changed Successfully - RentAll",
htmlContent
);
}
async sendRentalRequestEmail(rental) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const approveUrl = `${frontendUrl}/my-listings?rentalId=${rental.id}`;
// Fetch owner details
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName", "lastName"],
});
// Fetch renter details
const renter = await User.findByPk(rental.renterId, {
attributes: ["firstName", "lastName"],
});
if (!owner || !renter) {
console.error(
"Owner or renter not found for rental request notification"
);
return { success: false, error: "User not found" };
}
const variables = {
ownerName: owner.firstName,
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "A renter",
itemName: rental.item?.name || "your item",
startDate: rental.startDateTime
? new Date(rental.startDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
endDate: rental.endDateTime
? new Date(rental.endDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: "Not specified",
totalAmount: rental.totalAmount
? parseFloat(rental.totalAmount).toFixed(2)
: "0.00",
payoutAmount: rental.payoutAmount
? parseFloat(rental.payoutAmount).toFixed(2)
: "0.00",
deliveryMethod: rental.deliveryMethod || "Not specified",
approveUrl: approveUrl,
};
const htmlContent = this.renderTemplate("rentalRequest", variables);
return await this.sendEmail(
owner.email,
`Rental Request for ${rental.item?.name || "Your Item"}`,
htmlContent
);
}
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
const htmlContent = this.renderTemplate(templateName, variables);
return await this.sendEmail(toEmail, subject, htmlContent);
}
async sendLateReturnToCustomerService(rental, lateCalculation) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error("Owner or renter not found for late return notification");
return;
}
// Format dates
const scheduledEnd = new Date(rental.endDateTime).toLocaleString();
const actualReturn = new Date(
rental.actualReturnDateTime
).toLocaleString();
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Late Return Detected - Action Required",
"lateReturnCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: owner.name,
ownerEmail: owner.email,
renterName: renter.name,
renterEmail: renter.email,
scheduledEnd,
actualReturn,
hoursLate: lateCalculation.lateHours.toFixed(1),
lateFee: lateCalculation.lateFee.toFixed(2),
}
);
console.log(
`Late return notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send late return notification to customer service:",
error
);
}
}
async sendDamageReportToCustomerService(
rental,
damageAssessment,
lateCalculation = null
) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error(
"Owner or renter not found for damage report notification"
);
return;
}
// Calculate total fees (ensure numeric values)
const damageFee = parseFloat(damageAssessment.feeCalculation.amount) || 0;
const lateFee = parseFloat(lateCalculation?.lateFee || 0);
const totalFees = damageFee + lateFee;
// Determine fee type description
let feeTypeDescription = "";
if (damageAssessment.feeCalculation.type === "repair") {
feeTypeDescription = "Repair Cost";
} else if (damageAssessment.feeCalculation.type === "replacement") {
feeTypeDescription = "Replacement Cost";
} else {
feeTypeDescription = "Damage Assessment Fee";
}
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Damage Report Filed - Action Required",
"damageReportCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: `${owner.firstName} ${owner.lastName}`,
ownerEmail: owner.email,
renterName: `${renter.firstName} ${renter.lastName}`,
renterEmail: renter.email,
damageDescription: damageAssessment.description,
canBeFixed: damageAssessment.canBeFixed ? "Yes" : "No",
repairCost: damageAssessment.repairCost
? damageAssessment.repairCost.toFixed(2)
: "N/A",
needsReplacement: damageAssessment.needsReplacement ? "Yes" : "No",
replacementCost: damageAssessment.replacementCost
? damageAssessment.replacementCost.toFixed(2)
: "N/A",
feeTypeDescription,
damageFee: damageFee.toFixed(2),
lateFee: lateFee.toFixed(2),
totalFees: totalFees.toFixed(2),
hasProofOfOwnership:
damageAssessment.proofOfOwnership &&
damageAssessment.proofOfOwnership.length > 0
? "Yes"
: "No",
}
);
console.log(
`Damage report notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send damage report notification to customer service:",
error
);
}
}
async sendLostItemToCustomerService(rental) {
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId);
const renter = await User.findByPk(rental.renterId);
if (!owner || !renter) {
console.error("Owner or renter not found for lost item notification");
return;
}
// Format dates
const reportedAt = new Date(rental.itemLostReportedAt).toLocaleString();
const scheduledReturnDate = new Date(rental.endDateTime).toLocaleString();
// Send email to customer service
await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL,
"Lost Item Claim Filed - Action Required",
"lostItemCS",
{
rentalId: rental.id,
itemName: rental.item.name,
ownerName: `${owner.firstName} ${owner.lastName}`,
ownerEmail: owner.email,
renterName: `${renter.firstName} ${renter.lastName}`,
renterEmail: renter.email,
reportedAt,
scheduledReturnDate,
replacementCost: parseFloat(rental.item.replacementCost).toFixed(2),
}
);
console.log(
`Lost item notification sent to customer service for rental ${rental.id}`
);
} catch (error) {
console.error(
"Failed to send lost item notification to customer service:",
error
);
}
}
async sendRentalConfirmationEmails(rental) {
const results = {
ownerEmailSent: false,
renterEmailSent: false,
};
try {
// Get owner and renter details
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName"],
});
const renter = await User.findByPk(rental.renterId, {
attributes: ["email", "firstName"],
});
// Create notification data for owner
const ownerNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your "${rental.item.name}" has been confirmed for rental.`,
rentalId: rental.id,
userId: rental.ownerId,
metadata: { rentalStart: rental.startDateTime },
};
// Create notification data for renter
const renterNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your rental of "${rental.item.name}" has been confirmed.`,
rentalId: rental.id,
userId: rental.renterId,
metadata: { rentalStart: rental.startDateTime },
};
// Send email to owner - independent error handling
if (owner?.email) {
try {
const ownerResult = await this.sendRentalConfirmation(
owner.email,
ownerNotification,
rental,
owner.firstName
);
if (ownerResult.success) {
console.log(
`Rental confirmation email sent to owner: ${owner.email}`
);
results.ownerEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
ownerResult.error
);
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to owner (${owner.email}):`,
error.message
);
}
}
// Send email to renter - independent error handling
if (renter?.email) {
try {
const renterResult = await this.sendRentalConfirmation(
renter.email,
renterNotification,
rental,
renter.firstName
);
if (renterResult.success) {
console.log(
`Rental confirmation email sent to renter: ${renter.email}`
);
results.renterEmailSent = true;
} else {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
renterResult.error
);
}
} catch (error) {
console.error(
`Failed to send rental confirmation email to renter (${renter.email}):`,
error.message
);
}
}
} catch (error) {
console.error(
"Error fetching user data for rental confirmation emails:",
error
);
}
return results;
}
}
module.exports = new EmailService();