diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 4f47a6a..d88d1d8 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -1,7 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { MailService, ValidationAccountEmailKind } from './mail.service'; +import { MailService, ValidationAccountEmailKind, isTransientSmtpError } from './mail.service'; import { AppConfigService } from '../config/config.service'; +describe('isTransientSmtpError', () => { + it('détecte les erreurs SMTP temporaires (454, coupure auth, timeout)', () => { + expect(isTransientSmtpError(new Error('Invalid login: 454 4.7.0 Temporary authentication failure'))).toBe(true); + expect(isTransientSmtpError(new Error('Connection lost to authentication server'))).toBe(true); + expect(isTransientSmtpError(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))).toBe(true); + expect(isTransientSmtpError(Object.assign(new Error('auth failed'), { responseCode: 454 }))).toBe(true); + }); + + it('ignore les erreurs permanentes (mauvaise adresse, refus définitif)', () => { + expect(isTransientSmtpError(new Error('Invalid login: 535 Authentication failed'))).toBe(false); + expect(isTransientSmtpError(new Error('Mailbox unavailable'))).toBe(false); + }); +}); + describe('MailService (ticket #28)', () => { let service: MailService; let sendEmailSpy: jest.SpyInstance; diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index 91d2dfb..0c2be27 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -2,20 +2,125 @@ import { Injectable, Logger } from '@nestjs/common'; import { readFileSync } from 'fs'; import { join } from 'path'; import * as Handlebars from 'handlebars'; +import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import { AppConfigService } from '../config/config.service'; /** Ticket #28 — quel template d'email envoyer après validation de compte */ export type ValidationAccountEmailKind = 'parent' | 'am'; +type MailTransporter = { + sendMail: (options: Record) => Promise; +}; + +/** Erreurs SMTP temporaires (surcharge auth, coupure réseau…) — retentables. */ +export function isTransientSmtpError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const code = typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : ''; + const responseCode = + typeof error === 'object' && error !== null && 'responseCode' in error + ? Number((error as { responseCode?: unknown }).responseCode) + : undefined; + + if (responseCode === 454 || responseCode === 421 || responseCode === 450 || responseCode === 451) { + return true; + } + + const transientCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ECONNREFUSED', 'EPIPE']); + if (transientCodes.has(code)) { + return true; + } + + const transientPatterns = [ + /temporary authentication failure/i, + /connection lost to authentication server/i, + /connection reset/i, + /timeout/i, + /try again later/i, + ]; + return transientPatterns.some((pattern) => pattern.test(message)); +} + @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); + private readonly smtpMaxAttempts = 3; + private readonly smtpRetryBaseDelayMs = 1000; /** Cache des templates Handlebars compilés (fichiers .hbs) */ private readonly compiledTemplates = new Map(); + /** Transporteur SMTP réutilisé (évite une auth TCP/SASL par email). */ + private transporter: MailTransporter | null = null; + private transporterConfigKey: string | null = null; + constructor(private readonly configService: AppConfigService) {} + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private buildSmtpTransportConfig(): SMTPTransport.Options { + const smtpHost = this.configService.get('smtp_host'); + const smtpPort = this.configService.get('smtp_port'); + const smtpSecure = this.configService.get('smtp_secure'); + const smtpAuthRequired = this.configService.get('smtp_auth_required'); + const smtpUser = this.configService.get('smtp_user'); + const smtpPassword = this.configService.get('smtp_password'); + + const transportConfig: SMTPTransport.Options = { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + pool: true, + maxConnections: 1, + maxMessages: 100, + }; + + if (smtpAuthRequired && smtpUser && smtpPassword) { + transportConfig.auth = { + user: smtpUser, + pass: smtpPassword, + }; + } + + return transportConfig; + } + + private getSmtpConfigKey(config: SMTPTransport.Options): string { + const auth = config.auth as { user?: string; pass?: string } | undefined; + return JSON.stringify({ + host: config.host, + port: config.port, + secure: config.secure, + user: auth?.user ?? '', + pass: auth?.pass ?? '', + }); + } + + private resetTransporter(): void { + const current = this.transporter as { close?: () => void } | null; + current?.close?.(); + this.transporter = null; + this.transporterConfigKey = null; + } + + private async getTransporter(): Promise { + const transportConfig = this.buildSmtpTransportConfig(); + const configKey = this.getSmtpConfigKey(transportConfig); + + if (this.transporter && this.transporterConfigKey === configKey) { + return this.transporter; + } + + this.resetTransporter(); + const nodemailer = await import('nodemailer'); + this.transporter = nodemailer.createTransport(transportConfig) as MailTransporter; + this.transporterConfigKey = configKey; + return this.transporter; + } + private templateBasename(kind: ValidationAccountEmailKind): string { const map: Record = { parent: 'account-validated-parent', @@ -81,50 +186,48 @@ export class MailService { * @param text Contenu texte (optionnel) */ async sendEmail(to: string, subject: string, html: string, text?: string): Promise { - try { - // Récupération de la configuration SMTP - const smtpHost = this.configService.get('smtp_host'); - const smtpPort = this.configService.get('smtp_port'); - const smtpSecure = this.configService.get('smtp_secure'); - const smtpAuthRequired = this.configService.get('smtp_auth_required'); - const smtpUser = this.configService.get('smtp_user'); - const smtpPassword = this.configService.get('smtp_password'); - const emailFromName = this.configService.get('email_from_name'); - const emailFromAddress = this.configService.get('email_from_address'); + const emailFromName = this.configService.get('email_from_name'); + const emailFromAddress = this.configService.get('email_from_address'); + const mailOptions = { + from: `"${emailFromName}" <${emailFromAddress}>`, + to, + subject, + text: text || html.replace(/<[^>]*>?/gm, ''), + html, + }; - // Import dynamique de nodemailer - const nodemailer = await import('nodemailer'); + let lastError: unknown; - // Configuration du transporteur - const transportConfig: any = { - host: smtpHost, - port: smtpPort, - secure: smtpSecure, - }; + for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) { + try { + const transporter = await this.getTransporter(); + await transporter.sendMail(mailOptions); + this.logger.log(`📧 Email envoyé à ${to} : ${subject}`); + return; + } catch (error) { + lastError = error; + const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error); - if (smtpAuthRequired && smtpUser && smtpPassword) { - transportConfig.auth = { - user: smtpUser, - pass: smtpPassword, - }; + if (canRetry) { + const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1); + this.logger.warn( + `SMTP temporairement indisponible pour ${to} (tentative ${attempt}/${this.smtpMaxAttempts}), nouvel essai dans ${delayMs}ms`, + error instanceof Error ? error.message : error, + ); + this.resetTransporter(); + await this.sleep(delayMs); + continue; + } + + this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error); + this.resetTransporter(); + throw error; } - - const transporter = nodemailer.createTransport(transportConfig); - - // Envoi de l'email - await transporter.sendMail({ - from: `"${emailFromName}" <${emailFromAddress}>`, - to, - subject, - text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple - html, - }); - - this.logger.log(`📧 Email envoyé à ${to} : ${subject}`); - } catch (error) { - this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error); - throw error; } + + this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError); + this.resetTransporter(); + throw lastError; } /** diff --git a/backend/src/routes/user/user.service.ts b/backend/src/routes/user/user.service.ts index 3dc2532..be700a3 100644 --- a/backend/src/routes/user/user.service.ts +++ b/backend/src/routes/user/user.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { In, MoreThan, Repository } from "typeorm"; @@ -390,6 +390,7 @@ export class UserService { return updatedUsers; }); + const emailFailures: string[] = []; for (const savedUser of savedUsers) { try { await this.mailService.sendRefusEmail( @@ -400,10 +401,17 @@ export class UserService { tokenReprise, ); } catch (err) { - this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err); + emailFailures.push(savedUser.email); + this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err); } } + if (emailFailures.length > 0) { + throw new ServiceUnavailableException( + `Le dossier a bien été refusé en base, mais l'envoi d'email a échoué pour : ${emailFailures.join(', ')}. Réessayez ou contactez le support.`, + ); + } + return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0]; }