diff --git a/backend/jobs/conditionCheckReminder.js b/backend/jobs/conditionCheckReminder.js index df792d1..99ee42e 100644 --- a/backend/jobs/conditionCheckReminder.js +++ b/backend/jobs/conditionCheckReminder.js @@ -6,7 +6,7 @@ const { ConditionCheck, } = require("../models"); const { Op } = require("sequelize"); -const emailService = require("../services/emailService"); +const emailServices = require("../services/email"); const logger = require("../utils/logger"); const reminderSchedule = "0 * * * *"; // Run every hour @@ -170,7 +170,7 @@ class ConditionCheckReminderJob { }, }; - await emailService.sendConditionCheckReminder( + await emailServices.rentalReminder.sendConditionCheckReminder( rental.owner.email, notificationData, rental @@ -195,7 +195,7 @@ class ConditionCheckReminderJob { }, }; - await emailService.sendConditionCheckReminder( + await emailServices.rentalReminder.sendConditionCheckReminder( rental.renter.email, notificationData, rental @@ -220,7 +220,7 @@ class ConditionCheckReminderJob { }, }; - await emailService.sendConditionCheckReminder( + await emailServices.rentalReminder.sendConditionCheckReminder( rental.renter.email, notificationData, rental @@ -245,7 +245,7 @@ class ConditionCheckReminderJob { }, }; - await emailService.sendConditionCheckReminder( + await emailServices.rentalReminder.sendConditionCheckReminder( rental.owner.email, notificationData, rental diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 62c53fd..0e60b89 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -3,7 +3,7 @@ const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); const { User, AlphaInvitation } = require("../models"); // Import from models/index.js to get models with associations const logger = require("../utils/logger"); -const emailService = require("../services/emailService"); +const emailServices = require("../services/email"); const crypto = require("crypto"); const { @@ -117,7 +117,7 @@ router.post( // Send verification email (don't block registration if email fails) let verificationEmailSent = false; try { - await emailService.sendVerificationEmail(user, user.verificationToken); + await emailServices.auth.sendVerificationEmail(user, user.verificationToken); verificationEmailSent = true; } catch (emailError) { const reqLogger = logger.withRequestId(req.id); @@ -558,7 +558,7 @@ router.post( // Send verification email try { - await emailService.sendVerificationEmail(user, user.verificationToken); + await emailServices.auth.sendVerificationEmail(user, user.verificationToken); } catch (emailError) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Failed to resend verification email", { @@ -726,7 +726,7 @@ router.post( // Send password reset email try { - await emailService.sendPasswordResetEmail(user, resetToken); + await emailServices.auth.sendPasswordResetEmail(user, resetToken); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Password reset email sent", { @@ -868,7 +868,7 @@ router.post( // Send password changed notification email try { - await emailService.sendPasswordChangedEmail(user); + await emailServices.auth.sendPasswordChangedEmail(user); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Password changed notification sent", { userId: user.id, diff --git a/backend/routes/feedback.js b/backend/routes/feedback.js index fb36200..5e35243 100644 --- a/backend/routes/feedback.js +++ b/backend/routes/feedback.js @@ -3,7 +3,7 @@ const { Feedback, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); const { validateFeedback, sanitizeInput } = require('../middleware/validation'); const logger = require('../utils/logger'); -const emailService = require('../services/emailService'); +const emailServices = require('../services/email'); const router = express.Router(); // Submit new feedback @@ -29,7 +29,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, // Send confirmation email to user try { - await emailService.sendFeedbackConfirmation(req.user, feedback); + await emailServices.feedback.sendFeedbackConfirmation(req.user, feedback); } catch (emailError) { reqLogger.error("Failed to send feedback confirmation email", { error: emailError.message, @@ -41,7 +41,7 @@ router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, // Send notification email to admin try { - await emailService.sendFeedbackNotificationToAdmin(req.user, feedback); + await emailServices.feedback.sendFeedbackNotificationToAdmin(req.user, feedback); } catch (emailError) { reqLogger.error("Failed to send feedback notification to admin", { error: emailError.message, diff --git a/backend/routes/forum.js b/backend/routes/forum.js index 4d5f385..b01a9bb 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -4,7 +4,7 @@ const { ForumPost, ForumComment, PostTag, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const logger = require('../utils/logger'); -const emailService = require('../services/emailService'); +const emailServices = require('../services/email'); const router = express.Router(); // Helper function to build nested comment tree @@ -489,7 +489,7 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => // Only send email if not marking your own comment as answer if (comment && comment.authorId !== req.user.id) { - await emailService.sendForumAnswerAcceptedNotification( + await emailServices.forum.sendForumAnswerAcceptedNotification( comment.author, postAuthor, post, @@ -617,7 +617,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, // Send reply notification if not replying to yourself if (parentComment && parentComment.authorId !== req.user.id) { - await emailService.sendForumReplyNotification( + await emailServices.forum.sendForumReplyNotification( parentComment.author, commenter, postWithAuthor, @@ -629,7 +629,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, } else { // Send comment notification to post author if not commenting on your own post if (postWithAuthor.authorId !== req.user.id) { - await emailService.sendForumCommentNotification( + await emailServices.forum.sendForumCommentNotification( postWithAuthor.author, commenter, postWithAuthor, @@ -662,7 +662,7 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, // Send thread activity notifications to all unique participants for (const participant of participants) { if (participant.author) { - await emailService.sendForumThreadActivityNotification( + await emailServices.forum.sendForumThreadActivityNotification( participant.author, commenter, postWithAuthor, diff --git a/backend/routes/items.js b/backend/routes/items.js index 3d5e259..8ac0dc1 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -238,8 +238,8 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { // If first listing, send celebration email if (ownerItemCount === 1) { try { - const emailService = require("../services/emailService"); - await emailService.sendFirstListingCelebrationEmail( + const emailServices = require("../services/email"); + await emailServices.userEngagement.sendFirstListingCelebrationEmail( itemWithOwner.owner, itemWithOwner ); diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 6739faa..c1fc02d 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -6,7 +6,7 @@ const { uploadMessageImage } = require('../middleware/upload'); const logger = require('../utils/logger'); const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket'); const { Op } = require('sequelize'); -const emailService = require('../services/emailService'); +const emailServices = require('../services/email'); const fs = require('fs'); const path = require('path'); const router = express.Router(); @@ -293,7 +293,7 @@ router.post('/', authenticateToken, uploadMessageImage, async (req, res) => { attributes: ['id', 'firstName', 'lastName', 'email'] }); - await emailService.sendNewMessageNotification(receiver, sender, message); + await emailServices.messaging.sendNewMessageNotification(receiver, sender, message); } catch (emailError) { // Log email error but don't block the message send const reqLogger = logger.withRequestId(req.id); diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 12b96ea..fa96e4b 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -7,7 +7,7 @@ const RentalDurationCalculator = require("../utils/rentalDurationCalculator"); const RefundService = require("../services/refundService"); const LateReturnService = require("../services/lateReturnService"); const DamageAssessmentService = require("../services/damageAssessmentService"); -const emailService = require("../services/emailService"); +const emailServices = require("../services/email"); const logger = require("../utils/logger"); const router = express.Router(); @@ -302,7 +302,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { // Send rental request notification to owner try { - await emailService.sendRentalRequestEmail(rentalWithDetails); + await emailServices.rentalFlow.sendRentalRequestEmail(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental request notification sent to owner", { rentalId: rental.id, @@ -320,7 +320,7 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => { // Send rental request confirmation to renter try { - await emailService.sendRentalRequestConfirmationEmail(rentalWithDetails); + await emailServices.rentalFlow.sendRentalRequestConfirmationEmail(rentalWithDetails.renter, rentalWithDetails); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental request confirmation sent to renter", { rentalId: rental.id, @@ -444,7 +444,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => { // Send confirmation emails // Send approval confirmation to owner with Stripe reminder try { - await emailService.sendRentalApprovalConfirmationEmail(updatedRental); + await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental approval confirmation sent to owner", { rentalId: updatedRental.id, @@ -473,7 +473,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => { userId: updatedRental.renterId, metadata: { rentalStart: updatedRental.startDateTime }, }; - await emailService.sendRentalConfirmation( + await emailServices.rentalFlow.sendRentalConfirmation( renter.email, renterNotification, updatedRental, @@ -536,7 +536,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => { // Send confirmation emails // Send approval confirmation to owner (for free rentals, no Stripe reminder shown) try { - await emailService.sendRentalApprovalConfirmationEmail(updatedRental); + await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(updatedRental.owner, updatedRental.renter, updatedRental); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental approval confirmation sent to owner", { rentalId: updatedRental.id, @@ -565,7 +565,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => { userId: updatedRental.renterId, metadata: { rentalStart: updatedRental.startDateTime }, }; - await emailService.sendRentalConfirmation( + await emailServices.rentalFlow.sendRentalConfirmation( renter.email, renterNotification, updatedRental, @@ -686,7 +686,7 @@ router.put("/:id/decline", authenticateToken, async (req, res) => { // Send decline notification email to renter try { - await emailService.sendRentalDeclinedEmail(updatedRental, reason); + await emailServices.rentalFlow.sendRentalDeclinedEmail(updatedRental.renter, updatedRental, reason); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental decline notification sent to renter", { rentalId: rental.id, @@ -1060,7 +1060,9 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => { // Send cancellation notification emails try { - await emailService.sendRentalCancellationEmails( + await emailServices.rentalFlow.sendRentalCancellationEmails( + updatedRental.owner, + updatedRental.renter, updatedRental, result.refund ); @@ -1153,7 +1155,7 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => { // Send completion emails to both renter and owner try { - await emailService.sendRentalCompletionEmails(rentalWithDetails); + await emailServices.rentalFlow.sendRentalCompletionEmails(rentalWithDetails.owner, rentalWithDetails.renter, rentalWithDetails); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Rental completion emails sent", { rentalId, @@ -1221,7 +1223,9 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => { }); // Send notification to customer service - await emailService.sendLostItemToCustomerService(updatedRental); + const owner = await User.findByPk(rental.ownerId); + const renter = await User.findByPk(rental.renterId); + await emailServices.customerService.sendLostItemToCustomerService(updatedRental, owner, renter); break; default: diff --git a/backend/scripts/manageAlphaInvitations.js b/backend/scripts/manageAlphaInvitations.js index 350ec74..c41518f 100644 --- a/backend/scripts/manageAlphaInvitations.js +++ b/backend/scripts/manageAlphaInvitations.js @@ -7,7 +7,7 @@ const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const { AlphaInvitation, User, sequelize } = require("../models"); -const emailService = require("../services/emailService"); +const emailServices = require("../services/email"); const logger = require("../utils/logger"); // Generate unique alpha code @@ -69,7 +69,7 @@ async function addInvitation(email, notes = "") { // Send invitation email let emailSent = false; try { - await emailService.sendAlphaInvitation(email, code); + await emailServices.alphaInvitation.sendAlphaInvitation(email, code); emailSent = true; } catch (emailError) { console.log(`\n⚠️ Warning: Failed to send email to ${email}`); @@ -131,7 +131,7 @@ async function resendInvitation(emailOrCode) { // Resend the email try { - await emailService.sendAlphaInvitation(invitation.email, invitation.code); + await emailServices.alphaInvitation.sendAlphaInvitation(invitation.email, invitation.code); console.log(`\n✅ Alpha invitation resent successfully!`); console.log(` Email: ${invitation.email}`); diff --git a/backend/services/damageAssessmentService.js b/backend/services/damageAssessmentService.js index 36899a0..ad0f947 100644 --- a/backend/services/damageAssessmentService.js +++ b/backend/services/damageAssessmentService.js @@ -1,6 +1,6 @@ -const { Rental, Item, ConditionCheck } = require("../models"); +const { Rental, Item, ConditionCheck, User } = require("../models"); const LateReturnService = require("./lateReturnService"); -const emailService = require("./emailService"); +const emailServices = require("./email"); class DamageAssessmentService { /** @@ -119,9 +119,15 @@ class DamageAssessmentService { const updatedRental = await rental.update(updates); + // Fetch owner and renter user data for email + const owner = await User.findByPk(updatedRental.ownerId); + const renter = await User.findByPk(updatedRental.renterId); + // Send damage report to customer service for review - await emailService.sendDamageReportToCustomerService( + await emailServices.customerService.sendDamageReportToCustomerService( updatedRental, + owner, + renter, damageAssessment, lateCalculation ); diff --git a/backend/services/email/core/EmailClient.js b/backend/services/email/core/EmailClient.js new file mode 100644 index 0000000..252821b --- /dev/null +++ b/backend/services/email/core/EmailClient.js @@ -0,0 +1,110 @@ +const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses"); +const { getAWSConfig } = require("../../../config/aws"); +const { htmlToPlainText } = require("./emailUtils"); + +/** + * EmailClient handles AWS SES configuration and core email sending functionality + * This class is responsible for: + * - Initializing the AWS SES client + * - Sending emails with HTML and plain text content + * - Managing email sending state (enabled/disabled via environment) + */ +class EmailClient { + constructor() { + this.sesClient = null; + this.initialized = false; + } + + /** + * Initialize the AWS SES client + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + try { + // Use centralized AWS configuration with credential profiles + const awsConfig = getAWSConfig(); + this.sesClient = new SESClient(awsConfig); + + this.initialized = true; + console.log("AWS SES Email Client initialized successfully"); + } catch (error) { + console.error("Failed to initialize AWS SES Email Client:", error); + throw error; + } + } + + /** + * Send an email using AWS SES + * @param {string|string[]} to - Email address(es) to send to + * @param {string} subject - Email subject line + * @param {string} htmlContent - HTML content of the email + * @param {string|null} textContent - Plain text content (auto-generated from HTML if not provided) + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendEmail(to, subject, htmlContent, textContent = null) { + if (!this.initialized) { + await this.initialize(); + } + + // Check if email sending is enabled in the environment + 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 = 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", + }, + }, + }, + }; + + // Add reply-to address if configured + 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 }; + } + } +} + +module.exports = EmailClient; diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js new file mode 100644 index 0000000..baa459f --- /dev/null +++ b/backend/services/email/core/TemplateManager.js @@ -0,0 +1,443 @@ +const fs = require("fs").promises; +const path = require("path"); + +/** + * 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 + */ +class TemplateManager { + constructor() { + this.templates = new Map(); + this.initialized = false; + } + + /** + * Initialize the template manager by loading all email templates + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await this.loadEmailTemplates(); + this.initialized = true; + console.log("Email Template Manager initialized successfully"); + } + + /** + * Load all email templates from disk into memory + * @returns {Promise} + */ + async loadEmailTemplates() { + const templatesDir = path.join(__dirname, "..", "..", "..", "templates", "emails"); + + try { + const templateFiles = [ + "conditionCheckReminderToUser.html", + "rentalConfirmationToUser.html", + "emailVerificationToUser.html", + "passwordResetToUser.html", + "passwordChangedToUser.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", + "alphaInvitationToUser.html", + "feedbackConfirmationToUser.html", + "feedbackNotificationToAdmin.html", + "newMessageToUser.html", + "forumCommentToPostAuthor.html", + "forumReplyToCommentAuthor.html", + "forumAnswerAcceptedToCommentAuthor.html", + "forumThreadActivityToParticipant.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); + console.log(`✓ Loaded template: ${templateName}`); + } catch (error) { + console.error( + `✗ Failed to load template ${templateFile}:`, + error.message + ); + console.error( + ` Template path: ${path.join(templatesDir, templateFile)}` + ); + } + } + + console.log( + `Loaded ${this.templates.size} of ${templateFiles.length} email templates` + ); + } catch (error) { + console.error("Failed to load email templates:", error); + console.error("Templates directory:", templatesDir); + console.error("Error stack:", error.stack); + } + } + + /** + * 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} Rendered HTML + */ + async renderTemplate(templateName, variables = {}) { + // Ensure service is initialized before rendering + if (!this.initialized) { + console.log(`Template manager not initialized yet, initializing now...`); + await this.initialize(); + } + + let template = this.templates.get(templateName); + + if (!template) { + console.error(`Template not found: ${templateName}`); + console.error( + `Available templates: ${Array.from(this.templates.keys()).join(", ")}` + ); + console.error(`Stack trace:`, new Error().stack); + console.log(`Using fallback template for: ${templateName}`); + template = this.getFallbackTemplate(templateName); + } else { + console.log(`✓ Template found: ${templateName}`); + } + + let rendered = template; + + try { + Object.keys(variables).forEach((key) => { + const regex = new RegExp(`{{${key}}}`, "g"); + rendered = rendered.replace(regex, variables[key] || ""); + }); + } catch (error) { + console.error(`Error rendering template ${templateName}:`, error); + console.error(`Stack trace:`, error.stack); + console.error(`Variables provided:`, Object.keys(variables)); + } + + return rendered; + } + + /** + * Get a fallback template when the HTML file is not available + * @param {string} templateName - Name of the template + * @returns {string} Fallback HTML template + */ + getFallbackTemplate(templateName) { + const baseTemplate = ` + + + + + + {{title}} + + + +
+
+ +
+
+ {{content}} +
+ +
+ + + `; + + const templates = { + conditionCheckReminderToUser: baseTemplate.replace( + "{{content}}", + ` +

{{title}}

+

{{message}}

+

Rental Item: {{itemName}}

+

Deadline: {{deadline}}

+

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

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

Hi {{recipientName}},

+

{{title}}

+

{{message}}

+

Item: {{itemName}}

+

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

+

Thank you for using RentAll!

+ ` + ), + + emailVerificationToUser: 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.

+ ` + ), + + passwordResetToUser: 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.

+ ` + ), + + passwordChangedToUser: 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.

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

Hi {{ownerName}},

+

New Rental Request for {{itemName}}

+

{{renterName}} would like to rent your item.

+

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

+

Total Amount: ${{totalAmount}}

+

Your Earnings: ${{payoutAmount}}

+

Delivery Method: {{deliveryMethod}}

+

Renter Notes: {{rentalNotes}}

+

Review & Respond

+

Please respond to this request within 24 hours.

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

Hi {{renterName}},

+

Your Rental Request Has Been Submitted!

+

Your request to rent {{itemName}} has been sent to the owner.

+

Item: {{itemName}}

+

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

+

Delivery Method: {{deliveryMethod}}

+

Total Amount: ${{totalAmount}}

+

{{paymentMessage}}

+

You'll receive an email notification once the owner responds to your request.

+

View My Rentals

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

Hi {{recipientName}},

+

Rental Cancelled Successfully

+

This confirms that your rental for {{itemName}} has been cancelled.

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Cancelled On: {{cancelledAt}}

+ {{refundSection}} + ` + ), + + rentalCancellationNotificationToUser: baseTemplate.replace( + "{{content}}", + ` +

Hi {{recipientName}},

+

Rental Cancellation Notice

+

{{cancellationMessage}}

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Cancelled On: {{cancelledAt}}

+ {{additionalInfo}} +

If you have any questions or concerns, please reach out to our support team.

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

Hi {{ownerName}},

+

Earnings Received: ${{payoutAmount}}

+

Great news! Your earnings from the rental of {{itemName}} have been transferred to your account.

+

Rental Details

+

Item: {{itemName}}

+

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

+

Transfer ID: {{stripeTransferId}}

+

Earnings Breakdown

+

Rental Amount: ${{totalAmount}}

+

Community Upkeep Fee (10%): -${{platformFee}}

+

Your Earnings: ${{payoutAmount}}

+

Funds are typically available in your bank account within 2-3 business days.

+

View Earnings Dashboard

+

Thank you for being a valued member of the RentAll community!

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

Hi {{renterName}},

+

Rental Request Declined

+

Thank you for your interest in renting {{itemName}}. Unfortunately, the owner is unable to accept your rental request at this time.

+

Request Details

+

Item: {{itemName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Delivery Method: {{deliveryMethod}}

+ {{ownerMessage}} +
+

What happens next?

+

{{paymentMessage}}

+

We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!

+
+

Browse Available Items

+

If you have any questions or concerns, please don't hesitate to contact our support team.

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

Hi {{ownerName}},

+

You've Approved the Rental Request!

+

You've successfully approved the rental request for {{itemName}}.

+

Rental Details

+

Item: {{itemName}}

+

Renter: {{renterName}}

+

Start Date: {{startDate}}

+

End Date: {{endDate}}

+

Your Earnings: ${{payoutAmount}}

+ {{stripeSection}} +

What's Next?

+
    +
  • Coordinate with the renter on pickup details
  • +
  • Take photos of the item's condition before handoff
  • +
  • Provide any care instructions or usage tips
  • +
+

View Rental Details

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

Hi {{renterName}},

+

Thank You for Returning On Time!

+

You've successfully returned {{itemName}} on time. On-time returns like yours help build trust in the RentAll community!

+

Rental Summary

+

Item: {{itemName}}

+

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

+

Returned On: {{returnedDate}}

+ {{reviewSection}} +

Browse Available Items

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

Hi {{ownerName}},

+

Congratulations on Completing a Rental!

+

{{itemName}} has been successfully returned on time. Great job!

+

Rental Summary

+

Item: {{itemName}}

+

Renter: {{renterName}}

+

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

+ {{earningsSection}} + {{stripeSection}} +

View My Listings

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

Hi {{userName}},

+

Thank You for Your Feedback!

+

We've received your feedback and our team will review it carefully.

+
+ {{feedbackText}} +
+

Submitted: {{submittedAt}}

+

Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.

+

If your feedback requires a response, our team will reach out to you directly.

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

New Feedback Received

+

From: {{userName}} ({{userEmail}})

+

User ID: {{userId}}

+

Submitted: {{submittedAt}}

+

Feedback Content

+
+ {{feedbackText}} +
+

Technical Context

+

Feedback ID: {{feedbackId}}

+

Page URL: {{url}}

+

User Agent: {{userAgent}}

+

Please review this feedback and take appropriate action if needed.

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

{{title}}

+

{{message}}

+ ` + ) + ); + } +} + +module.exports = TemplateManager; diff --git a/backend/services/email/core/emailUtils.js b/backend/services/email/core/emailUtils.js new file mode 100644 index 0000000..9d0fa85 --- /dev/null +++ b/backend/services/email/core/emailUtils.js @@ -0,0 +1,98 @@ +/** + * Email utility functions shared across all email services + */ + +/** + * Convert HTML to plain text for email fallback + * Strips HTML tags and formats content for plain text email clients + * @param {string} html - HTML content to convert + * @returns {string} Plain text version of the HTML + */ +function 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() + ); +} + +/** + * Format a date consistently for email display + * @param {Date|string} date - Date to format + * @returns {string} Formatted date string + */ +function formatEmailDate(date) { + const dateObj = typeof date === "string" ? new Date(date) : date; + return dateObj.toLocaleString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +/** + * Format a date as a short date (no time) + * @param {Date|string} date - Date to format + * @returns {string} Formatted date string + */ +function formatShortDate(date) { + const dateObj = typeof date === "string" ? new Date(date) : date; + return dateObj.toLocaleString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** + * Format currency for email display + * @param {number} amount - Amount in cents or smallest currency unit + * @param {string} currency - Currency code (default: USD) + * @returns {string} Formatted currency string + */ +function formatCurrency(amount, currency = "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency, + }).format(amount / 100); +} + +module.exports = { + htmlToPlainText, + formatEmailDate, + formatShortDate, + formatCurrency, +}; diff --git a/backend/services/email/domain/AlphaInvitationEmailService.js b/backend/services/email/domain/AlphaInvitationEmailService.js new file mode 100644 index 0000000..c6686f1 --- /dev/null +++ b/backend/services/email/domain/AlphaInvitationEmailService.js @@ -0,0 +1,71 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * AlphaInvitationEmailService handles alpha program invitation emails + * This service is responsible for: + * - Sending alpha access invitation codes to new testers + */ +class AlphaInvitationEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the alpha invitation email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Alpha Invitation Email Service initialized successfully"); + } + + /** + * Send alpha invitation email + * @param {string} email - Recipient's email address + * @param {string} code - Alpha access code + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendAlphaInvitation(email, code) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const variables = { + code: code, + email: email, + frontendUrl: frontendUrl, + title: "Welcome to Alpha Testing!", + message: `You've been invited to join our exclusive alpha testing program. Use the code ${code} to unlock access and be among the first to experience our platform.`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "alphaInvitationToUser", + variables + ); + + return await this.emailClient.sendEmail( + email, + "Your Alpha Access Code - RentAll", + htmlContent + ); + } catch (error) { + console.error("Failed to send alpha invitation email:", error); + return { success: false, error: error.message }; + } + } +} + +module.exports = AlphaInvitationEmailService; diff --git a/backend/services/email/domain/AuthEmailService.js b/backend/services/email/domain/AuthEmailService.js new file mode 100644 index 0000000..8da5276 --- /dev/null +++ b/backend/services/email/domain/AuthEmailService.js @@ -0,0 +1,136 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * AuthEmailService handles all authentication and account security related emails + * This service is responsible for: + * - Sending email verification links + * - Sending password reset links + * - Sending password changed confirmations + */ +class AuthEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the auth email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Auth Email Service initialized successfully"); + } + + /** + * Send email verification email to new users + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @param {string} verificationToken - Email verification token + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendVerificationEmail(user, verificationToken) { + if (!this.initialized) { + await this.initialize(); + } + + 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 = await this.templateManager.renderTemplate( + "emailVerificationToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Verify Your Email - RentAll", + htmlContent + ); + } + + /** + * Send password reset email with reset link + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @param {string} resetToken - Password reset token + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendPasswordResetEmail(user, resetToken) { + if (!this.initialized) { + await this.initialize(); + } + + 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 = await this.templateManager.renderTemplate( + "passwordResetToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Reset Your Password - RentAll", + htmlContent + ); + } + + /** + * Send password changed confirmation email + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendPasswordChangedEmail(user) { + if (!this.initialized) { + await this.initialize(); + } + + const timestamp = new Date().toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + recipientName: user.firstName || "there", + email: user.email, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "passwordChangedToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Password Changed Successfully - RentAll", + htmlContent + ); + } +} + +module.exports = AuthEmailService; diff --git a/backend/services/email/domain/CustomerServiceEmailService.js b/backend/services/email/domain/CustomerServiceEmailService.js new file mode 100644 index 0000000..333655e --- /dev/null +++ b/backend/services/email/domain/CustomerServiceEmailService.js @@ -0,0 +1,299 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * CustomerServiceEmailService handles all customer service alert emails + * This service is responsible for: + * - Sending late return notifications to CS team + * - Sending damage report notifications to CS team + * - Sending lost item notifications to CS team + */ +class CustomerServiceEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the customer service email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Customer Service Email Service initialized successfully"); + } + + /** + * Send late return notification to customer service + * @param {Object} rental - Rental object + * @param {number} rental.id - Rental ID + * @param {Date} rental.endDateTime - Scheduled end date/time + * @param {Date} rental.actualReturnDateTime - Actual return date/time + * @param {Object} rental.item - Item object with name property + * @param {Object} owner - Owner user object + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.lastName - Owner's last name + * @param {string} owner.email - Owner's email + * @param {Object} renter - Renter user object + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {string} renter.email - Renter's email + * @param {Object} lateCalculation - Late fee calculation + * @param {number} lateCalculation.lateHours - Hours late + * @param {number} lateCalculation.lateFee - Late fee amount + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendLateReturnToCustomerService(rental, owner, renter, lateCalculation) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL; + if (!csEmail) { + console.warn("No customer service email configured"); + return { success: false, error: "No customer service email configured" }; + } + + // Format dates + const scheduledEnd = new Date(rental.endDateTime).toLocaleString(); + const actualReturn = new Date(rental.actualReturnDateTime).toLocaleString(); + + const variables = { + rentalId: rental.id, + itemName: rental.item.name, + ownerName: `${owner.firstName} ${owner.lastName}`, + ownerEmail: owner.email, + renterName: `${renter.firstName} ${renter.lastName}`, + renterEmail: renter.email, + scheduledEnd, + actualReturn, + hoursLate: lateCalculation.lateHours.toFixed(1), + lateFee: lateCalculation.lateFee.toFixed(2), + }; + + const htmlContent = await this.templateManager.renderTemplate( + "lateReturnToCS", + variables + ); + + const result = await this.emailClient.sendEmail( + csEmail, + "Late Return Detected - Action Required", + htmlContent + ); + + if (result.success) { + console.log( + `Late return notification sent to customer service for rental ${rental.id}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send late return notification to customer service:", + error + ); + return { success: false, error: error.message }; + } + } + + /** + * Send damage report notification to customer service + * @param {Object} rental - Rental object + * @param {number} rental.id - Rental ID + * @param {Object} rental.item - Item object with name property + * @param {Object} owner - Owner user object + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.lastName - Owner's last name + * @param {string} owner.email - Owner's email + * @param {Object} renter - Renter user object + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {string} renter.email - Renter's email + * @param {Object} damageAssessment - Damage assessment details + * @param {string} damageAssessment.description - Damage description + * @param {boolean} damageAssessment.canBeFixed - Whether item can be repaired + * @param {number} [damageAssessment.repairCost] - Repair cost if applicable + * @param {boolean} damageAssessment.needsReplacement - Whether item needs replacement + * @param {number} [damageAssessment.replacementCost] - Replacement cost if applicable + * @param {Object} damageAssessment.feeCalculation - Fee calculation details + * @param {string} damageAssessment.feeCalculation.type - Fee type (repair/replacement) + * @param {number} damageAssessment.feeCalculation.amount - Fee amount + * @param {Array} [damageAssessment.proofOfOwnership] - Proof of ownership documents + * @param {Object} [lateCalculation] - Late fee calculation (optional) + * @param {number} [lateCalculation.lateFee] - Late fee amount + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendDamageReportToCustomerService( + rental, + owner, + renter, + damageAssessment, + lateCalculation = null + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL; + if (!csEmail) { + console.warn("No customer service email configured"); + return { success: false, error: "No customer service email configured" }; + } + + // 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"; + } + + const variables = { + 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", + }; + + const htmlContent = await this.templateManager.renderTemplate( + "damageReportToCS", + variables + ); + + const result = await this.emailClient.sendEmail( + csEmail, + "Damage Report Filed - Action Required", + htmlContent + ); + + if (result.success) { + console.log( + `Damage report notification sent to customer service for rental ${rental.id}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send damage report notification to customer service:", + error + ); + return { success: false, error: error.message }; + } + } + + /** + * Send lost item notification to customer service + * @param {Object} rental - Rental object + * @param {number} rental.id - Rental ID + * @param {Date} rental.endDateTime - Scheduled return date + * @param {Date} rental.itemLostReportedAt - When loss was reported + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {number} rental.item.replacementCost - Item replacement cost + * @param {Object} owner - Owner user object + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.lastName - Owner's last name + * @param {string} owner.email - Owner's email + * @param {Object} renter - Renter user object + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {string} renter.email - Renter's email + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendLostItemToCustomerService(rental, owner, renter) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const csEmail = process.env.CUSTOMER_SUPPORT_EMAIL; + if (!csEmail) { + console.warn("No customer service email configured"); + return { success: false, error: "No customer service email configured" }; + } + + // Format dates + const reportedAt = new Date(rental.itemLostReportedAt).toLocaleString(); + const scheduledReturnDate = new Date(rental.endDateTime).toLocaleString(); + + const variables = { + 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), + }; + + const htmlContent = await this.templateManager.renderTemplate( + "lostItemToCS", + variables + ); + + const result = await this.emailClient.sendEmail( + csEmail, + "Lost Item Claim Filed - Action Required", + htmlContent + ); + + if (result.success) { + console.log( + `Lost item notification sent to customer service for rental ${rental.id}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send lost item notification to customer service:", + error + ); + return { success: false, error: error.message }; + } + } +} + +module.exports = CustomerServiceEmailService; diff --git a/backend/services/email/domain/FeedbackEmailService.js b/backend/services/email/domain/FeedbackEmailService.js new file mode 100644 index 0000000..30f2a21 --- /dev/null +++ b/backend/services/email/domain/FeedbackEmailService.js @@ -0,0 +1,131 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * FeedbackEmailService handles all feedback-related email notifications + * This service is responsible for: + * - Sending feedback confirmation to users + * - Sending feedback notifications to administrators + */ +class FeedbackEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the feedback email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Feedback Email Service initialized successfully"); + } + + /** + * Send feedback confirmation email to user + * @param {Object} user - User object + * @param {string} user.firstName - User's first name + * @param {string} user.email - User's email address + * @param {Object} feedback - Feedback object + * @param {string} feedback.feedbackText - The feedback content + * @param {Date} feedback.createdAt - Feedback submission timestamp + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendFeedbackConfirmation(user, feedback) { + if (!this.initialized) { + await this.initialize(); + } + + const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + userName: user.firstName || "there", + userEmail: user.email, + feedbackText: feedback.feedbackText, + submittedAt: submittedAt, + year: new Date().getFullYear(), + }; + + const htmlContent = await this.templateManager.renderTemplate( + "feedbackConfirmationToUser", + variables + ); + + return await this.emailClient.sendEmail( + user.email, + "Thank You for Your Feedback - RentAll", + htmlContent + ); + } + + /** + * Send feedback notification to admin + * @param {Object} user - User object who submitted feedback + * @param {string} user.firstName - User's first name + * @param {string} user.lastName - User's last name + * @param {string} user.email - User's email address + * @param {string} user.id - User's ID + * @param {Object} feedback - Feedback object + * @param {string} feedback.id - Feedback ID + * @param {string} feedback.feedbackText - The feedback content + * @param {string} [feedback.url] - URL where feedback was submitted + * @param {string} [feedback.userAgent] - User's browser user agent + * @param {Date} feedback.createdAt - Feedback submission timestamp + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendFeedbackNotificationToAdmin(user, feedback) { + if (!this.initialized) { + await this.initialize(); + } + + const adminEmail = + process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL; + + if (!adminEmail) { + console.warn("No admin email configured for feedback notifications"); + return { success: false, error: "No admin email configured" }; + } + + const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", { + dateStyle: "long", + timeStyle: "short", + }); + + const variables = { + userName: `${user.firstName} ${user.lastName}`.trim() || "Unknown User", + userEmail: user.email, + userId: user.id, + feedbackText: feedback.feedbackText, + feedbackId: feedback.id, + url: feedback.url || "Not provided", + userAgent: feedback.userAgent || "Not provided", + submittedAt: submittedAt, + year: new Date().getFullYear(), + }; + + const htmlContent = await this.templateManager.renderTemplate( + "feedbackNotificationToAdmin", + variables + ); + + return await this.emailClient.sendEmail( + adminEmail, + `New Feedback from ${user.firstName} ${user.lastName}`, + htmlContent + ); + } +} + +module.exports = FeedbackEmailService; diff --git a/backend/services/email/domain/ForumEmailService.js b/backend/services/email/domain/ForumEmailService.js new file mode 100644 index 0000000..c51232d --- /dev/null +++ b/backend/services/email/domain/ForumEmailService.js @@ -0,0 +1,318 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * ForumEmailService handles all forum-related email notifications + * This service is responsible for: + * - Sending comment notifications to post authors + * - Sending reply notifications to comment authors + * - Sending answer accepted notifications + * - Sending thread activity notifications to participants + */ +class ForumEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the forum email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Forum Email Service initialized successfully"); + } + + /** + * Send notification when someone comments on a post + * @param {Object} postAuthor - Post author user object + * @param {string} postAuthor.firstName - Post author's first name + * @param {string} postAuthor.email - Post author's email + * @param {Object} commenter - Commenter user object + * @param {string} commenter.firstName - Commenter's first name + * @param {string} commenter.lastName - Commenter's last name + * @param {Object} post - Forum post object + * @param {number} post.id - Post ID + * @param {string} post.title - Post title + * @param {Object} comment - Comment object + * @param {string} comment.content - Comment content + * @param {Date} comment.createdAt - Comment creation timestamp + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumCommentNotification(postAuthor, commenter, post, comment) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const variables = { + postAuthorName: postAuthor.firstName || "there", + commenterName: + `${commenter.firstName} ${commenter.lastName}`.trim() || "Someone", + postTitle: post.title, + commentContent: comment.content, + postUrl: postUrl, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumCommentToPostAuthor", + variables + ); + + const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`; + + const result = await this.emailClient.sendEmail( + postAuthor.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum comment notification email sent to ${postAuthor.email}` + ); + } + + return result; + } catch (error) { + console.error("Failed to send forum comment notification email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send notification when someone replies to a comment + * @param {Object} commentAuthor - Original comment author user object + * @param {string} commentAuthor.firstName - Comment author's first name + * @param {string} commentAuthor.email - Comment author's email + * @param {Object} replier - Replier user object + * @param {string} replier.firstName - Replier's first name + * @param {string} replier.lastName - Replier's last name + * @param {Object} post - Forum post object + * @param {number} post.id - Post ID + * @param {string} post.title - Post title + * @param {Object} reply - Reply comment object + * @param {string} reply.content - Reply content + * @param {Date} reply.createdAt - Reply creation timestamp + * @param {Object} parentComment - Parent comment being replied to + * @param {string} parentComment.content - Parent comment content + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumReplyNotification( + commentAuthor, + replier, + post, + reply, + parentComment + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const timestamp = new Date(reply.createdAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const variables = { + commentAuthorName: commentAuthor.firstName || "there", + replierName: + `${replier.firstName} ${replier.lastName}`.trim() || "Someone", + postTitle: post.title, + parentCommentContent: parentComment.content, + replyContent: reply.content, + postUrl: postUrl, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumReplyToCommentAuthor", + variables + ); + + const subject = `${replier.firstName} ${replier.lastName} replied to your comment`; + + const result = await this.emailClient.sendEmail( + commentAuthor.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum reply notification email sent to ${commentAuthor.email}` + ); + } + + return result; + } catch (error) { + console.error("Failed to send forum reply notification email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send notification when a comment is marked as the accepted answer + * @param {Object} commentAuthor - Comment author user object + * @param {string} commentAuthor.firstName - Comment author's first name + * @param {string} commentAuthor.email - Comment author's email + * @param {Object} postAuthor - Post author user object who accepted the answer + * @param {string} postAuthor.firstName - Post author's first name + * @param {string} postAuthor.lastName - Post author's last name + * @param {Object} post - Forum post object + * @param {number} post.id - Post ID + * @param {string} post.title - Post title + * @param {Object} comment - Comment that was accepted as answer + * @param {string} comment.content - Comment content + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumAnswerAcceptedNotification( + commentAuthor, + postAuthor, + post, + comment + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const variables = { + commentAuthorName: commentAuthor.firstName || "there", + postAuthorName: + `${postAuthor.firstName} ${postAuthor.lastName}`.trim() || "Someone", + postTitle: post.title, + commentContent: comment.content, + postUrl: postUrl, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumAnswerAcceptedToCommentAuthor", + variables + ); + + const subject = `Your comment was marked as the accepted answer!`; + + const result = await this.emailClient.sendEmail( + commentAuthor.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum answer accepted notification email sent to ${commentAuthor.email}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send forum answer accepted notification email:", + error + ); + return { success: false, error: error.message }; + } + } + + /** + * Send notification to thread participants about new activity + * @param {Object} participant - Participant user object + * @param {string} participant.firstName - Participant's first name + * @param {string} participant.email - Participant's email + * @param {Object} commenter - User who posted new comment + * @param {string} commenter.firstName - Commenter's first name + * @param {string} commenter.lastName - Commenter's last name + * @param {Object} post - Forum post object + * @param {number} post.id - Post ID + * @param {string} post.title - Post title + * @param {Object} comment - New comment/activity + * @param {string} comment.content - Comment content + * @param {Date} comment.createdAt - Comment creation timestamp + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumThreadActivityNotification( + participant, + commenter, + post, + comment + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const variables = { + participantName: participant.firstName || "there", + commenterName: + `${commenter.firstName} ${commenter.lastName}`.trim() || "Someone", + postTitle: post.title, + commentContent: comment.content, + postUrl: postUrl, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumThreadActivityToParticipant", + variables + ); + + const subject = `New activity on a post you're following`; + + const result = await this.emailClient.sendEmail( + participant.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum thread activity notification email sent to ${participant.email}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send forum thread activity notification email:", + error + ); + return { success: false, error: error.message }; + } + } +} + +module.exports = ForumEmailService; diff --git a/backend/services/email/domain/MessagingEmailService.js b/backend/services/email/domain/MessagingEmailService.js new file mode 100644 index 0000000..6ebd3c2 --- /dev/null +++ b/backend/services/email/domain/MessagingEmailService.js @@ -0,0 +1,97 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * MessagingEmailService handles all messaging-related email notifications + * This service is responsible for: + * - Sending new message notifications to users + */ +class MessagingEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the messaging email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Messaging Email Service initialized successfully"); + } + + /** + * Send new message notification email + * @param {Object} receiver - User object of the message receiver + * @param {string} receiver.firstName - Receiver's first name + * @param {string} receiver.email - Receiver's email address + * @param {Object} sender - User object of the message sender + * @param {string} sender.id - Sender's user ID + * @param {string} sender.firstName - Sender's first name + * @param {string} sender.lastName - Sender's last name + * @param {Object} message - Message object + * @param {string} message.subject - Message subject + * @param {string} message.content - Message content + * @param {Date} message.createdAt - Message creation timestamp + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendNewMessageNotification(receiver, sender, message) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`; + + const timestamp = new Date(message.createdAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const variables = { + recipientName: receiver.firstName || "there", + senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user", + subject: message.subject, + messageContent: message.content, + conversationUrl: conversationUrl, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "newMessageToUser", + variables + ); + + const subject = `New message from ${sender.firstName} ${sender.lastName}`; + + const result = await this.emailClient.sendEmail( + receiver.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Message notification email sent to ${receiver.email} from ${sender.firstName} ${sender.lastName}` + ); + } + + return result; + } catch (error) { + console.error("Failed to send message notification email:", error); + return { success: false, error: error.message }; + } + } +} + +module.exports = MessagingEmailService; diff --git a/backend/services/email/domain/RentalFlowEmailService.js b/backend/services/email/domain/RentalFlowEmailService.js new file mode 100644 index 0000000..c04c056 --- /dev/null +++ b/backend/services/email/domain/RentalFlowEmailService.js @@ -0,0 +1,1201 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * RentalFlowEmailService handles rental lifecycle flow emails + * This service is responsible for: + * - Sending rental request notifications to owners + * - Sending rental request confirmations to renters + * - Sending rental approval confirmations to owners + * - Sending rental declined notifications to renters + * - Sending rental confirmation emails to renters and owners + * - Sending rental cancellation emails to both parties + * - Sending rental completion emails to both parties + * - Sending payout received notifications to owners + */ +class RentalFlowEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the rental flow email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Rental Flow Email Service initialized successfully"); + } + + /** + * Send rental request email to owner + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {Object} renter - Renter user object + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {Object} rental - Rental object with all details + * @param {number} rental.id - Rental ID + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.payoutAmount - Owner's payout amount + * @param {string} rental.deliveryMethod - Delivery method + * @param {string} rental.notes - Rental notes from renter + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRentalRequestEmail(owner, renter, rental) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const approveUrl = `${frontendUrl}/owning?rentalId=${rental.id}`; + + 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", + rentalNotes: rental.notes || "No additional notes provided", + approveUrl: approveUrl, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "rentalRequestToOwner", + variables + ); + + return await this.emailClient.sendEmail( + owner.email, + `Rental Request for ${rental.item?.name || "Your Item"}`, + htmlContent + ); + } catch (error) { + console.error("Failed to send rental request email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send rental request confirmation email to renter + * @param {Object} renter - Renter user object + * @param {string} renter.email - Renter's email address + * @param {string} renter.firstName - Renter's first name + * @param {Object} rental - Rental object with all details + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.deliveryMethod - Delivery method + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRentalRequestConfirmationEmail(renter, rental) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const viewRentalsUrl = `${frontendUrl}/renting`; + + // Determine payment message based on rental amount + const totalAmount = parseFloat(rental.totalAmount) || 0; + const paymentMessage = + totalAmount > 0 + ? "The owner will review your request. You'll only be charged if they approve it." + : "The owner will review your request and respond soon."; + + const variables = { + renterName: renter.firstName || "there", + itemName: rental.item?.name || "the 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: totalAmount.toFixed(2), + deliveryMethod: rental.deliveryMethod || "Not specified", + paymentMessage: paymentMessage, + viewRentalsUrl: viewRentalsUrl, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "rentalRequestConfirmationToRenter", + variables + ); + + return await this.emailClient.sendEmail( + renter.email, + `Rental Request Submitted - ${rental.item?.name || "Item"}`, + htmlContent + ); + } catch (error) { + console.error("Failed to send rental request confirmation email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send rental approval confirmation email to owner + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.stripeConnectedAccountId - Owner's Stripe account ID + * @param {Object} renter - Renter user object + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {Object} rental - Rental object with all details + * @param {number} rental.id - Rental ID + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.deliveryMethod - Delivery method + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.payoutAmount - Owner's payout amount + * @param {string} rental.platformFee - Platform fee amount + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRentalApprovalConfirmationEmail(owner, renter, rental) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + // Determine if Stripe setup is needed + const hasStripeAccount = !!owner.stripeConnectedAccountId; + const totalAmount = parseFloat(rental.totalAmount) || 0; + const payoutAmount = parseFloat(rental.payoutAmount) || 0; + const platformFee = parseFloat(rental.platformFee) || 0; + + // Build payment message + const isPaidRental = totalAmount > 0; + let paymentMessage = ""; + if (isPaidRental) { + paymentMessage = "their payment has been processed successfully."; + } else { + paymentMessage = "this is a free rental (no payment required)."; + } + + // Build earnings section (only for paid rentals) + let earningsSection = ""; + if (isPaidRental) { + earningsSection = ` +

    Your Earnings

    + + + + + + + + + + + + + +
    Total Rental Amount\\$${totalAmount.toFixed(2)}
    Community Upkeep Fee (10%)-\\$${platformFee.toFixed(2)}
    Your Payout\\$${payoutAmount.toFixed(2)}
    + `; + } + + // Build conditional Stripe section based on Stripe status + let stripeSection = ""; + if (!hasStripeAccount && isPaidRental) { + // Only show Stripe setup reminder for paid rentals + stripeSection = ` +
    +

    ⚠️ Action Required: Set Up Your Earnings Account

    +

    To receive your payout of \\$${payoutAmount.toFixed( + 2 + )} when this rental completes, you need to set up your earnings account.

    +
    +

    Set Up Earnings to Get Paid

    +
    +

    Why set up now?

    +
      +
    • Automatic payouts when rentals complete
    • +
    • Secure transfers directly to your bank account
    • +
    • Track all earnings in one dashboard
    • +
    • Fast deposits (typically 2-3 business days)
    • +
    +

    Setup only takes about 5 minutes and you only need to do it once.

    +
    +

    + Set Up Earnings Account Now +

    +

    + Important: Without earnings setup, you won't receive payouts automatically when rentals complete. +

    + `; + } else if (hasStripeAccount && isPaidRental) { + stripeSection = ` +
    +

    ✓ Earnings Account Active

    +

    Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed( + 2 + )} when this rental completes.

    +

    View your earnings dashboard →

    +
    + `; + } + + // Format delivery method for display + const deliveryMethodDisplay = + rental.deliveryMethod === "delivery" ? "Delivery" : "Pickup"; + + const variables = { + ownerName: owner.firstName || "there", + itemName: rental.item?.name || "your item", + renterName: + `${renter.firstName} ${renter.lastName}`.trim() || "The renter", + 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", + deliveryMethod: deliveryMethodDisplay, + paymentMessage: paymentMessage, + earningsSection: earningsSection, + stripeSection: stripeSection, + rentalDetailsUrl: `${frontendUrl}/owning?rentalId=${rental.id}`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "rentalApprovalConfirmationToOwner", + variables + ); + + const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`; + + return await this.emailClient.sendEmail( + owner.email, + subject, + htmlContent + ); + } catch (error) { + console.error("Failed to send rental approval confirmation email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send rental declined email to renter + * @param {Object} renter - Renter user object + * @param {string} renter.email - Renter's email address + * @param {string} renter.firstName - Renter's first name + * @param {Object} rental - Rental object with all details + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.payoutAmount - Owner's payout amount + * @param {string} rental.deliveryMethod - Delivery method + * @param {string|null} declineReason - Reason for declining rental (optional) + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRentalDeclinedEmail(renter, rental, declineReason) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const browseItemsUrl = `${frontendUrl}/`; + + // Determine payment message based on rental amount + const totalAmount = parseFloat(rental.totalAmount) || 0; + const paymentMessage = + totalAmount > 0 + ? "Since your request was declined before payment was processed, you will not be charged." + : "No payment was required for this rental request."; + + // Build owner message section if decline reason provided + const ownerMessage = declineReason + ? ` +
    +

    Message from the owner:

    +

    ${declineReason}

    +
    + ` + : ""; + + const variables = { + renterName: renter.firstName || "there", + itemName: rental.item?.name || "the 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", + deliveryMethod: rental.deliveryMethod || "Not specified", + paymentMessage: paymentMessage, + ownerMessage: ownerMessage, + browseItemsUrl: browseItemsUrl, + payoutAmount: rental.payoutAmount + ? parseFloat(rental.payoutAmount).toFixed(2) + : "0.00", + totalAmount: totalAmount.toFixed(2), + }; + + const htmlContent = await this.templateManager.renderTemplate( + "rentalDeclinedToRenter", + variables + ); + + return await this.emailClient.sendEmail( + renter.email, + `Rental Request Declined - ${rental.item?.name || "Item"}`, + htmlContent + ); + } catch (error) { + console.error("Failed to send rental declined email:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send rental confirmation email with payment receipt (if applicable) + * @param {string} userEmail - User's email address + * @param {Object} notification - Notification object + * @param {string} notification.title - Notification title + * @param {string} notification.message - Notification message + * @param {Object} rental - Rental object with all rental details + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.paymentStatus - Payment status + * @param {string} rental.paymentMethodBrand - Payment method brand + * @param {string} rental.paymentMethodLast4 - Last 4 digits of payment method + * @param {string} rental.stripePaymentIntentId - Stripe payment intent ID + * @param {string} rental.chargedAt - Payment charge timestamp + * @param {string|null} recipientName - Recipient's name + * @param {boolean} isRenter - Whether recipient is the renter (to show payment receipt) + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendRentalConfirmation( + userEmail, + notification, + rental, + recipientName = null, + isRenter = false + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + 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", + isRenter: isRenter, + }; + + // Add payment information if this is for the renter and rental has payment info + let paymentSection = ""; + if (isRenter) { + const totalAmount = parseFloat(rental.totalAmount) || 0; + const isPaidRental = totalAmount > 0 && rental.paymentStatus === "paid"; + + if (isPaidRental) { + // Format payment method display + let paymentMethodDisplay = "Payment method on file"; + if (rental.paymentMethodBrand && rental.paymentMethodLast4) { + const brandCapitalized = + rental.paymentMethodBrand.charAt(0).toUpperCase() + + rental.paymentMethodBrand.slice(1); + paymentMethodDisplay = `${brandCapitalized} ending in ${rental.paymentMethodLast4}`; + } + + const chargedAtFormatted = rental.chargedAt + ? new Date(rental.chargedAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : new Date().toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + // Build payment receipt section HTML + paymentSection = ` +

    Payment Receipt

    +
    +
    💳
    +

    Payment Successful

    +

    Your payment has been processed. This email serves as your receipt.

    +
    + + + + + + + + + + + + + + + + + +
    Amount Charged$${totalAmount.toFixed(2)}
    Payment Method${paymentMethodDisplay}
    Transaction ID${ + rental.stripePaymentIntentId || "N/A" + }
    Transaction Date${chargedAtFormatted}
    +

    + Note: Keep this email for your records. You can use the transaction ID above if you need to contact support about this payment. +

    + `; + } else if (totalAmount === 0) { + // Free rental message + paymentSection = ` +
    +

    No Payment Required: This is a free rental.

    +
    + `; + } + } + + variables.paymentSection = paymentSection; + + const htmlContent = await this.templateManager.renderTemplate( + "rentalConfirmationToUser", + variables + ); + + // Use clear, transactional subject line with item name + const subject = `Rental Confirmation - ${itemName}`; + + return await this.emailClient.sendEmail(userEmail, subject, htmlContent); + } catch (error) { + console.error("Failed to send rental confirmation:", error); + return { success: false, error: error.message }; + } + } + + /** + * Send rental confirmation emails to both owner and renter + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {Object} renter - Renter user object + * @param {string} renter.email - Renter's email address + * @param {string} renter.firstName - Renter's first name + * @param {Object} rental - Rental object with all details + * @param {number} rental.id - Rental ID + * @param {number} rental.ownerId - Owner's user ID + * @param {number} rental.renterId - Renter's user ID + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @returns {Promise<{ownerEmailSent: boolean, renterEmailSent: boolean}>} + */ + async sendRentalConfirmationEmails(owner, renter, rental) { + if (!this.initialized) { + await this.initialize(); + } + + const results = { + ownerEmailSent: false, + renterEmailSent: false, + }; + + try { + // 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, + false // isRenter = false for owner + ); + 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, + true // isRenter = true for renter (enables payment receipt) + ); + 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; + } + + /** + * Send rental cancellation emails to both parties + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {Object} renter - Renter user object + * @param {string} renter.email - Renter's email address + * @param {string} renter.firstName - Renter's first name + * @param {Object} rental - Rental object with all details + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.cancelledBy - Who cancelled ('owner' or 'renter') + * @param {string} rental.cancelledAt - Cancellation timestamp + * @param {string} rental.totalAmount - Total rental amount + * @param {Object} refundInfo - Refund information + * @param {number} refundInfo.amount - Refund amount + * @param {number} refundInfo.percentage - Refund percentage (0-1) + * @param {string} refundInfo.reason - Refund reason description + * @returns {Promise<{confirmationEmailSent: boolean, notificationEmailSent: boolean}>} + */ + async sendRentalCancellationEmails(owner, renter, rental, refundInfo) { + if (!this.initialized) { + await this.initialize(); + } + + const results = { + confirmationEmailSent: false, + notificationEmailSent: false, + }; + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const browseUrl = `${frontendUrl}/`; + + const cancelledBy = rental.cancelledBy; + const itemName = rental.item?.name || "the item"; + const startDate = rental.startDateTime + ? new Date(rental.startDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const endDate = rental.endDateTime + ? new Date(rental.endDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const cancelledAt = rental.cancelledAt + ? new Date(rental.cancelledAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + + // Determine who gets confirmation and who gets notification + let confirmationRecipient, notificationRecipient; + let confirmationRecipientName, notificationRecipientName; + let cancellationMessage, additionalInfo; + + if (cancelledBy === "owner") { + // Owner cancelled: owner gets confirmation, renter gets notification + confirmationRecipient = owner.email; + confirmationRecipientName = owner.firstName || "there"; + notificationRecipient = renter.email; + notificationRecipientName = renter.firstName || "there"; + + cancellationMessage = `The owner has cancelled the rental for ${itemName}. We apologize for any inconvenience this may cause.`; + + // Only show refund info if rental had a cost + if (rental.totalAmount > 0) { + additionalInfo = ` +
    +

    Full Refund Processed

    +

    You will receive a full refund of $${refundInfo.amount.toFixed( + 2 + )}. The refund will appear in your account within 5-10 business days.

    +
    + + `; + } else { + additionalInfo = ` +
    +

    This rental has been cancelled by the owner. We apologize for any inconvenience.

    +
    + + `; + } + } else { + // Renter cancelled: renter gets confirmation, owner gets notification + confirmationRecipient = renter.email; + confirmationRecipientName = renter.firstName || "there"; + notificationRecipient = owner.email; + notificationRecipientName = owner.firstName || "there"; + + cancellationMessage = `The renter has cancelled their rental for ${itemName}.`; + additionalInfo = ` +
    +

    Your item is now available

    +

    Your item is now available for other renters to book for these dates.

    +
    + `; + } + + // Build refund section for confirmation email (only for paid rentals) + let refundSection = ""; + if (rental.totalAmount > 0) { + if (refundInfo.amount > 0) { + const refundPercentage = (refundInfo.percentage * 100).toFixed(0); + refundSection = ` +

    Refund Information

    +
    $${refundInfo.amount.toFixed(2)}
    +
    +

    Refund Amount: $${refundInfo.amount.toFixed( + 2 + )} (${refundPercentage}% of total)

    +

    Reason: ${refundInfo.reason}

    +

    Processing Time: Refunds typically appear within 5-10 business days.

    +
    + `; + } else { + refundSection = ` +

    Refund Information

    +
    +

    No Refund Available

    +

    ${refundInfo.reason}

    +
    + `; + } + } + + // Send confirmation email to canceller + try { + const confirmationVariables = { + recipientName: confirmationRecipientName, + itemName: itemName, + startDate: startDate, + endDate: endDate, + cancelledAt: cancelledAt, + refundSection: refundSection, + }; + + const confirmationHtml = await this.templateManager.renderTemplate( + "rentalCancellationConfirmationToUser", + confirmationVariables + ); + + const confirmationResult = await this.emailClient.sendEmail( + confirmationRecipient, + `Cancellation Confirmed - ${itemName}`, + confirmationHtml + ); + + if (confirmationResult.success) { + console.log( + `Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}` + ); + results.confirmationEmailSent = true; + } + } catch (error) { + console.error( + `Failed to send cancellation confirmation email to ${cancelledBy}:`, + error.message + ); + } + + // Send notification email to other party + try { + const notificationVariables = { + recipientName: notificationRecipientName, + itemName: itemName, + startDate: startDate, + endDate: endDate, + cancelledAt: cancelledAt, + cancellationMessage: cancellationMessage, + additionalInfo: additionalInfo, + }; + + const notificationHtml = await this.templateManager.renderTemplate( + "rentalCancellationNotificationToUser", + notificationVariables + ); + + const notificationResult = await this.emailClient.sendEmail( + notificationRecipient, + `Rental Cancelled - ${itemName}`, + notificationHtml + ); + + if (notificationResult.success) { + console.log( + `Cancellation notification email sent to ${ + cancelledBy === "owner" ? "renter" : "owner" + }: ${notificationRecipient}` + ); + results.notificationEmailSent = true; + } + } catch (error) { + console.error( + `Failed to send cancellation notification email:`, + error.message + ); + } + } catch (error) { + console.error("Error sending cancellation emails:", error); + } + + return results; + } + + /** + * Send rental completion emails to both owner and renter + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.lastName - Owner's last name + * @param {string} owner.stripeConnectedAccountId - Owner's Stripe account ID + * @param {Object} renter - Renter user object + * @param {string} renter.email - Renter's email address + * @param {string} renter.firstName - Renter's first name + * @param {string} renter.lastName - Renter's last name + * @param {Object} rental - Rental object with all details + * @param {number} rental.id - Rental ID + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.actualReturnDateTime - Actual return timestamp + * @param {string} rental.itemReviewSubmittedAt - Review submission timestamp + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.payoutAmount - Owner's payout amount + * @param {string} rental.platformFee - Platform fee amount + * @returns {Promise<{renterEmailSent: boolean, ownerEmailSent: boolean}>} + */ + async sendRentalCompletionEmails(owner, renter, rental) { + if (!this.initialized) { + await this.initialize(); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const results = { + renterEmailSent: false, + ownerEmailSent: false, + }; + + try { + // Format dates + const startDate = rental.startDateTime + ? new Date(rental.startDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const endDate = rental.endDateTime + ? new Date(rental.endDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : "Not specified"; + const returnedDate = rental.actualReturnDateTime + ? new Date(rental.actualReturnDateTime).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }) + : endDate; + + // Check if renter has already submitted a review + const hasReviewed = !!rental.itemReviewSubmittedAt; + + // Build review section for renter email + let reviewSection = ""; + if (!hasReviewed) { + reviewSection = ` +

    Share Your Experience

    +
    +

    Help the community by leaving a review!

    +

    Your feedback helps other renters make informed decisions and supports quality listings on RentAll.

    +
      +
    • How was the item's condition?
    • +
    • Was the owner responsive and helpful?
    • +
    • Would you rent this item again?
    • +
    +
    +

    + Leave a Review +

    + `; + } else { + reviewSection = ` +
    +

    ✓ Thank You for Your Review!

    +

    Your feedback has been submitted and helps strengthen the RentAll community.

    +
    + `; + } + + // Send email to renter + try { + const renterVariables = { + renterName: renter.firstName || "there", + itemName: rental.item?.name || "the item", + ownerName: owner.firstName || "the owner", + startDate: startDate, + endDate: endDate, + returnedDate: returnedDate, + reviewSection: reviewSection, + browseItemsUrl: `${frontendUrl}/`, + }; + + const renterHtmlContent = await this.templateManager.renderTemplate( + "rentalCompletionThankYouToRenter", + renterVariables + ); + + const renterResult = await this.emailClient.sendEmail( + renter.email, + `Thank You for Returning "${rental.item?.name || "Item"}" On Time!`, + renterHtmlContent + ); + + if (renterResult.success) { + console.log( + `Rental completion thank you email sent to renter: ${renter.email}` + ); + results.renterEmailSent = true; + } else { + console.error( + `Failed to send rental completion email to renter (${renter.email}):`, + renterResult.error + ); + } + } catch (emailError) { + console.error( + `Failed to send rental completion email to renter (${renter.email}):`, + emailError.message + ); + } + + // Prepare owner email + const hasStripeAccount = !!owner.stripeConnectedAccountId; + const totalAmount = parseFloat(rental.totalAmount) || 0; + const payoutAmount = parseFloat(rental.payoutAmount) || 0; + const platformFee = parseFloat(rental.platformFee) || 0; + const isPaidRental = totalAmount > 0; + + // Build earnings section for owner (only for paid rentals) + let earningsSection = ""; + if (isPaidRental) { + earningsSection = ` +

    Your Earnings

    + + + + + + + + + + + + + +
    Total Rental Amount\\$${totalAmount.toFixed(2)}
    Community Upkeep Fee (10%)-\\$${platformFee.toFixed(2)}
    Your Payout\\$${payoutAmount.toFixed(2)}
    +

    + Your earnings will be automatically transferred to your account when the rental period ends and any dispute windows close. +

    + `; + } + + // Build Stripe section for owner + let stripeSection = ""; + if (!hasStripeAccount && isPaidRental) { + // Show Stripe setup reminder for paid rentals + stripeSection = ` +
    +

    ⚠️ Action Required: Set Up Your Earnings Account

    +

    To receive your payout of \\$${payoutAmount.toFixed( + 2 + )}, you need to set up your earnings account.

    +
    +

    Set Up Earnings to Get Paid

    +
    +

    Why set up now?

    +
      +
    • Automatic payouts when the rental period ends
    • +
    • Secure transfers directly to your bank account
    • +
    • Track all earnings in one dashboard
    • +
    • Fast deposits (typically 2-3 business days)
    • +
    +

    Setup only takes about 5 minutes and you only need to do it once.

    +
    +

    + Set Up Earnings Account Now +

    +

    + Important: Without earnings setup, you won't receive payouts automatically. +

    + `; + } else if (hasStripeAccount && isPaidRental) { + stripeSection = ` +
    +

    ✓ Earnings Account Active

    +

    Your earnings account is set up. You'll automatically receive \\$${payoutAmount.toFixed( + 2 + )} when the rental period ends.

    +

    View your earnings dashboard →

    +
    + `; + } + + // Send email to owner + try { + const ownerVariables = { + ownerName: owner.firstName || "there", + itemName: rental.item?.name || "your item", + renterName: + `${renter.firstName} ${renter.lastName}`.trim() || "The renter", + startDate: startDate, + endDate: endDate, + returnedDate: returnedDate, + earningsSection: earningsSection, + stripeSection: stripeSection, + owningUrl: `${frontendUrl}/owning`, + }; + + const ownerHtmlContent = await this.templateManager.renderTemplate( + "rentalCompletionCongratsToOwner", + ownerVariables + ); + + const ownerResult = await this.emailClient.sendEmail( + owner.email, + `Rental Complete - ${rental.item?.name || "Your Item"}`, + ownerHtmlContent + ); + + if (ownerResult.success) { + console.log( + `Rental completion congratulations email sent to owner: ${owner.email}` + ); + results.ownerEmailSent = true; + } else { + console.error( + `Failed to send rental completion email to owner (${owner.email}):`, + ownerResult.error + ); + } + } catch (emailError) { + console.error( + `Failed to send rental completion email to owner (${owner.email}):`, + emailError.message + ); + } + } catch (error) { + console.error("Error sending rental completion emails:", error); + } + + return results; + } + + /** + * Send payout received email to owner + * @param {Object} owner - Owner user object + * @param {string} owner.email - Owner's email address + * @param {string} owner.firstName - Owner's first name + * @param {Object} rental - Rental object with all details + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @param {string} rental.startDateTime - Rental start date + * @param {string} rental.endDateTime - Rental end date + * @param {string} rental.totalAmount - Total rental amount + * @param {string} rental.platformFee - Platform fee amount + * @param {string} rental.payoutAmount - Owner's payout amount + * @param {string} rental.stripeTransferId - Stripe transfer ID + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendPayoutReceivedEmail(owner, rental) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const earningsDashboardUrl = `${frontendUrl}/earnings`; + + // Format currency values + const totalAmount = parseFloat(rental.totalAmount) || 0; + const platformFee = parseFloat(rental.platformFee) || 0; + const payoutAmount = parseFloat(rental.payoutAmount) || 0; + + const variables = { + ownerName: owner.firstName || "there", + 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: totalAmount.toFixed(2), + platformFee: platformFee.toFixed(2), + payoutAmount: payoutAmount.toFixed(2), + stripeTransferId: rental.stripeTransferId || "N/A", + earningsDashboardUrl: earningsDashboardUrl, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "payoutReceivedToOwner", + variables + ); + + return await this.emailClient.sendEmail( + owner.email, + `Earnings Received - $${payoutAmount.toFixed(2)} for ${ + rental.item?.name || "Your Item" + }`, + htmlContent + ); + } catch (error) { + console.error("Failed to send payout received email:", error); + return { success: false, error: error.message }; + } + } +} + +module.exports = RentalFlowEmailService; diff --git a/backend/services/email/domain/RentalReminderEmailService.js b/backend/services/email/domain/RentalReminderEmailService.js new file mode 100644 index 0000000..0fd4520 --- /dev/null +++ b/backend/services/email/domain/RentalReminderEmailService.js @@ -0,0 +1,77 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * RentalReminderEmailService handles rental reminder emails + * This service is responsible for: + * - Sending condition check reminders + */ +class RentalReminderEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the rental reminder email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("Rental Reminder Email Service initialized successfully"); + } + + /** + * Send condition check reminder email + * @param {string} userEmail - User's email address + * @param {Object} notification - Notification object + * @param {string} notification.title - Notification title + * @param {string} notification.message - Notification message + * @param {Object} notification.metadata - Notification metadata + * @param {string} notification.metadata.deadline - Condition check deadline + * @param {Object} rental - Rental object + * @param {Object} rental.item - Item object + * @param {string} rental.item.name - Item name + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendConditionCheckReminder(userEmail, notification, rental) { + if (!this.initialized) { + await this.initialize(); + } + + try { + 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 = await this.templateManager.renderTemplate( + "conditionCheckReminderToUser", + variables + ); + + return await this.emailClient.sendEmail( + userEmail, + `RentAll: ${notification.title}`, + htmlContent + ); + } catch (error) { + console.error("Failed to send condition check reminder:", error); + return { success: false, error: error.message }; + } + } +} + +module.exports = RentalReminderEmailService; diff --git a/backend/services/email/domain/UserEngagementEmailService.js b/backend/services/email/domain/UserEngagementEmailService.js new file mode 100644 index 0000000..a0c5425 --- /dev/null +++ b/backend/services/email/domain/UserEngagementEmailService.js @@ -0,0 +1,77 @@ +const EmailClient = require("../core/EmailClient"); +const TemplateManager = require("../core/TemplateManager"); + +/** + * UserEngagementEmailService handles user engagement emails + * This service is responsible for: + * - Sending first listing celebration emails + * - Other user engagement and milestone emails + */ +class UserEngagementEmailService { + constructor() { + this.emailClient = new EmailClient(); + this.templateManager = new TemplateManager(); + this.initialized = false; + } + + /** + * Initialize the user engagement email service + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.emailClient.initialize(), + this.templateManager.initialize(), + ]); + + this.initialized = true; + console.log("User Engagement Email Service initialized successfully"); + } + + /** + * Send first listing celebration email to owner + * @param {Object} owner - Owner user object + * @param {string} owner.firstName - Owner's first name + * @param {string} owner.email - Owner's email address + * @param {Object} item - Item object + * @param {number} item.id - Item ID + * @param {string} item.name - Item name + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendFirstListingCelebrationEmail(owner, item) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const variables = { + ownerName: owner.firstName || "there", + itemName: item.name, + itemId: item.id, + viewItemUrl: `${frontendUrl}/items/${item.id}`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "firstListingCelebrationToOwner", + variables + ); + + const subject = `Congratulations! Your first item is live on RentAll`; + + return await this.emailClient.sendEmail( + owner.email, + subject, + htmlContent + ); + } catch (error) { + console.error("Failed to send first listing celebration email:", error); + return { success: false, error: error.message }; + } + } +} + +module.exports = UserEngagementEmailService; diff --git a/backend/services/email/index.js b/backend/services/email/index.js new file mode 100644 index 0000000..d084b53 --- /dev/null +++ b/backend/services/email/index.js @@ -0,0 +1,58 @@ +const AuthEmailService = require("./domain/AuthEmailService"); +const FeedbackEmailService = require("./domain/FeedbackEmailService"); +const ForumEmailService = require("./domain/ForumEmailService"); +const MessagingEmailService = require("./domain/MessagingEmailService"); +const CustomerServiceEmailService = require("./domain/CustomerServiceEmailService"); +const RentalFlowEmailService = require("./domain/RentalFlowEmailService"); +const RentalReminderEmailService = require("./domain/RentalReminderEmailService"); +const UserEngagementEmailService = require("./domain/UserEngagementEmailService"); +const AlphaInvitationEmailService = require("./domain/AlphaInvitationEmailService"); + +/** + * EmailServices aggregates all domain-specific email services + * This class provides a unified interface to access all email functionality + */ +class EmailServices { + constructor() { + // Initialize all domain services + this.auth = new AuthEmailService(); + this.feedback = new FeedbackEmailService(); + this.forum = new ForumEmailService(); + this.messaging = new MessagingEmailService(); + this.customerService = new CustomerServiceEmailService(); + this.rentalFlow = new RentalFlowEmailService(); + this.rentalReminder = new RentalReminderEmailService(); + this.userEngagement = new UserEngagementEmailService(); + this.alphaInvitation = new AlphaInvitationEmailService(); + + this.initialized = false; + } + + /** + * Initialize all email services + * @returns {Promise} + */ + async initialize() { + if (this.initialized) return; + + await Promise.all([ + this.auth.initialize(), + this.feedback.initialize(), + this.forum.initialize(), + this.messaging.initialize(), + this.customerService.initialize(), + this.rentalFlow.initialize(), + this.rentalReminder.initialize(), + this.userEngagement.initialize(), + this.alphaInvitation.initialize(), + ]); + + this.initialized = true; + console.log("All Email Services initialized successfully"); + } +} + +// Create and export singleton instance +const emailServices = new EmailServices(); + +module.exports = emailServices; diff --git a/backend/services/emailService.js b/backend/services/emailService.js deleted file mode 100644 index 1ddac48..0000000 --- a/backend/services/emailService.js +++ /dev/null @@ -1,2175 +0,0 @@ -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 = [ - "conditionCheckReminderToUser.html", - "rentalConfirmationToUser.html", - "emailVerificationToUser.html", - "passwordResetToUser.html", - "passwordChangedToUser.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", - "alphaInvitationToUser.html", - "feedbackConfirmationToUser.html", - "feedbackNotificationToAdmin.html", - "newMessageToUser.html", - "forumCommentToPostAuthor.html", - "forumReplyToCommentAuthor.html", - "forumAnswerAcceptedToCommentAuthor.html", - "forumThreadActivityToParticipant.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); - console.log(`✓ Loaded template: ${templateName}`); - } catch (error) { - console.error( - `✗ Failed to load template ${templateFile}:`, - error.message - ); - console.error( - ` Template path: ${path.join(templatesDir, templateFile)}` - ); - } - } - - console.log( - `Loaded ${this.templates.size} of ${templateFiles.length} email templates` - ); - } catch (error) { - console.error("Failed to load email templates:", error); - console.error("Templates directory:", templatesDir); - console.error("Error stack:", error.stack); - } - } - - /** - * 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 }; - } - } - - async renderTemplate(templateName, variables = {}) { - // Ensure service is initialized before rendering - if (!this.initialized) { - console.log(`Email service not initialized yet, initializing now...`); - await this.initialize(); - } - - let template = this.templates.get(templateName); - - if (!template) { - console.error(`Template not found: ${templateName}`); - console.error( - `Available templates: ${Array.from(this.templates.keys()).join(", ")}` - ); - console.error(`Stack trace:`, new Error().stack); - console.log(`Using fallback template for: ${templateName}`); - template = this.getFallbackTemplate(templateName); - } else { - console.log(`✓ Template found: ${templateName}`); - } - - let rendered = template; - - try { - Object.keys(variables).forEach((key) => { - const regex = new RegExp(`{{${key}}}`, "g"); - rendered = rendered.replace(regex, variables[key] || ""); - }); - } catch (error) { - console.error(`Error rendering template ${templateName}:`, error); - console.error(`Stack trace:`, error.stack); - console.error(`Variables provided:`, Object.keys(variables)); - } - - return rendered; - } - - getFallbackTemplate(templateName) { - const baseTemplate = ` - - - - - - {{title}} - - - -
    -
    - -
    -
    - {{content}} -
    - -
    - - - `; - - const templates = { - conditionCheckReminderToUser: baseTemplate.replace( - "{{content}}", - ` -

    {{title}}

    -

    {{message}}

    -

    Rental Item: {{itemName}}

    -

    Deadline: {{deadline}}

    -

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

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

    Hi {{recipientName}},

    -

    {{title}}

    -

    {{message}}

    -

    Item: {{itemName}}

    -

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

    -

    Thank you for using RentAll!

    - ` - ), - - emailVerificationToUser: 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.

    - ` - ), - - passwordResetToUser: 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.

    - ` - ), - - passwordChangedToUser: 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.

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

    Hi {{ownerName}},

    -

    New Rental Request for {{itemName}}

    -

    {{renterName}} would like to rent your item.

    -

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

    -

    Total Amount: \${{totalAmount}}

    -

    Your Earnings: \${{payoutAmount}}

    -

    Delivery Method: {{deliveryMethod}}

    -

    Renter Notes: {{rentalNotes}}

    -

    Review & Respond

    -

    Please respond to this request within 24 hours.

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

    Hi {{renterName}},

    -

    Your Rental Request Has Been Submitted!

    -

    Your request to rent {{itemName}} has been sent to the owner.

    -

    Item: {{itemName}}

    -

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

    -

    Delivery Method: {{deliveryMethod}}

    -

    Total Amount: \${{totalAmount}}

    -

    {{paymentMessage}}

    -

    You'll receive an email notification once the owner responds to your request.

    -

    View My Rentals

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

    Hi {{recipientName}},

    -

    Rental Cancelled Successfully

    -

    This confirms that your rental for {{itemName}} has been cancelled.

    -

    Item: {{itemName}}

    -

    Start Date: {{startDate}}

    -

    End Date: {{endDate}}

    -

    Cancelled On: {{cancelledAt}}

    - {{refundSection}} - ` - ), - - rentalCancellationNotificationToUser: baseTemplate.replace( - "{{content}}", - ` -

    Hi {{recipientName}},

    -

    Rental Cancellation Notice

    -

    {{cancellationMessage}}

    -

    Item: {{itemName}}

    -

    Start Date: {{startDate}}

    -

    End Date: {{endDate}}

    -

    Cancelled On: {{cancelledAt}}

    - {{additionalInfo}} -

    If you have any questions or concerns, please reach out to our support team.

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

    Hi {{ownerName}},

    -

    Earnings Received: \${{payoutAmount}}

    -

    Great news! Your earnings from the rental of {{itemName}} have been transferred to your account.

    -

    Rental Details

    -

    Item: {{itemName}}

    -

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

    -

    Transfer ID: {{stripeTransferId}}

    -

    Earnings Breakdown

    -

    Rental Amount: \${{totalAmount}}

    -

    Community Upkeep Fee (10%): -\${{platformFee}}

    -

    Your Earnings: \${{payoutAmount}}

    -

    Funds are typically available in your bank account within 2-3 business days.

    -

    View Earnings Dashboard

    -

    Thank you for being a valued member of the RentAll community!

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

    Hi {{renterName}},

    -

    Rental Request Declined

    -

    Thank you for your interest in renting {{itemName}}. Unfortunately, the owner is unable to accept your rental request at this time.

    -

    Request Details

    -

    Item: {{itemName}}

    -

    Start Date: {{startDate}}

    -

    End Date: {{endDate}}

    -

    Delivery Method: {{deliveryMethod}}

    - {{ownerMessage}} -
    -

    What happens next?

    -

    {{paymentMessage}}

    -

    We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!

    -
    -

    Browse Available Items

    -

    If you have any questions or concerns, please don't hesitate to contact our support team.

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

    Hi {{ownerName}},

    -

    You've Approved the Rental Request!

    -

    You've successfully approved the rental request for {{itemName}}.

    -

    Rental Details

    -

    Item: {{itemName}}

    -

    Renter: {{renterName}}

    -

    Start Date: {{startDate}}

    -

    End Date: {{endDate}}

    -

    Your Earnings: \${{payoutAmount}}

    - {{stripeSection}} -

    What's Next?

    -
      -
    • Coordinate with the renter on pickup details
    • -
    • Take photos of the item's condition before handoff
    • -
    • Provide any care instructions or usage tips
    • -
    -

    View Rental Details

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

    Hi {{renterName}},

    -

    Thank You for Returning On Time!

    -

    You've successfully returned {{itemName}} on time. On-time returns like yours help build trust in the RentAll community!

    -

    Rental Summary

    -

    Item: {{itemName}}

    -

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

    -

    Returned On: {{returnedDate}}

    - {{reviewSection}} -

    Browse Available Items

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

    Hi {{ownerName}},

    -

    Congratulations on Completing a Rental!

    -

    {{itemName}} has been successfully returned on time. Great job!

    -

    Rental Summary

    -

    Item: {{itemName}}

    -

    Renter: {{renterName}}

    -

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

    - {{earningsSection}} - {{stripeSection}} -

    View My Listings

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

    Hi {{userName}},

    -

    Thank You for Your Feedback!

    -

    We've received your feedback and our team will review it carefully.

    -
    - {{feedbackText}} -
    -

    Submitted: {{submittedAt}}

    -

    Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.

    -

    If your feedback requires a response, our team will reach out to you directly.

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

    New Feedback Received

    -

    From: {{userName}} ({{userEmail}})

    -

    User ID: {{userId}}

    -

    Submitted: {{submittedAt}}

    -

    Feedback Content

    -
    - {{feedbackText}} -
    -

    Technical Context

    -

    Feedback ID: {{feedbackId}}

    -

    Page URL: {{url}}

    -

    User Agent: {{userAgent}}

    -

    Please review this feedback and take appropriate action if needed.

    - ` - ), - }; - - 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 = await this.renderTemplate( - "conditionCheckReminderToUser", - variables - ); - - return await this.sendEmail( - userEmail, - `RentAll: ${notification.title}`, - htmlContent - ); - } - - async sendRentalConfirmation( - userEmail, - notification, - rental, - recipientName = null, - isRenter = false - ) { - 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", - isRenter: isRenter, - }; - - // Add payment information if this is for the renter and rental has payment info - let paymentSection = ""; - if (isRenter) { - const totalAmount = parseFloat(rental.totalAmount) || 0; - const isPaidRental = totalAmount > 0 && rental.paymentStatus === "paid"; - - if (isPaidRental) { - // Format payment method display - let paymentMethodDisplay = "Payment method on file"; - if (rental.paymentMethodBrand && rental.paymentMethodLast4) { - const brandCapitalized = - rental.paymentMethodBrand.charAt(0).toUpperCase() + - rental.paymentMethodBrand.slice(1); - paymentMethodDisplay = `${brandCapitalized} ending in ${rental.paymentMethodLast4}`; - } - - const chargedAtFormatted = rental.chargedAt - ? new Date(rental.chargedAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : new Date().toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - - // Build payment receipt section HTML - paymentSection = ` -

    Payment Receipt

    -
    -
    💳
    -

    Payment Successful

    -

    Your payment has been processed. This email serves as your receipt.

    -
    - - - - - - - - - - - - - - - - - -
    Amount Charged$${totalAmount.toFixed(2)}
    Payment Method${paymentMethodDisplay}
    Transaction ID${ - rental.stripePaymentIntentId || "N/A" - }
    Transaction Date${chargedAtFormatted}
    -

    - Note: Keep this email for your records. You can use the transaction ID above if you need to contact support about this payment. -

    - `; - } else if (totalAmount === 0) { - // Free rental message - paymentSection = ` -
    -

    No Payment Required: This is a free rental.

    -
    - `; - } - } - - variables.paymentSection = paymentSection; - - const htmlContent = await this.renderTemplate( - "rentalConfirmationToUser", - 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 = await this.renderTemplate( - "emailVerificationToUser", - variables - ); - - return await this.sendEmail( - user.email, - "Verify Your Email - RentAll", - htmlContent - ); - } - - async sendAlphaInvitation(email, code) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - - const variables = { - code: code, - email: email, - frontendUrl: frontendUrl, - title: "Welcome to Alpha Testing!", - message: `You've been invited to join our exclusive alpha testing program. Use the code ${code} to unlock access and be among the first to experience our platform.`, - }; - - const htmlContent = await this.renderTemplate( - "alphaInvitationToUser", - variables - ); - - return await this.sendEmail( - email, - "Your Alpha Access Code - 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 = await this.renderTemplate( - "passwordResetToUser", - 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 = await this.renderTemplate( - "passwordChangedToUser", - 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}/owning?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", - rentalNotes: rental.notes || "No additional notes provided", - approveUrl: approveUrl, - }; - - const htmlContent = await this.renderTemplate( - "rentalRequestToOwner", - variables - ); - - return await this.sendEmail( - owner.email, - `Rental Request for ${rental.item?.name || "Your Item"}`, - htmlContent - ); - } - - async sendRentalRequestConfirmationEmail(rental) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const viewRentalsUrl = `${frontendUrl}/renting`; - - // Fetch renter details - const renter = await User.findByPk(rental.renterId, { - attributes: ["email", "firstName", "lastName"], - }); - - if (!renter) { - console.error( - "Renter not found for rental request confirmation notification" - ); - return { success: false, error: "Renter not found" }; - } - - // Determine payment message based on rental amount - const totalAmount = parseFloat(rental.totalAmount) || 0; - const paymentMessage = - totalAmount > 0 - ? "The owner will review your request. You'll only be charged if they approve it." - : "The owner will review your request and respond soon."; - - const variables = { - renterName: renter.firstName || "there", - itemName: rental.item?.name || "the 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: totalAmount.toFixed(2), - deliveryMethod: rental.deliveryMethod || "Not specified", - paymentMessage: paymentMessage, - viewRentalsUrl: viewRentalsUrl, - }; - - const htmlContent = await this.renderTemplate( - "rentalRequestConfirmationToRenter", - variables - ); - - return await this.sendEmail( - renter.email, - `Rental Request Submitted - ${rental.item?.name || "Item"}`, - htmlContent - ); - } - - async sendRentalDeclinedEmail(rental, declineReason) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const browseItemsUrl = `${frontendUrl}/`; - - // Fetch renter details - const renter = await User.findByPk(rental.renterId, { - attributes: ["email", "firstName", "lastName"], - }); - - if (!renter) { - console.error("Renter not found for rental decline notification"); - return { success: false, error: "Renter not found" }; - } - - // Determine payment message based on rental amount - const totalAmount = parseFloat(rental.totalAmount) || 0; - const paymentMessage = - totalAmount > 0 - ? "Since your request was declined before payment was processed, you will not be charged." - : "No payment was required for this rental request."; - - // Build owner message section if decline reason provided - const ownerMessage = declineReason - ? ` -
    -

    Message from the owner:

    -

    ${declineReason}

    -
    - ` - : ""; - - const variables = { - renterName: renter.firstName || "there", - itemName: rental.item?.name || "the 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", - deliveryMethod: rental.deliveryMethod || "Not specified", - paymentMessage: paymentMessage, - ownerMessage: ownerMessage, - browseItemsUrl: browseItemsUrl, - payoutAmount: rental.payoutAmount - ? parseFloat(rental.payoutAmount).toFixed(2) - : "0.00", - totalAmount: totalAmount.toFixed(2), - }; - - const htmlContent = await this.renderTemplate( - "rentalDeclinedToRenter", - variables - ); - - return await this.sendEmail( - renter.email, - `Rental Request Declined - ${rental.item?.name || "Item"}`, - htmlContent - ); - } - - async sendPayoutReceivedEmail(rental) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const earningsDashboardUrl = `${frontendUrl}/earnings`; - - // Fetch owner details - const owner = await User.findByPk(rental.ownerId, { - attributes: ["email", "firstName", "lastName"], - }); - - if (!owner) { - console.error("Owner not found for payout notification"); - return { success: false, error: "Owner not found" }; - } - - // Format currency values - const totalAmount = parseFloat(rental.totalAmount) || 0; - const platformFee = parseFloat(rental.platformFee) || 0; - const payoutAmount = parseFloat(rental.payoutAmount) || 0; - - const variables = { - ownerName: owner.firstName || "there", - 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: totalAmount.toFixed(2), - platformFee: platformFee.toFixed(2), - payoutAmount: payoutAmount.toFixed(2), - stripeTransferId: rental.stripeTransferId || "N/A", - earningsDashboardUrl: earningsDashboardUrl, - }; - - const htmlContent = await this.renderTemplate( - "payoutReceivedToOwner", - variables - ); - - return await this.sendEmail( - owner.email, - `Earnings Received - $${payoutAmount.toFixed(2)} for ${ - rental.item?.name || "Your Item" - }`, - htmlContent - ); - } - - async sendRentalCancellationEmails(rental, refundInfo) { - const results = { - confirmationEmailSent: false, - notificationEmailSent: false, - }; - - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const browseUrl = `${frontendUrl}/`; - - // Fetch both owner and renter details - const owner = await User.findByPk(rental.ownerId, { - attributes: ["email", "firstName", "lastName"], - }); - const renter = await User.findByPk(rental.renterId, { - attributes: ["email", "firstName", "lastName"], - }); - - if (!owner || !renter) { - console.error( - "Owner or renter not found for rental cancellation emails" - ); - return { success: false, error: "User not found" }; - } - - const cancelledBy = rental.cancelledBy; // 'owner' or 'renter' - const itemName = rental.item?.name || "the item"; - const startDate = rental.startDateTime - ? new Date(rental.startDateTime).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : "Not specified"; - const endDate = rental.endDateTime - ? new Date(rental.endDateTime).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : "Not specified"; - const cancelledAt = rental.cancelledAt - ? new Date(rental.cancelledAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : "Not specified"; - - // Determine who gets confirmation and who gets notification - let confirmationRecipient, notificationRecipient; - let confirmationRecipientName, notificationRecipientName; - let cancellationMessage, additionalInfo; - - if (cancelledBy === "owner") { - // Owner cancelled: owner gets confirmation, renter gets notification - confirmationRecipient = owner.email; - confirmationRecipientName = owner.firstName || "there"; - notificationRecipient = renter.email; - notificationRecipientName = renter.firstName || "there"; - - cancellationMessage = `The owner has cancelled the rental for ${itemName}. We apologize for any inconvenience this may cause.`; - - // Only show refund info if rental had a cost - if (rental.totalAmount > 0) { - additionalInfo = ` -
    -

    Full Refund Processed

    -

    You will receive a full refund of $${refundInfo.amount.toFixed( - 2 - )}. The refund will appear in your account within 5-10 business days.

    -
    - - `; - } else { - additionalInfo = ` -
    -

    This rental has been cancelled by the owner. We apologize for any inconvenience.

    -
    - - `; - } - } else { - // Renter cancelled: renter gets confirmation, owner gets notification - confirmationRecipient = renter.email; - confirmationRecipientName = renter.firstName || "there"; - notificationRecipient = owner.email; - notificationRecipientName = owner.firstName || "there"; - - cancellationMessage = `The renter has cancelled their rental for ${itemName}.`; - additionalInfo = ` -
    -

    Your item is now available

    -

    Your item is now available for other renters to book for these dates.

    -
    - `; - } - - // Build refund section for confirmation email (only for paid rentals) - let refundSection = ""; - if (rental.totalAmount > 0) { - if (refundInfo.amount > 0) { - const refundPercentage = (refundInfo.percentage * 100).toFixed(0); - refundSection = ` -

    Refund Information

    -
    $${refundInfo.amount.toFixed(2)}
    -
    -

    Refund Amount: $${refundInfo.amount.toFixed( - 2 - )} (${refundPercentage}% of total)

    -

    Reason: ${refundInfo.reason}

    -

    Processing Time: Refunds typically appear within 5-10 business days.

    -
    - `; - } else { - refundSection = ` -

    Refund Information

    -
    -

    No Refund Available

    -

    ${refundInfo.reason}

    -
    - `; - } - } - // For free rentals (totalAmount = 0), refundSection stays empty - - // Send confirmation email to canceller - try { - const confirmationVariables = { - recipientName: confirmationRecipientName, - itemName: itemName, - startDate: startDate, - endDate: endDate, - cancelledAt: cancelledAt, - refundSection: refundSection, - }; - - const confirmationHtml = await this.renderTemplate( - "rentalCancellationConfirmationToUser", - confirmationVariables - ); - - const confirmationResult = await this.sendEmail( - confirmationRecipient, - `Cancellation Confirmed - ${itemName}`, - confirmationHtml - ); - - if (confirmationResult.success) { - console.log( - `Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}` - ); - results.confirmationEmailSent = true; - } - } catch (error) { - console.error( - `Failed to send cancellation confirmation email to ${cancelledBy}:`, - error.message - ); - } - - // Send notification email to other party - try { - const notificationVariables = { - recipientName: notificationRecipientName, - itemName: itemName, - startDate: startDate, - endDate: endDate, - cancelledAt: cancelledAt, - cancellationMessage: cancellationMessage, - additionalInfo: additionalInfo, - }; - - const notificationHtml = await this.renderTemplate( - "rentalCancellationNotificationToUser", - notificationVariables - ); - - const notificationResult = await this.sendEmail( - notificationRecipient, - `Rental Cancelled - ${itemName}`, - notificationHtml - ); - - if (notificationResult.success) { - console.log( - `Cancellation notification email sent to ${ - cancelledBy === "owner" ? "renter" : "owner" - }: ${notificationRecipient}` - ); - results.notificationEmailSent = true; - } - } catch (error) { - console.error( - `Failed to send cancellation notification email:`, - error.message - ); - } - } catch (error) { - console.error("Error sending cancellation emails:", error); - } - - return results; - } - - async sendTemplateEmail(toEmail, subject, templateName, variables = {}) { - const htmlContent = await 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", - "lateReturnToCS", - { - 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", - "damageReportToCS", - { - 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", - "lostItemToCS", - { - 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, - false // isRenter = false for owner - ); - 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, - true // isRenter = true for renter (enables payment receipt) - ); - 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; - } - - async sendFirstListingCelebrationEmail(owner, item) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - - const variables = { - ownerName: owner.firstName || "there", - itemName: item.name, - itemId: item.id, - viewItemUrl: `${frontendUrl}/items/${item.id}`, - }; - - const htmlContent = await this.renderTemplate( - "firstListingCelebrationToOwner", - variables - ); - - const subject = `🎉 Congratulations! Your first item is live on RentAll`; - - return await this.sendEmail(owner.email, subject, htmlContent); - } - - async sendRentalApprovalConfirmationEmail(rental) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - - // Fetch owner details - const owner = await User.findByPk(rental.ownerId, { - attributes: [ - "email", - "firstName", - "lastName", - "stripeConnectedAccountId", - ], - }); - - // 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 approval confirmation email" - ); - return { success: false, error: "User not found" }; - } - - // Determine if Stripe setup is needed - const hasStripeAccount = !!owner.stripeConnectedAccountId; - const totalAmount = parseFloat(rental.totalAmount) || 0; - const payoutAmount = parseFloat(rental.payoutAmount) || 0; - const platformFee = parseFloat(rental.platformFee) || 0; - - // Build payment message - const isPaidRental = totalAmount > 0; - let paymentMessage = ""; - if (isPaidRental) { - paymentMessage = "their payment has been processed successfully."; - } else { - paymentMessage = "this is a free rental (no payment required)."; - } - - // Build earnings section (only for paid rentals) - let earningsSection = ""; - if (isPaidRental) { - earningsSection = ` -

    Your Earnings

    - - - - - - - - - - - - - -
    Total Rental Amount\$${totalAmount.toFixed(2)}
    Community Upkeep Fee (10%)-\$${platformFee.toFixed(2)}
    Your Payout\$${payoutAmount.toFixed(2)}
    - `; - } - - // Build conditional Stripe section based on Stripe status - let stripeSection = ""; - if (!hasStripeAccount && isPaidRental) { - // Only show Stripe setup reminder for paid rentals - stripeSection = ` -
    -

    ⚠️ Action Required: Set Up Your Earnings Account

    -

    To receive your payout of \$${payoutAmount.toFixed( - 2 - )} when this rental completes, you need to set up your earnings account.

    -
    -

    Set Up Earnings to Get Paid

    -
    -

    Why set up now?

    -
      -
    • Automatic payouts when rentals complete
    • -
    • Secure transfers directly to your bank account
    • -
    • Track all earnings in one dashboard
    • -
    • Fast deposits (typically 2-3 business days)
    • -
    -

    Setup only takes about 5 minutes and you only need to do it once.

    -
    -

    - Set Up Earnings Account Now -

    -

    - Important: Without earnings setup, you won't receive payouts automatically when rentals complete. -

    - `; - } else if (hasStripeAccount && isPaidRental) { - stripeSection = ` -
    -

    ✓ Earnings Account Active

    -

    Your earnings account is set up. You'll automatically receive \$${payoutAmount.toFixed( - 2 - )} when this rental completes.

    -

    View your earnings dashboard →

    -
    - `; - } - - // Format delivery method for display - const deliveryMethodDisplay = - rental.deliveryMethod === "delivery" ? "Delivery" : "Pickup"; - - const variables = { - ownerName: owner.firstName || "there", - itemName: rental.item?.name || "your item", - renterName: - `${renter.firstName} ${renter.lastName}`.trim() || "The renter", - 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", - deliveryMethod: deliveryMethodDisplay, - paymentMessage: paymentMessage, - earningsSection: earningsSection, - stripeSection: stripeSection, - rentalDetailsUrl: `${frontendUrl}/owning?rentalId=${rental.id}`, - }; - - const htmlContent = await this.renderTemplate( - "rentalApprovalConfirmationToOwner", - variables - ); - - const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`; - - return await this.sendEmail(owner.email, subject, htmlContent); - } - - async sendRentalCompletionEmails(rental) { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const results = { - renterEmailSent: false, - ownerEmailSent: false, - }; - - try { - // Fetch owner details with Stripe info - const owner = await User.findByPk(rental.ownerId, { - attributes: [ - "email", - "firstName", - "lastName", - "stripeConnectedAccountId", - ], - }); - - // Fetch renter details - const renter = await User.findByPk(rental.renterId, { - attributes: ["email", "firstName", "lastName"], - }); - - if (!owner || !renter) { - console.error("Owner or renter not found for rental completion emails"); - return { success: false, error: "User not found" }; - } - - // Format dates - const startDate = rental.startDateTime - ? new Date(rental.startDateTime).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : "Not specified"; - const endDate = rental.endDateTime - ? new Date(rental.endDateTime).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : "Not specified"; - const returnedDate = rental.actualReturnDateTime - ? new Date(rental.actualReturnDateTime).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }) - : endDate; - - // Check if renter has already submitted a review - const hasReviewed = !!rental.itemReviewSubmittedAt; - - // Build review section for renter email - let reviewSection = ""; - if (!hasReviewed) { - reviewSection = ` -

    Share Your Experience

    -
    -

    Help the community by leaving a review!

    -

    Your feedback helps other renters make informed decisions and supports quality listings on RentAll.

    -
      -
    • How was the item's condition?
    • -
    • Was the owner responsive and helpful?
    • -
    • Would you rent this item again?
    • -
    -
    -

    - Leave a Review -

    - `; - } else { - reviewSection = ` -
    -

    ✓ Thank You for Your Review!

    -

    Your feedback has been submitted and helps strengthen the RentAll community.

    -
    - `; - } - - // Send email to renter - try { - const renterVariables = { - renterName: renter.firstName || "there", - itemName: rental.item?.name || "the item", - ownerName: owner.firstName || "the owner", - startDate: startDate, - endDate: endDate, - returnedDate: returnedDate, - reviewSection: reviewSection, - browseItemsUrl: `${frontendUrl}/`, - }; - - const renterHtmlContent = await this.renderTemplate( - "rentalCompletionThankYouToRenter", - renterVariables - ); - - const renterResult = await this.sendEmail( - renter.email, - `Thank You for Returning "${rental.item?.name || "Item"}" On Time!`, - renterHtmlContent - ); - - if (renterResult.success) { - console.log( - `Rental completion thank you email sent to renter: ${renter.email}` - ); - results.renterEmailSent = true; - } else { - console.error( - `Failed to send rental completion email to renter (${renter.email}):`, - renterResult.error - ); - } - } catch (emailError) { - console.error( - `Failed to send rental completion email to renter (${renter.email}):`, - emailError.message - ); - } - - // Prepare owner email - const hasStripeAccount = !!owner.stripeConnectedAccountId; - const totalAmount = parseFloat(rental.totalAmount) || 0; - const payoutAmount = parseFloat(rental.payoutAmount) || 0; - const platformFee = parseFloat(rental.platformFee) || 0; - const isPaidRental = totalAmount > 0; - - // Build earnings section for owner (only for paid rentals) - let earningsSection = ""; - if (isPaidRental) { - earningsSection = ` -

    Your Earnings

    - - - - - - - - - - - - - -
    Total Rental Amount\$${totalAmount.toFixed(2)}
    Community Upkeep Fee (10%)-\$${platformFee.toFixed(2)}
    Your Payout\$${payoutAmount.toFixed(2)}
    -

    - Your earnings will be automatically transferred to your account when the rental period ends and any dispute windows close. -

    - `; - } - - // Build Stripe section for owner - let stripeSection = ""; - if (!hasStripeAccount && isPaidRental) { - // Show Stripe setup reminder for paid rentals - stripeSection = ` -
    -

    ⚠️ Action Required: Set Up Your Earnings Account

    -

    To receive your payout of \$${payoutAmount.toFixed( - 2 - )}, you need to set up your earnings account.

    -
    -

    Set Up Earnings to Get Paid

    -
    -

    Why set up now?

    -
      -
    • Automatic payouts when the rental period ends
    • -
    • Secure transfers directly to your bank account
    • -
    • Track all earnings in one dashboard
    • -
    • Fast deposits (typically 2-3 business days)
    • -
    -

    Setup only takes about 5 minutes and you only need to do it once.

    -
    -

    - Set Up Earnings Account Now -

    -

    - Important: Without earnings setup, you won't receive payouts automatically. -

    - `; - } else if (hasStripeAccount && isPaidRental) { - stripeSection = ` -
    -

    ✓ Earnings Account Active

    -

    Your earnings account is set up. You'll automatically receive \$${payoutAmount.toFixed( - 2 - )} when the rental period ends.

    -

    View your earnings dashboard →

    -
    - `; - } - - // Send email to owner - try { - const ownerVariables = { - ownerName: owner.firstName || "there", - itemName: rental.item?.name || "your item", - renterName: - `${renter.firstName} ${renter.lastName}`.trim() || "The renter", - startDate: startDate, - endDate: endDate, - returnedDate: returnedDate, - earningsSection: earningsSection, - stripeSection: stripeSection, - owningUrl: `${frontendUrl}/owning`, - }; - - const ownerHtmlContent = await this.renderTemplate( - "rentalCompletionCongratsToOwner", - ownerVariables - ); - - const ownerResult = await this.sendEmail( - owner.email, - `Rental Complete - ${rental.item?.name || "Your Item"}`, - ownerHtmlContent - ); - - if (ownerResult.success) { - console.log( - `Rental completion congratulations email sent to owner: ${owner.email}` - ); - results.ownerEmailSent = true; - } else { - console.error( - `Failed to send rental completion email to owner (${owner.email}):`, - ownerResult.error - ); - } - } catch (emailError) { - console.error( - `Failed to send rental completion email to owner (${owner.email}):`, - emailError.message - ); - } - } catch (error) { - console.error("Error sending rental completion emails:", error); - } - - return results; - } - - async sendFeedbackConfirmation(user, feedback) { - const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", { - dateStyle: "long", - timeStyle: "short", - }); - - const variables = { - userName: user.firstName || "there", - userEmail: user.email, - feedbackText: feedback.feedbackText, - submittedAt: submittedAt, - year: new Date().getFullYear(), - }; - - const htmlContent = await this.renderTemplate( - "feedbackConfirmationToUser", - variables - ); - - return await this.sendEmail( - user.email, - "Thank You for Your Feedback - RentAll", - htmlContent - ); - } - - async sendFeedbackNotificationToAdmin(user, feedback) { - const adminEmail = - process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL; - - if (!adminEmail) { - console.warn("No admin email configured for feedback notifications"); - return { success: false, error: "No admin email configured" }; - } - - const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", { - dateStyle: "long", - timeStyle: "short", - }); - - const variables = { - userName: `${user.firstName} ${user.lastName}`.trim() || "Unknown User", - userEmail: user.email, - userId: user.id, - feedbackText: feedback.feedbackText, - feedbackId: feedback.id, - url: feedback.url || "Not provided", - userAgent: feedback.userAgent || "Not provided", - submittedAt: submittedAt, - year: new Date().getFullYear(), - }; - - const htmlContent = await this.renderTemplate( - "feedbackNotificationToAdmin", - variables - ); - - return await this.sendEmail( - adminEmail, - `New Feedback from ${user.firstName} ${user.lastName}`, - htmlContent - ); - } - - async sendNewMessageNotification(receiver, sender, message) { - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const conversationUrl = `${frontendUrl}/messages/conversations/${sender.id}`; - - const timestamp = new Date(message.createdAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - - const variables = { - recipientName: receiver.firstName || "there", - senderName: `${sender.firstName} ${sender.lastName}`.trim() || "A user", - subject: message.subject, - messageContent: message.content, - conversationUrl: conversationUrl, - timestamp: timestamp, - }; - - const htmlContent = await this.renderTemplate( - "newMessageToUser", - variables - ); - - const subject = `New message from ${sender.firstName} ${sender.lastName}`; - - const result = await this.sendEmail(receiver.email, subject, htmlContent); - - if (result.success) { - console.log( - `Message notification email sent to ${receiver.email} from ${sender.email}` - ); - } - - return result; - } catch (error) { - console.error("Failed to send message notification email:", error); - return { success: false, error: error.message }; - } - } - - async sendForumCommentNotification(postAuthor, commenter, post, comment) { - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const postUrl = `${frontendUrl}/forum/posts/${post.id}`; - - const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - - const variables = { - postAuthorName: postAuthor.firstName || "there", - commenterName: - `${commenter.firstName} ${commenter.lastName}`.trim() || "Someone", - postTitle: post.title, - commentContent: comment.content, - postUrl: postUrl, - timestamp: timestamp, - }; - - const htmlContent = await this.renderTemplate( - "forumCommentToPostAuthor", - variables - ); - - const subject = `${commenter.firstName} ${commenter.lastName} commented on your post`; - - const result = await this.sendEmail( - postAuthor.email, - subject, - htmlContent - ); - - if (result.success) { - console.log( - `Forum comment notification email sent to ${postAuthor.email}` - ); - } - - return result; - } catch (error) { - console.error("Failed to send forum comment notification email:", error); - return { success: false, error: error.message }; - } - } - - async sendForumReplyNotification( - commentAuthor, - replier, - post, - reply, - parentComment - ) { - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const postUrl = `${frontendUrl}/forum/posts/${post.id}`; - - const timestamp = new Date(reply.createdAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - - const variables = { - commentAuthorName: commentAuthor.firstName || "there", - replierName: - `${replier.firstName} ${replier.lastName}`.trim() || "Someone", - postTitle: post.title, - parentCommentContent: parentComment.content, - replyContent: reply.content, - postUrl: postUrl, - timestamp: timestamp, - }; - - const htmlContent = await this.renderTemplate( - "forumReplyToCommentAuthor", - variables - ); - - const subject = `${replier.firstName} ${replier.lastName} replied to your comment`; - - const result = await this.sendEmail( - commentAuthor.email, - subject, - htmlContent - ); - - if (result.success) { - console.log( - `Forum reply notification email sent to ${commentAuthor.email}` - ); - } - - return result; - } catch (error) { - console.error("Failed to send forum reply notification email:", error); - return { success: false, error: error.message }; - } - } - - async sendForumAnswerAcceptedNotification( - commentAuthor, - postAuthor, - post, - comment - ) { - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const postUrl = `${frontendUrl}/forum/posts/${post.id}`; - - const variables = { - commentAuthorName: commentAuthor.firstName || "there", - postAuthorName: - `${postAuthor.firstName} ${postAuthor.lastName}`.trim() || "Someone", - postTitle: post.title, - commentContent: comment.content, - postUrl: postUrl, - }; - - const htmlContent = await this.renderTemplate( - "forumAnswerAcceptedToCommentAuthor", - variables - ); - - const subject = `Your comment was marked as the accepted answer!`; - - const result = await this.sendEmail( - commentAuthor.email, - subject, - htmlContent - ); - - if (result.success) { - console.log( - `Forum answer accepted notification email sent to ${commentAuthor.email}` - ); - } - - return result; - } catch (error) { - console.error( - "Failed to send forum answer accepted notification email:", - error - ); - return { success: false, error: error.message }; - } - } - - async sendForumThreadActivityNotification( - participant, - commenter, - post, - comment - ) { - try { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; - const postUrl = `${frontendUrl}/forum/posts/${post.id}`; - - const timestamp = new Date(comment.createdAt).toLocaleString("en-US", { - dateStyle: "medium", - timeStyle: "short", - }); - - const variables = { - participantName: participant.firstName || "there", - commenterName: - `${commenter.firstName} ${commenter.lastName}`.trim() || "Someone", - postTitle: post.title, - commentContent: comment.content, - postUrl: postUrl, - timestamp: timestamp, - }; - - const htmlContent = await this.renderTemplate( - "forumThreadActivityToParticipant", - variables - ); - - const subject = `New activity on a post you're following`; - - const result = await this.sendEmail( - participant.email, - subject, - htmlContent - ); - - if (result.success) { - console.log( - `Forum thread activity notification email sent to ${participant.email}` - ); - } - - return result; - } catch (error) { - console.error( - "Failed to send forum thread activity notification email:", - error - ); - return { success: false, error: error.message }; - } - } -} - -module.exports = new EmailService(); diff --git a/backend/services/lateReturnService.js b/backend/services/lateReturnService.js index d908d3c..d9a63e4 100644 --- a/backend/services/lateReturnService.js +++ b/backend/services/lateReturnService.js @@ -1,5 +1,5 @@ -const { Rental, Item } = require("../models"); -const emailService = require("./emailService"); +const { Rental, Item, User } = require("../models"); +const emailServices = require("./email"); class LateReturnService { /** @@ -91,8 +91,14 @@ class LateReturnService { // Send notification to customer service if late return detected if (lateCalculation.isLate && lateCalculation.lateFee > 0) { - await emailService.sendLateReturnToCustomerService( + // Fetch owner and renter user data for email + const owner = await User.findByPk(updatedRental.ownerId); + const renter = await User.findByPk(updatedRental.renterId); + + await emailServices.customerService.sendLateReturnToCustomerService( updatedRental, + owner, + renter, lateCalculation ); } diff --git a/backend/services/payoutService.js b/backend/services/payoutService.js index 9e0f066..64e49a3 100644 --- a/backend/services/payoutService.js +++ b/backend/services/payoutService.js @@ -1,6 +1,6 @@ const { Rental, User, Item } = require("../models"); const StripeService = require("./stripeService"); -const emailService = require("./emailService"); +const emailServices = require("./email"); const { Op } = require("sequelize"); class PayoutService { @@ -82,7 +82,7 @@ class PayoutService { // Send payout notification email to owner try { - await emailService.sendPayoutReceivedEmail(rental); + await emailServices.rentalFlow.sendPayoutReceivedEmail(rental.owner, rental); console.log( `Payout notification email sent to owner for rental ${rental.id}` );