556 lines
17 KiB
TypeScript
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(/&/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<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 });
|
|
}
|
|
}
|