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 = { host: smtpHost, port: smtpPort, secure: smtpSecure, pool: true, maxConnections: 1, maxMessages: 100, ...(smtpAuthRequired && smtpUser && smtpPassword ? { auth: { user: smtpUser, pass: smtpPassword } } : {}), } as SMTPTransport.Options; 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', am: 'account-validated-am', }; return map[kind]; } private getCompiledTemplate(kind: ValidationAccountEmailKind): Handlebars.TemplateDelegate { let compiled = this.compiledTemplates.get(kind); if (!compiled) { const filePath = join(__dirname, 'templates', `${this.templateBasename(kind)}.hbs`); const source = readFileSync(filePath, 'utf8'); compiled = Handlebars.compile(source); this.compiledTemplates.set(kind, compiled); } return compiled; } /** * Email post-validation : lien création MDP (token existant ou régénéré côté appelant). * Ticket #28 — app_name, app_url, expéditeur via ConfigService (sendEmail). */ async sendValidatedAccountPasswordSetupEmail( recipient: { email: string; prenom: string; nom: string; token: string; numeroDossier?: string | null; }, kind: ValidationAccountEmailKind, ): Promise { const appName = this.configService.get('app_name', "P'titsPas"); const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`; const data: Record = { prenom: recipient.prenom || '', nom: recipient.nom || '', appName, appUrl, createPasswordUrl, numeroDossier: recipient.numeroDossier || '', }; const html = this.getCompiledTemplate(kind)(data) as string; const subjects: Record = { parent: `${appName} — Compte parent validé : créez votre mot de passe`, am: `${appName} — Inscription validée : créez votre mot de passe`, }; await this.sendEmail(recipient.email, subjects[kind], html); } /** * Envoi d'un email générique * @param to Destinataire * @param subject Sujet * @param html Contenu HTML * @param text Contenu texte (optionnel) */ async sendEmail(to: string, subject: string, html: string, text?: string): Promise { 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, }; let lastError: unknown; 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 (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; } } this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError); this.resetTransporter(); throw lastError; } /** * Envoi de l'email de bienvenue pour un gestionnaire * @param to Email du gestionnaire * @param prenom Prénom * @param nom Nom * @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable) * @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation * Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien. * Le ticket #24 parle de "API Création mot de passe" via token. * Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé). * Si on suit le ticket #35 (Frontend), on saisit un mot de passe. * Donc on envoie juste un email de confirmation de création de compte. */ async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise { const appName = this.configService.get('app_name', 'P\'titsPas'); const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); const subject = `Bienvenue sur ${appName}`; const html = `

Bienvenue ${prenom} ${nom} !

Votre compte gestionnaire sur ${appName} a été créé avec succès.

Vous pouvez dès à présent vous connecter avec l'adresse email ${to} et le mot de passe qui vous a été communiqué.

Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.


Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

`; await this.sendEmail(to, subject, html); } /** * Accusé de réception inscription (parent ou assistante maternelle) : * demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade. */ async sendRegistrationPendingEmail( to: string, prenom: string, nom: string, numeroDossier: string, ): Promise { const appName = this.configService.get('app_name', "P'titsPas"); const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); const safe = (s: string) => (s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`; const html = `

Bonjour ${safe(prenom)} ${safe(nom)},

Nous avons bien enregistré votre demande de création de compte sur ${safe(appName)}.

Numéro de dossier : ${safe(numeroDossier)}

Votre dossier est en attente de validation par notre équipe. Vous recevrez un email lorsqu’il aura été traité.


Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

`; await this.sendEmail(to, subject, html); } /** * Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation. */ async sendResoumissionPendingEmail( to: string, prenom: string, nom: string, numeroDossier: string, ): Promise { const appName = this.configService.get('app_name', "P'titsPas"); const appUrl = this.configService.get('app_url', 'https://app.ptits-pas.fr'); const safe = (s: string) => (s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const subject = `Votre dossier a été resoumis — ${safe(numeroDossier)}`; const html = `

Bonjour ${safe(prenom)} ${safe(nom)},

Nous avons bien reçu la resoumission de votre dossier sur ${safe(appName)}.

Numéro de dossier : ${safe(numeroDossier)}

Votre dossier est de nouveau en attente de validation par notre équipe. Vous recevrez un email lorsqu'il aura été traité.


Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

`; await this.sendEmail(to, subject, html); } /** * Email de refus de dossier avec lien reprise (token). * Ticket #110 – Refus sans suppression */ async sendRefusEmail( to: string, prenom: string, nom: string, comment: string | undefined, token: string, ): Promise { const appName = this.configService.get('app_name', "P'titsPas"); const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`; const safe = (s: string) => (s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const subject = `Votre dossier – compléments demandés`; const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.'); const html = `

Bonjour ${safe(prenom)} ${safe(nom)},

Votre dossier d'inscription sur ${safe(appName)} n'a pas pu être validé en l'état.

Message du gestionnaire :

${commentText}

Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.

Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.


Cet email a été envoyé automatiquement. Merci de ne pas y répondre.

`; await this.sendEmail(to, subject, html); } /** * Ticket #127 — lien application `/reset-password?token=` (pas create-password). */ async sendPasswordResetEmail(recipient: { email: string; prenom: string; nom: string; token: string; }): Promise { const appName = this.configService.get('app_name', "P'titsPas"); const appUrl = (this.configService.get('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, ''); const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`; const filePath = join(__dirname, 'templates', 'password-reset.hbs'); const source = readFileSync(filePath, 'utf8'); const compiled = Handlebars.compile(source); const html = compiled({ prenom: recipient.prenom || '', nom: recipient.nom || '', appName, resetPasswordUrl, }) as string; const subject = `${appName} — Réinitialisation de votre mot de passe`; await this.sendEmail(recipient.email, subject, html); } }