From 14c779e0183a2b3d9ce3118b801a331c0fde34dc Mon Sep 17 00:00:00 2001 From: dollarop <290174976+dollarop@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:57:15 -0600 Subject: [PATCH] fix(backend): log mail delivery failures --- .../services/__tests__/mailerService.test.ts | 56 +++++++++++++++++++ .../services/notifications/mailerService.ts | 38 ++++++++++--- 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 backend/src/services/__tests__/mailerService.test.ts diff --git a/backend/src/services/__tests__/mailerService.test.ts b/backend/src/services/__tests__/mailerService.test.ts new file mode 100644 index 00000000..b2b82336 --- /dev/null +++ b/backend/src/services/__tests__/mailerService.test.ts @@ -0,0 +1,56 @@ +import logger from '../../utils/logger.js'; +import { MailerService } from '../notifications/mailerService.js'; + +const smtpKeys = ['SMTP_HOST', 'SMTP_USER', 'SMTP_PASS', 'SMTP_PORT', 'SMTP_SECURE', 'SMTP_FROM'] as const; + +describe('MailerService', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.restoreAllMocks(); + for (const key of smtpKeys) delete process.env[key]; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('logs a warning when SMTP is not configured', async () => { + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); + + await MailerService.sendMail({ + to: ['recipient@example.com'], + subject: 'password-reset', + text: 'Reset requested', + }); + + expect(warn).toHaveBeenCalledWith( + 'Email notification skipped: SMTP is not configured', + expect.objectContaining({ + recipients: ['recipient@example.com'], + mailType: 'password-reset', + }), + ); + }); + + it('logs an error when nodemailer cannot be loaded', async () => { + process.env.SMTP_HOST = 'smtp.example.com'; + process.env.SMTP_USER = 'mailer@example.com'; + process.env.SMTP_PASS = 'secret'; + const error = jest.spyOn(logger, 'error').mockImplementation(() => undefined); + + await MailerService.sendMail({ + to: ['recipient@example.com'], + subject: 'payroll-notification', + text: 'Payroll ready', + }); + + expect(error).toHaveBeenCalledWith( + 'Email notification skipped: nodemailer could not be loaded', + expect.objectContaining({ + recipients: ['recipient@example.com'], + mailType: 'payroll-notification', + }), + ); + }); +}); diff --git a/backend/src/services/notifications/mailerService.ts b/backend/src/services/notifications/mailerService.ts index f9fbfc29..2f378839 100644 --- a/backend/src/services/notifications/mailerService.ts +++ b/backend/src/services/notifications/mailerService.ts @@ -1,3 +1,5 @@ +import logger from '../../utils/logger.js'; + export interface SendMailInput { to: string[]; subject: string; @@ -10,12 +12,23 @@ export class MailerService { } static async sendMail(input: SendMailInput): Promise { - if (!this.isConfigured()) return; + if (!this.isConfigured()) { + logger.warn('Email notification skipped: SMTP is not configured', { + recipients: input.to, + mailType: input.subject, + }); + return; + } let nodemailer: any; try { nodemailer = (await import('nodemailer')).default; - } catch { + } catch (error) { + logger.error('Email notification skipped: nodemailer could not be loaded', { + error: error instanceof Error ? error.message : String(error), + recipients: input.to, + mailType: input.subject, + }); return; } @@ -31,11 +44,20 @@ export class MailerService { const from = process.env.SMTP_FROM || process.env.SMTP_USER; - await transporter.sendMail({ - from, - to: input.to.join(','), - subject: input.subject, - text: input.text, - }); + try { + await transporter.sendMail({ + from, + to: input.to.join(','), + subject: input.subject, + text: input.text, + }); + } catch (error) { + logger.error('Email notification failed', { + error: error instanceof Error ? error.message : String(error), + recipients: input.to, + mailType: input.subject, + }); + throw error; + } } }