xpeditis2.0/apps/backend/src/infrastructure/email/email.adapter.ts

556 lines
17 KiB
TypeScript

/**
* 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 <head> 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(/<head[\s\S]*?<\/head>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<div[^>]*display:\s*none[^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n• ')
.replace(/<\/(p|div|h[1-6]|tr|table|ul|ol)>/gi, '\n')
.replace(/<\/td>/gi, ' ')
.replace(/<a[^>]*href="(?!mailto:)([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#8594;/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<void> {
const host = this.configService.get<string>('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<string> {
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<number>('SMTP_PORT', 2525);
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
this.transporter = nodemailer.createTransport({
host: actualHost,
port,
secure,
requireTLS: this.configService.get<string>('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<string>('SMTP_FROM', DEFAULT_FROM_ADDRESS);
return `"${SENDER_NAMES[kind]}" <${address}>`;
}
private get appUrl(): string {
return String(this.configService.get<string>('APP_URL', 'http://localhost:3000')).replace(
/\/+$/,
''
);
}
async send(options: EmailOptions): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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 });
}
}