/** * Email Adapter * * Implements EmailPort using nodemailer */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; import * as https from 'https'; import { EmailPort, EmailOptions } from '@domain/ports/out/email.port'; import { EmailTemplates } from './templates/email-templates'; // Display names included → moins susceptibles d'être marqués spam const EMAIL_SENDERS = { SECURITY: '"Xpeditis Sécurité" ', BOOKINGS: '"Xpeditis Bookings" ', TEAM: '"Équipe Xpeditis" ', CARRIERS: '"Xpeditis Transporteurs" ', NOREPLY: '"Xpeditis" ', } as const; /** * Génère une version plain text à partir du HTML pour améliorer la délivrabilité. * Les emails sans version texte sont pénalisés par les filtres anti-spam. */ function htmlToPlainText(html: string): string { return html .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(//gi, '\n') .replace(/<\/p>/gi, '\n\n') .replace(/<\/div>/gi, '\n') .replace(/<\/h[1-6]>/gi, '\n\n') .replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)') .replace(/<[^>]+>/g, '') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/"/g, '"') .replace(/'/g, "'") .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, auth: { user, pass }, tls: { rejectUnauthorized: false, 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`); } }); } async send(options: EmailOptions): Promise { try { const from = options.from ?? this.configService.get('SMTP_FROM', EMAIL_SENDERS.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(`Failed to send email to ${options.to}`, error); throw error; } } async sendBookingConfirmation( email: string, bookingNumber: string, bookingDetails: any, pdfAttachment?: Buffer ): Promise { const html = await this.emailTemplates.renderBookingConfirmation({ bookingNumber, bookingDetails, }); const attachments = pdfAttachment ? [ { filename: `booking-${bookingNumber}.pdf`, content: pdfAttachment, contentType: 'application/pdf', }, ] : undefined; await this.send({ to: email, from: EMAIL_SENDERS.BOOKINGS, subject: `Booking Confirmation - ${bookingNumber}`, html, attachments, }); } async sendVerificationEmail(email: string, token: string): Promise { const verifyUrl = `${this.configService.get('APP_URL')}/verify-email?token=${token}`; const html = await this.emailTemplates.renderVerificationEmail({ verifyUrl, }); await this.send({ to: email, from: EMAIL_SENDERS.SECURITY, subject: 'Verify your email - Xpeditis', html, }); } async sendPasswordResetEmail(email: string, token: string): Promise { const resetUrl = `${this.configService.get('APP_URL')}/reset-password?token=${token}`; const html = await this.emailTemplates.renderPasswordResetEmail({ resetUrl, }); await this.send({ to: email, from: EMAIL_SENDERS.SECURITY, subject: 'Reset your password - Xpeditis', html, }); } async sendWelcomeEmail(email: string, firstName: string): Promise { const html = await this.emailTemplates.renderWelcomeEmail({ firstName, dashboardUrl: `${this.configService.get('APP_URL')}/dashboard`, }); await this.send({ to: email, from: EMAIL_SENDERS.NOREPLY, subject: 'Welcome to Xpeditis', html, }); } async sendUserInvitation( email: string, organizationName: string, inviterName: string, tempPassword: string ): Promise { const loginUrl = `${this.configService.get('APP_URL')}/login`; const html = await this.emailTemplates.renderUserInvitation({ organizationName, inviterName, tempPassword, loginUrl, }); await this.send({ to: email, from: EMAIL_SENDERS.TEAM, subject: `You've been invited to join ${organizationName} on Xpeditis`, html, }); } async sendInvitationWithToken( email: string, firstName: string, lastName: string, organizationName: string, inviterName: string, invitationLink: string, expiresAt: Date ): Promise { try { this.logger.log(`[sendInvitationWithToken] Starting email generation for ${email}`); const expiresAtFormatted = expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', }); this.logger.log(`[sendInvitationWithToken] Rendering template...`); const html = await this.emailTemplates.renderInvitationWithToken({ firstName, lastName, organizationName, inviterName, invitationLink, expiresAt: expiresAtFormatted, }); this.logger.log(`[sendInvitationWithToken] Template rendered, sending email to ${email}...`); this.logger.log(`[sendInvitationWithToken] HTML size: ${html.length} bytes`); await this.send({ to: email, from: EMAIL_SENDERS.TEAM, subject: `Invitation à rejoindre ${organizationName} sur Xpeditis`, html, }); this.logger.log(`Invitation email sent to ${email} for ${organizationName}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorCode = (error as any).code; const errorResponse = (error as any).response; const errorResponseCode = (error as any).responseCode; const errorCommand = (error as any).command; this.logger.error(`[sendInvitationWithToken] ERROR MESSAGE: ${errorMessage}`); this.logger.error(`[sendInvitationWithToken] ERROR CODE: ${errorCode}`); this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE: ${errorResponse}`); this.logger.error(`[sendInvitationWithToken] ERROR RESPONSE CODE: ${errorResponseCode}`); this.logger.error(`[sendInvitationWithToken] ERROR COMMAND: ${errorCommand}`); if (error instanceof Error && error.stack) { this.logger.error(`[sendInvitationWithToken] STACK: ${error.stack.substring(0, 500)}`); } throw error; } } 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 { // Use APP_URL (frontend) for accept/reject links // The frontend pages will call the backend API at /accept/:token and /reject/:token const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const acceptUrl = `${frontendUrl}/carrier/accept/${bookingData.confirmationToken}`; const rejectUrl = `${frontendUrl}/carrier/reject/${bookingData.confirmationToken}`; const html = await this.emailTemplates.renderCsvBookingRequest({ ...bookingData, acceptUrl, rejectUrl, }); await this.send({ to: carrierEmail, from: EMAIL_SENDERS.BOOKINGS, subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`, html, }); this.logger.log( `CSV booking request sent to ${carrierEmail} for booking ${bookingData.bookingId}` ); } /** * Send carrier account creation email with temporary password */ async sendCarrierAccountCreated( email: string, carrierName: string, temporaryPassword: string ): Promise { const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const loginUrl = `${baseUrl}/carrier/login`; const html = `

🚢 Bienvenue sur Xpeditis

Votre compte transporteur a été créé

Bonjour ${carrierName},

Un compte transporteur a été automatiquement créé pour vous sur la plateforme Xpeditis.

Vos identifiants de connexion :

Email : ${email}

Mot de passe temporaire : ${temporaryPassword}

⚠️ Important : Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire dès votre première connexion.

Prochaines étapes :

  1. Connectez-vous avec vos identifiants
  2. Changez votre mot de passe
  3. Complétez votre profil transporteur
  4. Consultez vos demandes de réservation
`; await this.send({ to: email, from: EMAIL_SENDERS.CARRIERS, subject: '🚢 Votre compte transporteur Xpeditis a été créé', html, }); this.logger.log(`Carrier account creation email sent to ${email}`); } /** * Send carrier password reset email with temporary password */ async sendCarrierPasswordReset( email: string, carrierName: string, temporaryPassword: string ): Promise { const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const loginUrl = `${baseUrl}/carrier/login`; const html = `

🔑 Réinitialisation de mot de passe

Votre mot de passe a été réinitialisé

Bonjour ${carrierName},

Vous avez demandé la réinitialisation de votre mot de passe Xpeditis.

Votre nouveau mot de passe temporaire :

${temporaryPassword}

⚠️ Sécurité :

  • Ce mot de passe est temporaire et doit être changé immédiatement
  • Ne partagez jamais vos identifiants avec qui que ce soit
  • Si vous n'avez pas demandé cette réinitialisation, contactez-nous immédiatement

Si vous rencontrez des difficultés, n'hésitez pas à contacter notre équipe support.

`; await this.send({ to: email, from: EMAIL_SENDERS.SECURITY, subject: '🔑 Réinitialisation de votre mot de passe Xpeditis', html, }); this.logger.log(`Carrier password reset email sent to ${email}`); } /** * Send document access email to carrier after booking acceptance */ 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 frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; // Password section HTML - only show if password is set const passwordSection = data.documentPassword ? `

🔐 Mot de passe d'accès aux documents

Pour accéder aux documents, vous aurez besoin du mot de passe suivant :

${data.documentPassword}

⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.

` : ''; const html = `

Documents disponibles

Votre reservation a ete acceptee

${data.bookingNumber ? `

N° ${data.bookingNumber}

` : ''}

Bonjour ${data.carrierName},

Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.

${data.origin} ${data.destination}
Volume ${data.volumeCBM} CBM
Poids ${data.weightKG} kg
${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}
${passwordSection} Acceder aux documents

Ce lien est permanent. Vous pouvez y acceder a tout moment.

`; await this.send({ to: carrierEmail, from: EMAIL_SENDERS.BOOKINGS, subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`, html, }); this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`); } /** * Send notification to carrier when new documents are added */ async sendNewDocumentsNotification( carrierEmail: string, data: { carrierName: string; bookingId: string; origin: string; destination: string; newDocumentsCount: number; totalDocumentsCount: number; confirmationToken: string; } ): Promise { const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000'); const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`; const html = `

Nouveaux documents ajoutes

Bonjour ${data.carrierName},

De nouveaux documents ont ete ajoutes a votre reservation.

${data.origin} ${data.destination}

+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}

Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}

Voir les documents
`; await this.send({ to: carrierEmail, from: EMAIL_SENDERS.BOOKINGS, subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`, html, }); this.logger.log( `New documents notification sent to ${carrierEmail} for booking ${data.bookingId}` ); } }