From ffaef986d19e0cf97e096dcbc123870272b449bc Mon Sep 17 00:00:00 2001 From: GiftedGiftB Date: Wed, 1 Jul 2026 10:18:39 +0100 Subject: [PATCH] Enhance MailService with provider resilience and delivery status tracking - Added MAIL_PROVIDERS to env validation schema. - Implemented sendMailWithResilience method for retrying email delivery. - Introduced EmailDeliveryStatus for tracking email delivery attempts. - Created unit tests for MailService to validate provider fallback behavior. - Added test-mailer-types.ts for MailerModule type exploration. --- backend/src/config/env.validation.ts | 1 + backend/src/modules/mail/mail.service.spec.ts | 62 +++++ backend/src/modules/mail/mail.service.ts | 247 +++++++++++++++++- backend/test-mailer-types.ts | 2 + 4 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 backend/src/modules/mail/mail.service.spec.ts create mode 100644 backend/test-mailer-types.ts diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index 94cfebf97..8cad70632 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -48,4 +48,5 @@ export const envValidationSchema = Joi.object({ MAIL_USER: Joi.string().optional(), MAIL_PASS: Joi.string().optional(), MAIL_FROM: Joi.string().optional(), + MAIL_PROVIDERS: Joi.string().optional(), }).or('DATABASE_URL', 'DB_HOST'); // enforce at least one DB connection strategy diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts new file mode 100644 index 000000000..1e02ba51f --- /dev/null +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -0,0 +1,62 @@ +import { MailerService } from '@nestjs-modules/mailer'; +import { ConfigService } from '@nestjs/config'; +import { MailService, MailTransport } from './mail.service'; + +describe('MailService', () => { + let mailerService: { sendMail: jest.Mock }; + let configService: Partial; + let service: MailService; + + beforeEach(() => { + mailerService = { sendMail: jest.fn() }; + configService = { + get: jest.fn((key: string, defaultValue?: unknown) => { + const values: Record = { + 'mail.from': 'noreply@nestera.test', + }; + + return values[key] ?? defaultValue; + }), + }; + + service = new MailService( + mailerService as unknown as MailerService, + configService as ConfigService, + () => [], + ); + }); + + it('retries the primary provider and falls back to the next provider when it is unavailable', async () => { + const primaryProvider: MailTransport = { + name: 'primary', + maxRetries: 1, + retryDelayMs: 0, + sendMail: jest.fn().mockRejectedValue(new Error('primary outage')), + }; + + const secondaryProvider: MailTransport = { + name: 'secondary', + maxRetries: 0, + retryDelayMs: 0, + sendMail: jest.fn().mockResolvedValue({ messageId: 'secondary-id' }), + }; + + service = new MailService( + mailerService as unknown as MailerService, + configService as ConfigService, + () => [primaryProvider, secondaryProvider], + ); + + await expect( + service.sendRawMail('user@example.com', 'Test subject', 'hello world'), + ).resolves.toBeUndefined(); + + expect(primaryProvider.sendMail).toHaveBeenCalledTimes(2); + expect(secondaryProvider.sendMail).toHaveBeenCalledTimes(1); + + const deliveryStatuses = service.getDeliveryStatusHistory(); + expect(deliveryStatuses).toHaveLength(1); + expect(deliveryStatuses[0].status).toBe('sent'); + expect(deliveryStatuses[0].provider).toBe('secondary'); + }); +}); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 2203b3611..5d9562957 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -1,15 +1,50 @@ import { Injectable, Logger } from '@nestjs/common'; import { MailerService } from '@nestjs-modules/mailer'; +import { ConfigService } from '@nestjs/config'; +import { createTransport } from 'nodemailer'; + +export interface MailTransport { + name: string; + sendMail(options: Record): Promise; + maxRetries?: number; + retryDelayMs?: number; + region?: string; +} + +export type MailTransportFactory = () => MailTransport[]; + +export interface EmailDeliveryStatus { + messageId: string; + to: string; + subject: string; + status: 'queued' | 'sending' | 'sent' | 'failed'; + attempts: number; + provider?: string; + lastAttemptAt: Date; + error?: string; +} @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); + private readonly deliveryStatuses = new Map(); + private readonly defaultFrom: string; - constructor(private readonly mailerService: MailerService) {} + constructor( + private readonly mailerService: MailerService, + private readonly configService: ConfigService, + private readonly mailTransportFactory?: MailTransportFactory, + ) { + this.defaultFrom = + this.configService.get( + 'mail.from', + '"Nestera" ', + ) ?? '"Nestera" '; + } async sendWelcomeEmail(userEmail: string, name: string): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Welcome to Nestera!', template: './welcome', @@ -29,7 +64,7 @@ export class MailService { amount: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Account Sweep Completed', template: './sweep-completed', @@ -55,7 +90,7 @@ export class MailService { netAmount: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Completed', template: './withdrawal-completed', @@ -83,7 +118,7 @@ export class MailService { notes?: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: `Medical Claim ${status}`, template: './claim-status', @@ -110,7 +145,7 @@ export class MailService { percentage: number, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: `Congrats — ${percentage}% of your goal achieved!`, template: './goal-milestone', @@ -137,7 +172,7 @@ export class MailService { productId: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'A savings product you waited for is available', template: './waitlist-available', @@ -161,7 +196,7 @@ export class MailService { message: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Savings product alert', template: './generic-notification', @@ -187,7 +222,7 @@ export class MailService { netAmount: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Approved', template: './withdrawal-approved', @@ -213,7 +248,7 @@ export class MailService { reason: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Rejected', template: './withdrawal-rejected', @@ -233,7 +268,7 @@ export class MailService { async sendRawMail(to: string, subject: string, text: string): Promise { try { - await this.mailerService.sendMail({ to, subject, text }); + await this.sendMailWithResilience({ to, subject, text }); } catch (error) { this.logger.error(`Failed to send raw email to ${to}`, error); } @@ -246,7 +281,7 @@ export class MailService { message: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject, template: './generic-notification', @@ -271,7 +306,7 @@ export class MailService { points: number, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: `You earned the "${badgeName}" badge!`, template: './badge-earned', @@ -299,7 +334,7 @@ export class MailService { filename: string, ): Promise { try { - await this.mailerService.sendMail({ + await this.sendMailWithResilience({ to: userEmail, subject: `Your ${reportType.replace(/_/g, ' ')} report — ${periodLabel}`, template: './savings-alert', @@ -319,4 +354,188 @@ export class MailService { this.logger.error(`Failed to send report email to ${userEmail}`, error); } } + + getDeliveryStatusHistory(): EmailDeliveryStatus[] { + return [...this.deliveryStatuses.values()].sort( + (left, right) => + right.lastAttemptAt.getTime() - left.lastAttemptAt.getTime(), + ); + } + + getDeliveryStatus(messageId: string): EmailDeliveryStatus | undefined { + return this.deliveryStatuses.get(messageId); + } + + private async sendMailWithResilience( + options: Record, + ): Promise { + const providers = this.mailTransportFactory + ? this.mailTransportFactory() + : this.buildDefaultTransports(); + + const normalizedProviders = providers.length + ? providers + : [ + { + name: 'default', + maxRetries: 1, + retryDelayMs: 0, + sendMail: (mailOptions: Record) => + this.mailerService.sendMail(mailOptions), + }, + ]; + + const messageId = this.createMessageId( + String(options.to ?? ''), + String(options.subject ?? ''), + ); + const status: EmailDeliveryStatus = { + messageId, + to: String(options.to ?? ''), + subject: String(options.subject ?? ''), + status: 'queued', + attempts: 0, + lastAttemptAt: new Date(), + }; + + this.deliveryStatuses.set(messageId, status); + + let lastError: Error | null = null; + + for (const provider of normalizedProviders) { + const maxAttempts = (provider.maxRetries ?? 1) + 1; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + status.status = 'sending'; + status.attempts += 1; + status.provider = provider.name; + status.lastAttemptAt = new Date(); + this.deliveryStatuses.set(messageId, { ...status }); + + try { + await provider.sendMail({ + ...options, + from: options.from ?? this.defaultFrom, + }); + + status.status = 'sent'; + status.error = undefined; + status.lastAttemptAt = new Date(); + this.deliveryStatuses.set(messageId, { ...status }); + return; + } catch (error) { + lastError = error as Error; + status.error = lastError.message; + status.lastAttemptAt = new Date(); + this.deliveryStatuses.set(messageId, { ...status }); + this.logger.warn( + `Provider ${provider.name} attempt ${attempt}/${maxAttempts} failed: ${lastError.message}`, + ); + + if (attempt < maxAttempts) { + await this.sleep( + (provider.retryDelayMs ?? 0) * Math.pow(2, attempt - 1), + ); + } + } + } + } + + status.status = 'failed'; + status.error = lastError?.message ?? 'Unknown mail delivery failure'; + status.lastAttemptAt = new Date(); + this.deliveryStatuses.set(messageId, { ...status }); + throw lastError ?? new Error(`Unable to deliver email to ${status.to}`); + } + + private buildDefaultTransports(): MailTransport[] { + const configuredProviders = + this.configService.get('mail.providers'); + + if (configuredProviders) { + try { + const parsedProviders = JSON.parse(configuredProviders) as Array< + Record + >; + + if (Array.isArray(parsedProviders)) { + return parsedProviders.map((providerConfig) => + this.buildTransportFromConfig(providerConfig), + ); + } + } catch (error) { + this.logger.warn( + `Unable to parse mail providers config: ${(error as Error).message}`, + ); + } + } + + const host = this.configService.get('mail.host'); + const port = this.configService.get('mail.port'); + const user = this.configService.get('mail.user'); + const pass = this.configService.get('mail.pass'); + + if (!host && !user && !pass) { + return []; + } + + return [ + this.buildTransportFromConfig({ + name: 'default', + host, + port, + secure: false, + user, + pass, + maxRetries: 1, + retryDelayMs: 0, + }), + ]; + } + + private buildTransportFromConfig( + providerConfig: Record, + ): MailTransport { + const name = String(providerConfig.name ?? 'default'); + const host = providerConfig.host as string | undefined; + const port = Number(providerConfig.port ?? 587); + const secure = Boolean(providerConfig.secure ?? false); + const user = providerConfig.user as string | undefined; + const pass = providerConfig.pass as string | undefined; + const maxRetries = Number(providerConfig.maxRetries ?? 1); + const retryDelayMs = Number(providerConfig.retryDelayMs ?? 0); + + const transporter = host + ? createTransport({ + host, + port, + secure, + auth: + user || pass + ? { + user: user ?? '', + pass: pass ?? '', + } + : undefined, + }) + : undefined; + + return { + name, + maxRetries: Number.isFinite(maxRetries) ? maxRetries : 1, + retryDelayMs: Number.isFinite(retryDelayMs) ? retryDelayMs : 0, + region: providerConfig.region as string | undefined, + sendMail: (mailOptions: Record) => + transporter?.sendMail(mailOptions) ?? + this.mailerService.sendMail(mailOptions), + }; + } + + private createMessageId(to: string, subject: string): string { + return `${to}:${subject}:${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/backend/test-mailer-types.ts b/backend/test-mailer-types.ts new file mode 100644 index 000000000..ac25f9b14 --- /dev/null +++ b/backend/test-mailer-types.ts @@ -0,0 +1,2 @@ +import { MailerModule } from '@nestjs-modules/mailer'; +console.log(Object.keys(MailerModule));