xpeditis2.0/apps/backend/src/infrastructure/email/email.adapter.ts
David ec0173483a
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
fix language
2026-04-21 18:04:02 +02:00

728 lines
27 KiB
TypeScript

/**
* 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é" <security@xpeditis.com>',
BOOKINGS: '"Xpeditis Bookings" <bookings@xpeditis.com>',
TEAM: '"Équipe Xpeditis" <team@xpeditis.com>',
CARRIERS: '"Xpeditis Transporteurs" <carriers@xpeditis.com>',
NOREPLY: '"Xpeditis" <noreply@xpeditis.com>',
} 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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<\/div>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/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(/\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,
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<void> {
try {
const from =
options.from ?? this.configService.get<string>('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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const loginUrl = `${baseUrl}/carrier/login`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9f9f9; }
.credentials { background: white; padding: 20px; margin: 20px 0; border-left: 4px solid #0066cc; }
.button { display: inline-block; padding: 12px 30px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚢 Bienvenue sur Xpeditis</h1>
</div>
<div class="content">
<h2>Votre compte transporteur a été créé</h2>
<p>Bonjour <strong>${carrierName}</strong>,</p>
<p>Un compte transporteur a été automatiquement créé pour vous sur la plateforme Xpeditis.</p>
<div class="credentials">
<h3>Vos identifiants de connexion :</h3>
<p><strong>Email :</strong> ${email}</p>
<p><strong>Mot de passe temporaire :</strong> <code style="background: #f0f0f0; padding: 5px 10px; border-radius: 3px;">${temporaryPassword}</code></p>
</div>
<p><strong>⚠️ Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire dès votre première connexion.</p>
<div style="text-align: center;">
<a href="${loginUrl}" class="button">Se connecter maintenant</a>
</div>
<h3>Prochaines étapes :</h3>
<ol>
<li>Connectez-vous avec vos identifiants</li>
<li>Changez votre mot de passe</li>
<li>Complétez votre profil transporteur</li>
<li>Consultez vos demandes de réservation</li>
</ol>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>
`;
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<void> {
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const loginUrl = `${baseUrl}/carrier/login`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
.content { padding: 30px; background: #f9f9f9; }
.credentials { background: white; padding: 20px; margin: 20px 0; border-left: 4px solid #ff9900; }
.button { display: inline-block; padding: 12px 30px; background: #0066cc; color: white; text-decoration: none; border-radius: 5px; margin: 20px 0; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.warning { background: #fff3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 5px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔑 Réinitialisation de mot de passe</h1>
</div>
<div class="content">
<h2>Votre mot de passe a été réinitialisé</h2>
<p>Bonjour <strong>${carrierName}</strong>,</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe Xpeditis.</p>
<div class="credentials">
<h3>Votre nouveau mot de passe temporaire :</h3>
<p><code style="background: #f0f0f0; padding: 10px 15px; border-radius: 3px; font-size: 16px; display: inline-block;">${temporaryPassword}</code></p>
</div>
<div class="warning">
<p><strong>⚠️ Sécurité :</strong></p>
<ul style="margin: 10px 0;">
<li>Ce mot de passe est temporaire et doit être changé immédiatement</li>
<li>Ne partagez jamais vos identifiants avec qui que ce soit</li>
<li>Si vous n'avez pas demandé cette réinitialisation, contactez-nous immédiatement</li>
</ul>
</div>
<div style="text-align: center;">
<a href="${loginUrl}" class="button">Se connecter et changer le mot de passe</a>
</div>
<p style="margin-top: 30px;">Si vous rencontrez des difficultés, n'hésitez pas à contacter notre équipe support.</p>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
<p>Cet email a été envoyé automatiquement, merci de ne pas y répondre.</p>
</div>
</div>
</body>
</html>
`;
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<void> {
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
? `
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
</div>
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;">⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
</div>
`
: '';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #0284c7; margin: 0 10px; }
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
.summary-row:last-child { border-bottom: none; }
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Documents disponibles</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
<div class="route">
${data.origin} <span class="route-arrow">→</span> ${data.destination}
</div>
<div class="summary">
<div class="summary-row">
<span style="color: #64748b;">Volume</span>
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
</div>
<div class="summary-row">
<span style="color: #64748b;">Poids</span>
<span style="font-weight: 500;">${data.weightKG} kg</span>
</div>
</div>
<div style="text-align: center;">
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
</div>
${passwordSection}
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
</div>
<div class="footer">
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
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<void> {
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
.header h1 { margin: 0; font-size: 24px; }
.content { padding: 30px; }
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
.route-arrow { color: #f59e0b; margin: 0 10px; }
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nouveaux documents ajoutes</h1>
</div>
<div class="content">
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
<div class="route">
${data.origin} <span class="route-arrow">→</span> ${data.destination}
</div>
<div class="highlight">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
</p>
<p style="margin: 5px 0 0 0; color: #a16207;">
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
</p>
</div>
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
</div>
<div class="footer">
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</div>
</div>
</body>
</html>
`;
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}`
);
}
}