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
728 lines
27 KiB
TypeScript
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(/&/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<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}`
|
|
);
|
|
}
|
|
}
|