All files / src/services emailService.server.ts

70.86% Statements 163/230
77.77% Branches 14/18
72.72% Functions 8/11
72.53% Lines 140/193

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 1942x 2x 2x 2x 2x 2x   2x 2x 2x 2x   2x 2x 2x 29x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 29x 16x 2x 1x 1x 1x 1x 1x 1x 16x 6x 1x 1x             2x     29x 3x           3x   3x 3x   2x 2x 2x 2x   2x                     29x           3x 3x     3x     6x               3x   2x 2x 2x 1x 1x 1x 1x 3x 1x 3x 1x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 29x 8x 1x 8x 1x 8x 2x                 8x 2x 2x 8x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 29x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x  
// src/services/emailService.server.ts
/**
 * @file This service manages all email sending functionality using Nodemailer.
 * It is configured via environment variables and should only be used on the server.
 */
import nodemailer from 'nodemailer';
import type { Job } from 'bullmq';
import type { Logger } from 'pino';
import { logger as globalLogger } from './logger.server';
import { WatchedItemDeal } from '../types';
import type { EmailJobData } from '../types/job-data';

// 1. Create a Nodemailer transporter using SMTP configuration from environment variables.
// For development, you can use a service like Ethereal (https://ethereal.email/)
// or a local SMTP server like MailHog.
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT || '587', 10),
  secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});
 
/**
 * Sends an email using the pre-configured transporter.
 * @param options The email options, including recipient, subject, and body.
 */
export const sendEmail = async (options: EmailJobData, logger: Logger) => {
  const mailOptions = {
    from: `"Flyer Crawler" <${process.env.SMTP_FROM_EMAIL}>`, // sender address
    to: options.to,
    subject: options.subject,
    text: options.text,
    html: options.html,
  };
 
  const info = await transporter.sendMail(mailOptions);
  logger.info(
    { to: options.to, subject: options.subject, messageId: info.messageId },
    `Email sent successfully.`,
  );
};

/**
 * Processes an email sending job from the queue.
 * This is the entry point for the email worker.
 * It encapsulates logging and error handling for the job.
 * @param job The BullMQ job object.
 */
export const processEmailJob = async (job: Job<EmailJobData>) => {
  const jobLogger = globalLogger.child({
    jobId: job.id,
    jobName: job.name,
    recipient: job.data.to,
  });

  jobLogger.info(`Picked up email job.`);

  try {
    await sendEmail(job.data, jobLogger);
  } catch (error) {
    const wrappedError = error instanceof Error ? error : new Error(String(error));
    jobLogger.error(
      { err: wrappedError, jobData: job.data, attemptsMade: job.attemptsMade },
      `Email job failed.`,
    );
    throw wrappedError;
  }
};

/**
 * Sends a notification email to a user about new deals on their watched items.
 * This function formats the deal information into a user-friendly email.
 * @param to The recipient's email address.
 * @param name The recipient's name (can be null).
 * @param deals An array of deals found for the user.
 */
export const sendDealNotificationEmail = async (
  to: string,
  name: string | null,
  deals: WatchedItemDeal[],
  logger: Logger,
) => {
  const recipientName = name || 'there';
  const subject = `New Deals Found on Your Watched Items!`;

  // Generate a simple list of deals for the email body
  const dealsListHtml = deals
    .map(
      (deal) =>
        `<li>
          <strong>${deal.item_name}</strong> is on sale for
          <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong>
          at ${deal.store.name}!
        </li>`,
    )
    .join('');

  const html = `
    <h1>Hi ${recipientName},</h1>
    <p>We found some great deals on items you're watching:</p>
    <ul>
      ${dealsListHtml}
    </ul>
    <p>Check them out on the deals page!</p>
  `;
 
  const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.\n\nFlyer Crawler`;
 
  try {
    // Use the generic sendEmail function to send the composed email
    await sendEmail(
      {
        to,
        subject,
        text,
        html,
      },
      logger,
    );
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    logger.error({ err: error, to, subject }, 'Failed to send email.');
    throw error;
  }
};
 
/**
 * Sends a password reset email to a user containing a link with their reset token.
 * @param to The recipient's email address.
 * @param token The unique password reset token.
 */
export const sendPasswordResetEmail = async (to: string, token: string, logger: Logger) => {
  const subject = 'Your Password Reset Request';
  // Construct the full reset URL using the frontend base URL from environment variables.
  const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${token}`;
 
  const html = `
    <div style="font-family: sans-serif; padding: 20px;">
      <h2>Password Reset Request</h2>
      <p>You requested a password reset for your Flyer Crawler account.</p>
      <p>Please click the link below to set a new password. This link will expire in 1 hour.</p>
      <a href="${resetUrl}" style="background-color: #007bff; color: white; padding: 14px 25px; text-align: center; text-decoration: none; display: inline-block; border-radius: 5px;">Reset Your Password</a>
      <p style="margin-top: 20px;">If you did not request this, please ignore this email.</p>
    </div>
  `;

  const text = `Password Reset Request\n\nYou requested a password reset for your Flyer Crawler account.\nPlease use the following link to set a new password: ${resetUrl}\nThis link will expire in 1 hour.\n\nIf you did not request this, please ignore this email.`;
 
  // Use the generic sendEmail function to send the composed email.
  await sendEmail(
    {
      to,
      subject,
      text,
      html,
    },
    logger,
  );
};
 
/**
 * Sends a welcome email to a new user.
 * @param to The recipient's email address.
 * @param name The recipient's name (can be null).
 */
export const sendWelcomeEmail = async (to: string, name: string | null, logger: Logger) => {
  const recipientName = name || 'there';
  const subject = 'Welcome to Flyer Crawler!';
 
  const html = `
    <div style="font-family: sans-serif; padding: 20px;">
      <h2>Welcome!</h2>
      <p>Hello ${recipientName},</p>
      <p>Thank you for joining Flyer Crawler. We're excited to have you on board.</p>
      <p>Start by uploading your first flyer to see how much you can save!</p>
    </div>
  `;
 
  const text = `Welcome!\n\nHello ${recipientName},\n\nThank you for joining Flyer Crawler. We're excited to have you on board.\n\nStart by uploading your first flyer to see how much you can save!`;
 
  await sendEmail(
    {
      to,
      subject,
      text,
      html,
    },
    logger,
  );
};