/** * Email Adapter * * Implements EmailPort using nodemailer. Le contenu et la mise en page des * emails vivent dans `templates/` : l'adaptateur ne fait que les assembler * (destinataire, expediteur, sujet) et les envoyer. */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; import * as https from 'https'; import { BankTransferToValidateEmail, ContactMessageEmail, EmailPort, EmailOptions, } from '@domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; /** * Noms d'expéditeur affichés (un nom lisible est moins souvent classé en spam). * * L'ADRESSE, elle, est toujours SMTP_FROM. Chaque type d'email partait d'une * adresse codée en dur (team@, bookings@, security@, carriers@xpeditis.com) : * un relais SMTP comme Brevo refuse tout expéditeur non validé chez lui. Seuls * les emails envoyés depuis SMTP_FROM (test SMTP, alerte virement aux admins) * partaient donc — invitations et demandes aux transporteurs étaient rejetées. */ const SENDER_NAMES = { SECURITY: 'Xpeditis Sécurité', BOOKINGS: 'Xpeditis Bookings', TEAM: 'Équipe Xpeditis', CARRIERS: 'Xpeditis Transporteurs', NOREPLY: 'Xpeditis', } as const; type SenderKind = keyof typeof SENDER_NAMES; const DEFAULT_FROM_ADDRESS = 'noreply@xpeditis.com'; /** * Version texte générée à partir du HTML (les emails sans version texte sont * pénalisés par les filtres anti-spam, et elle sert aux clients texte). * * Le HTML compilé par MJML contient un chargé de styles, des * commentaires conditionnels Outlook et un preheader masqué : ils sont retirés * avant d'extraire le texte, sinon la version texte commençait par du CSS. */ function htmlToPlainText(html: string): string { return html .replace(//gi, '') .replace(//g, '') .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/]*display:\s*none[^>]*>[\s\S]*?<\/div>/gi, '') .replace(//gi, '\n') .replace(/]*>/gi, '\n• ') .replace(/<\/(p|div|h[1-6]|tr|table|ul|ol)>/gi, '\n') .replace(/<\/td>/gi, ' ') .replace(/]*href="(?!mailto:)([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/→/g, '→') .replace(/[ \t]+/g, ' ') .replace(/ *\n */g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); } @Injectable() export class EmailAdapter implements EmailPort, OnModuleInit { private readonly logger = new Logger(EmailAdapter.name); private transporter: nodemailer.Transporter; constructor( private readonly configService: ConfigService, private readonly emailTemplates: EmailTemplates ) {} async onModuleInit(): Promise { const host = this.configService.get('SMTP_HOST', 'localhost'); // 🔧 FIX: Mailtrap — IP directe hardcodée if (host.includes('mailtrap.io')) { this.buildTransporter('3.209.246.195', host); return; } // 🔧 FIX: DNS over HTTPS — contourne le port 53 UDP (bloqué sur certains réseaux). // On appelle l'API DoH de Cloudflare via HTTPS (port 443) pour résoudre l'IP // AVANT de créer le transporter, puis on passe l'IP directement à nodemailer. if (!/^\d+\.\d+\.\d+\.\d+$/.test(host) && host !== 'localhost') { try { const ip = await this.resolveViaDoH(host); this.logger.log(`[DNS-DoH] ${host} → ${ip}`); this.buildTransporter(ip, host); return; } catch (err: any) { this.logger.warn( `[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly` ); } } this.buildTransporter(host, host); } /** * Résout un hostname en IP via l'API DNS over HTTPS de Cloudflare. * Utilise HTTPS (port 443) donc fonctionne même quand le port 53 UDP est bloqué. */ private resolveViaDoH(hostname: string): Promise { return new Promise((resolve, reject) => { const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`; const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, res => { let raw = ''; res.on('data', chunk => (raw += chunk)); res.on('end', () => { try { const json = JSON.parse(raw); const aRecord = (json.Answer ?? []).find((r: any) => r.type === 1); if (aRecord?.data) { resolve(aRecord.data); } else { reject(new Error(`No A record returned by DoH for ${hostname}`)); } } catch (e) { reject(e); } }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('DoH request timed out')); }); }); } private buildTransporter(actualHost: string, serverName: string): void { const port = this.configService.get('SMTP_PORT', 2525); const user = this.configService.get('SMTP_USER'); const pass = this.configService.get('SMTP_PASS'); const secure = this.configService.get('SMTP_SECURE', false); this.transporter = nodemailer.createTransport({ host: actualHost, port, secure, requireTLS: this.configService.get('NODE_ENV') === 'production', auth: { user, pass }, tls: { rejectUnauthorized: true, servername: serverName, }, connectionTimeout: 15000, greetingTimeout: 15000, socketTimeout: 30000, } as any); this.logger.log( `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}` ); this.transporter.verify(error => { if (error) { this.logger.error(`❌ SMTP connection FAILED: ${error.message}`); } else { this.logger.log(`✅ SMTP connection verified — ready to send emails`); } }); } /** Expéditeur : nom affiché selon le type d'email, adresse validée SMTP_FROM. */ private sender(kind: SenderKind): string { const address = this.configService.get('SMTP_FROM', DEFAULT_FROM_ADDRESS); return `"${SENDER_NAMES[kind]}" <${address}>`; } private get appUrl(): string { return String(this.configService.get('APP_URL', 'http://localhost:3000')).replace( /\/+$/, '' ); } async send(options: EmailOptions): Promise { try { const from = options.from ?? this.sender('NOREPLY'); // Génère automatiquement la version plain text si absente (améliore le score anti-spam) const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined); const info = await this.transporter.sendMail({ from, to: options.to, cc: options.cc, bcc: options.bcc, replyTo: options.replyTo, subject: options.subject, html: options.html, text, attachments: options.attachments, }); this.logger.log( `✅ Email submitted — to: ${options.to} | from: ${from} | subject: "${options.subject}" | messageId: ${info.messageId} | accepted: ${JSON.stringify(info.accepted)} | rejected: ${JSON.stringify(info.rejected)}` ); } catch (error) { this.logger.error('Email delivery failed'); throw new Error('Email delivery failed'); } } /* ---------------------------------------------------------------------- */ /* Compte et sécurité */ /* ---------------------------------------------------------------------- */ async sendVerificationEmail(email: string, token: string): Promise { const verifyUrl = `${this.appUrl}/verify-email?token=${token}`; const html = await this.emailTemplates.renderVerificationEmail({ verifyUrl }); await this.send({ to: email, from: this.sender('SECURITY'), subject: 'Confirmez votre adresse email — Xpeditis', html, }); } async sendPasswordResetEmail(email: string, token: string): Promise { const resetUrl = `${this.appUrl}/reset-password?token=${token}`; const html = await this.emailTemplates.renderPasswordResetEmail({ resetUrl }); await this.send({ to: email, from: this.sender('SECURITY'), subject: 'Réinitialisez votre mot de passe Xpeditis', html, }); } async sendWelcomeEmail(email: string, firstName: string): Promise { const html = await this.emailTemplates.renderWelcomeEmail({ firstName, dashboardUrl: `${this.appUrl}/dashboard`, }); await this.send({ to: email, from: this.sender('NOREPLY'), subject: `Bienvenue sur Xpeditis, ${firstName}`, html, }); } async sendUserInvitation( email: string, organizationName: string, inviterName: string, tempPassword: string ): Promise { const html = await this.emailTemplates.renderUserInvitation({ organizationName, inviterName, tempPassword, loginUrl: `${this.appUrl}/login`, email, }); await this.send({ to: email, from: this.sender('TEAM'), subject: `Votre accès à ${organizationName} sur Xpeditis`, html, }); } async sendInvitationWithToken( email: string, firstName: string, lastName: string, organizationName: string, inviterName: string, invitationLink: string, expiresAt: Date ): Promise { try { const expiresAtFormatted = expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', }); const html = await this.emailTemplates.renderInvitationWithToken({ firstName, lastName, organizationName, inviterName, invitationLink, expiresAt: expiresAtFormatted, }); await this.send({ to: email, from: this.sender('TEAM'), subject: `${inviterName} vous invite à rejoindre ${organizationName} sur Xpeditis`, html, }); this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); } catch (error) { this.logger.error('Invitation email delivery failed'); throw new Error('Invitation email delivery failed'); } } /* ---------------------------------------------------------------------- */ /* Réservations */ /* ---------------------------------------------------------------------- */ async sendBookingConfirmation( email: string, bookingNumber: string, bookingDetails: any, pdfAttachment?: Buffer ): Promise { const html = await this.emailTemplates.renderBookingConfirmation({ bookingNumber, bookingDetails, // Le lien du bouton n'etait jamais fourni : il pointait nulle part. dashboardUrl: `${this.appUrl}/dashboard/bookings`, }); const attachments = pdfAttachment ? [ { filename: `booking-${bookingNumber}.pdf`, content: pdfAttachment, contentType: 'application/pdf', }, ] : undefined; await this.send({ to: email, from: this.sender('BOOKINGS'), subject: `Réservation confirmée — ${bookingNumber}`, html, attachments, }); } async sendCsvBookingRequest( carrierEmail: string, bookingData: { bookingId: string; bookingNumber?: string; documentPassword?: string; origin: string; destination: string; volumeCBM: number; weightKG: number; palletCount: number; priceUSD: number; priceEUR: number; primaryCurrency: string; transitDays: number; containerType: string; documents: Array<{ type: string; fileName: string; }>; confirmationToken: string; notes?: string; } ): Promise { // Les pages du frontend appellent ensuite l'API /accept/:token et /reject/:token. const acceptUrl = `${this.appUrl}/carrier/accept/${bookingData.confirmationToken}`; const rejectUrl = `${this.appUrl}/carrier/reject/${bookingData.confirmationToken}`; const html = await this.emailTemplates.renderCsvBookingRequest({ ...bookingData, acceptUrl, rejectUrl, }); await this.send({ to: carrierEmail, from: this.sender('BOOKINGS'), subject: `Nouvelle demande de réservation${bookingData.bookingNumber ? ` ${bookingData.bookingNumber}` : ''} — ${bookingData.origin} → ${bookingData.destination}`, html, }); this.logger.log( `CSV booking request sent to ${carrierEmail} for booking ${bookingData.bookingId}` ); } async sendDocumentAccessEmail( carrierEmail: string, data: { carrierName: string; bookingId: string; bookingNumber?: string; documentPassword?: string; origin: string; destination: string; volumeCBM: number; weightKG: number; documentCount: number; confirmationToken: string; } ): Promise { const html = await this.emailTemplates.renderDocumentAccess({ ...data, documentsUrl: `${this.appUrl}/carrier/documents/${data.confirmationToken}`, }); const reference = data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase(); await this.send({ to: carrierEmail, from: this.sender('BOOKINGS'), subject: `Documents disponibles — réservation ${reference} (${data.origin} → ${data.destination})`, html, }); this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`); } async sendNewDocumentsNotification( carrierEmail: string, data: { carrierName: string; bookingId: string; origin: string; destination: string; newDocumentsCount: number; totalDocumentsCount: number; confirmationToken: string; } ): Promise { const html = await this.emailTemplates.renderNewDocuments({ ...data, documentsUrl: `${this.appUrl}/carrier/documents/${data.confirmationToken}`, }); const count = data.newDocumentsCount; await this.send({ to: carrierEmail, from: this.sender('BOOKINGS'), subject: `${count} ${count > 1 ? 'nouveaux documents' : 'nouveau document'} — réservation ${data.origin} → ${data.destination}`, html, }); this.logger.log( `New documents notification sent to ${carrierEmail} for booking ${data.bookingId}` ); } /* ---------------------------------------------------------------------- */ /* Espace transporteur */ /* ---------------------------------------------------------------------- */ async sendCarrierAccountCreated( email: string, carrierName: string, temporaryPassword: string ): Promise { // Pas de page /carrier/login : les transporteurs se connectent par /login. const html = await this.emailTemplates.renderCarrierAccountCreated({ carrierName, email, temporaryPassword, loginUrl: `${this.appUrl}/login`, }); await this.send({ to: email, from: this.sender('CARRIERS'), subject: 'Votre compte transporteur Xpeditis est prêt', html, }); this.logger.log(`Carrier account creation email sent to ${email}`); } async sendCarrierPasswordReset( email: string, carrierName: string, temporaryPassword: string ): Promise { const html = await this.emailTemplates.renderCarrierPasswordReset({ carrierName, temporaryPassword, loginUrl: `${this.appUrl}/login`, }); await this.send({ to: email, from: this.sender('SECURITY'), subject: 'Votre mot de passe transporteur Xpeditis a été réinitialisé', html, }); this.logger.log(`Carrier password reset email sent to ${email}`); } /* ---------------------------------------------------------------------- */ /* Emails internes */ /* ---------------------------------------------------------------------- */ async sendContactMessage(to: string, data: ContactMessageEmail): Promise { const html = await this.emailTemplates.renderContactMessage(data); await this.send({ to, // Répondre au message écrit directement à la personne qui l'a envoyé. replyTo: data.email, subject: `[Contact] ${data.subjectLabel} — ${data.firstName} ${data.lastName}`, html, }); } async sendBankTransferToValidate(to: string[], data: BankTransferToValidateEmail): Promise { const html = await this.emailTemplates.renderBankTransferToValidate({ ...data, adminUrl: `${this.appUrl}/admin/bookings`, }); await this.send({ to, subject: `Virement à valider — ${data.bookingNumber}`, html, }); } async sendSmtpTest(to: string, requestedBy: string): Promise { const html = await this.emailTemplates.renderSmtpTest({ requestedBy, sentAt: new Date().toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' }), }); await this.send({ to, subject: 'Test SMTP Xpeditis', html }); } }