376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
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<string, unknown>) => Promise<unknown>;
|
||
};
|
||
|
||
/** 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<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
|
||
|
||
/** 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<void> {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
private buildSmtpTransportConfig(): SMTPTransport.Options {
|
||
const smtpHost = this.configService.get<string>('smtp_host');
|
||
const smtpPort = this.configService.get<number>('smtp_port');
|
||
const smtpSecure = this.configService.get<boolean>('smtp_secure');
|
||
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
|
||
const smtpUser = this.configService.get<string>('smtp_user');
|
||
const smtpPassword = this.configService.get<string>('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<MailTransporter> {
|
||
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<ValidationAccountEmailKind, string> = {
|
||
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<void> {
|
||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
|
||
|
||
const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`;
|
||
|
||
const data: Record<string, string> = {
|
||
prenom: recipient.prenom || '',
|
||
nom: recipient.nom || '',
|
||
appName,
|
||
appUrl,
|
||
createPasswordUrl,
|
||
numeroDossier: recipient.numeroDossier || '',
|
||
};
|
||
|
||
const html = this.getCompiledTemplate(kind)(data) as string;
|
||
|
||
const subjects: Record<ValidationAccountEmailKind, string> = {
|
||
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<void> {
|
||
const emailFromName = this.configService.get<string>('email_from_name');
|
||
const emailFromAddress = this.configService.get<string>('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<void> {
|
||
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
|
||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||
|
||
const subject = `Bienvenue sur ${appName}`;
|
||
const html = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
|
||
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
|
||
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
|
||
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
|
||
</div>
|
||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||
<p style="color: #666; font-size: 12px;">
|
||
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
|
||
</p>
|
||
</div>
|
||
`;
|
||
|
||
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<void> {
|
||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||
|
||
const safe = (s: string) =>
|
||
(s || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
|
||
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
|
||
const html = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
|
||
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
|
||
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsqu’il aura été traité.</p>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
|
||
</div>
|
||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||
</div>
|
||
`;
|
||
|
||
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<void> {
|
||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||
const appUrl = (this.configService.get<string>('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, '>')
|
||
.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 = `
|
||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
|
||
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;">
|
||
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
|
||
<p style="margin: 0;">${commentText}</p>
|
||
</div>
|
||
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
|
||
<div style="text-align: center; margin: 30px 0;">
|
||
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
||
</div>
|
||
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
|
||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||
</div>
|
||
`;
|
||
|
||
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<void> {
|
||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||
const appUrl = (this.configService.get<string>('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);
|
||
}
|
||
}
|