diff --git a/backend/src/config/env.validation.ts b/backend/src/config/env.validation.ts index d18d8a1e..e9ca9dc7 100644 --- a/backend/src/config/env.validation.ts +++ b/backend/src/config/env.validation.ts @@ -48,6 +48,7 @@ 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(), // ── Compression (response + request body) ────────────────────────────────── COMPRESSION_THRESHOLD: Joi.number().integer().min(0).default(1024).optional(), 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 00000000..1e02ba51 --- /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 00bc9063..34a4c830 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -1,38 +1,52 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; import { MailerService } from '@nestjs-modules/mailer'; +import { ConfigService } from '@nestjs/config'; +import { createTransport } from 'nodemailer'; import { TestModeService } from '../../common/test-mode/test-mode.service'; +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, + private readonly configService: ConfigService, @Optional() private readonly testModeService?: TestModeService, - ) {} - - private async sendMailWithTestMode( - options: Record, - ): Promise { - if (this.testModeService?.isEnabled) { - this.testModeService.captureEmail({ - to: options.to as string, - subject: options.subject as string, - text: options.text as string | undefined, - template: options.template as string | undefined, - context: options.context as Record | undefined, - attachments: options.attachments as - | { filename: string; content: Buffer }[] - | undefined, - }); - return; - } - await this.mailerService.sendMail(options); + private readonly mailTransportFactory?: MailTransportFactory, + ) { + this.defaultFrom = + this.configService.get( + 'mail.from', + '"Nestera" ', + ) ?? '"Nestera" '; } async sendWelcomeEmail(userEmail: string, name: string): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Welcome to Nestera!', template: './welcome', @@ -52,7 +66,7 @@ export class MailService { amount: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Account Sweep Completed', template: './sweep-completed', @@ -78,7 +92,7 @@ export class MailService { netAmount: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Completed', template: './withdrawal-completed', @@ -106,7 +120,7 @@ export class MailService { notes?: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: `Medical Claim ${status}`, template: './claim-status', @@ -133,7 +147,7 @@ export class MailService { percentage: number, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: `Congrats — ${percentage}% of your goal achieved!`, template: './goal-milestone', @@ -160,7 +174,7 @@ export class MailService { productId: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'A savings product you waited for is available', template: './waitlist-available', @@ -184,7 +198,7 @@ export class MailService { message: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Savings product alert', template: './generic-notification', @@ -210,7 +224,7 @@ export class MailService { netAmount: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Approved', template: './withdrawal-approved', @@ -236,7 +250,7 @@ export class MailService { reason: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: 'Withdrawal Request Rejected', template: './withdrawal-rejected', @@ -256,7 +270,7 @@ export class MailService { async sendRawMail(to: string, subject: string, text: string): Promise { try { - await this.sendMailWithTestMode({ to, subject, text }); + await this.sendMailWithResilience({ to, subject, text }); } catch (error) { this.logger.error(`Failed to send raw email to ${to}`, error); } @@ -269,7 +283,7 @@ export class MailService { message: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject, template: './generic-notification', @@ -294,7 +308,7 @@ export class MailService { points: number, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: `You earned the "${badgeName}" badge!`, template: './badge-earned', @@ -322,7 +336,7 @@ export class MailService { filename: string, ): Promise { try { - await this.sendMailWithTestMode({ + await this.sendMailWithResilience({ to: userEmail, subject: `Your ${reportType.replace(/_/g, ' ')} report — ${periodLabel}`, template: './savings-alert', @@ -342,4 +356,204 @@ 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 { + // In test mode, capture the email instead of actually sending it, + // and skip the provider/retry machinery entirely. + if (this.testModeService?.isEnabled) { + this.testModeService.captureEmail({ + to: options.to as string, + subject: options.subject as string, + text: options.text as string | undefined, + template: options.template as string | undefined, + context: options.context as Record | undefined, + attachments: options.attachments as + | { filename: string; content: Buffer }[] + | undefined, + }); + return; + } + + 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)); + } +} \ No newline at end of file diff --git a/backend/test-mailer-types.ts b/backend/test-mailer-types.ts new file mode 100644 index 00000000..ac25f9b1 --- /dev/null +++ b/backend/test-mailer-types.ts @@ -0,0 +1,2 @@ +import { MailerModule } from '@nestjs-modules/mailer'; +console.log(Object.keys(MailerModule));