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", ]; 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(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') // Convert common HTML elements to text equivalents .replace(//gi, '\n') .replace(/<\/p>/gi, '\n\n') .replace(/<\/div>/gi, '\n') .replace(/<\/li>/gi, '\n') .replace(/<\/h[1-6]>/gi, '\n\n') .replace(/
  • /gi, '• ') // Remove remaining HTML tags .replace(/<[^>]+>/g, '') // Decode HTML entities .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/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 = ` {{title}}
    {{content}}
    `; const templates = { conditionCheckReminder: baseTemplate.replace( "{{content}}", `

    {{title}}

    {{message}}

    Rental Item: {{itemName}}

    Deadline: {{deadline}}

    Please complete this condition check as soon as possible to ensure proper documentation.

    ` ), rentalConfirmation: baseTemplate.replace( "{{content}}", `

    Hi {{recipientName}},

    {{title}}

    {{message}}

    Item: {{itemName}}

    Rental Period: {{startDate}} to {{endDate}}

    Thank you for using RentAll!

    ` ), emailVerification: baseTemplate.replace( "{{content}}", `

    Hi {{recipientName}},

    Verify Your Email Address

    Thank you for registering with RentAll! Please verify your email address by clicking the button below.

    Verify Email Address

    If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}

    This link will expire in 24 hours.

    ` ), passwordReset: baseTemplate.replace( "{{content}}", `

    Hi {{recipientName}},

    Reset Your Password

    We received a request to reset the password for your RentAll account. Click the button below to choose a new password.

    Reset Password

    If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}

    This link will expire in 1 hour.

    If you didn't request this, you can safely ignore this email.

    ` ), passwordChanged: baseTemplate.replace( "{{content}}", `

    Hi {{recipientName}},

    Your Password Has Been Changed

    This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.

    Changed on: {{timestamp}}

    For your security, all existing sessions have been logged out.

    Didn't change your password? If you did not make this change, please contact our support team immediately.

    ` ), }; return ( templates[templateName] || baseTemplate.replace( "{{content}}", `

    {{title}}

    {{message}}

    ` ) ); } 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 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 const damageFee = damageAssessment.feeCalculation.amount; const lateFee = 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();