429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
/**
|
|
* Email Adapter
|
|
*
|
|
* Implements EmailPort using nodemailer
|
|
*/
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import * as nodemailer from 'nodemailer';
|
|
import { EmailPort, EmailOptions } from '@domain/ports/out/email.port';
|
|
import { EmailTemplates } from './templates/email-templates';
|
|
|
|
@Injectable()
|
|
export class EmailAdapter implements EmailPort {
|
|
private readonly logger = new Logger(EmailAdapter.name);
|
|
private transporter: nodemailer.Transporter;
|
|
|
|
constructor(
|
|
private readonly configService: ConfigService,
|
|
private readonly emailTemplates: EmailTemplates
|
|
) {
|
|
this.initializeTransporter();
|
|
}
|
|
|
|
private initializeTransporter(): void {
|
|
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
|
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);
|
|
|
|
// 🔧 FIX: Contournement DNS pour Mailtrap
|
|
// Utilise automatiquement l'IP directe quand 'mailtrap.io' est détecté
|
|
// Cela évite les timeouts DNS (queryA ETIMEOUT) sur certains réseaux
|
|
const useDirectIP = host.includes('mailtrap.io');
|
|
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
|
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS
|
|
|
|
this.transporter = nodemailer.createTransport({
|
|
host: actualHost,
|
|
port,
|
|
secure,
|
|
auth: {
|
|
user,
|
|
pass,
|
|
},
|
|
// Configuration TLS avec servername pour IP directe
|
|
tls: {
|
|
rejectUnauthorized: false,
|
|
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe
|
|
},
|
|
// Timeouts optimisés
|
|
connectionTimeout: 10000, // 10s
|
|
greetingTimeout: 10000, // 10s
|
|
socketTimeout: 30000, // 30s
|
|
dnsTimeout: 10000, // 10s
|
|
});
|
|
|
|
this.logger.log(
|
|
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` +
|
|
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
|
|
);
|
|
}
|
|
|
|
async send(options: EmailOptions): Promise<void> {
|
|
try {
|
|
const from = this.configService.get<string>('SMTP_FROM', 'noreply@xpeditis.com');
|
|
|
|
await this.transporter.sendMail({
|
|
from,
|
|
to: options.to,
|
|
cc: options.cc,
|
|
bcc: options.bcc,
|
|
replyTo: options.replyTo,
|
|
subject: options.subject,
|
|
html: options.html,
|
|
text: options.text,
|
|
attachments: options.attachments,
|
|
});
|
|
|
|
this.logger.log(`Email sent to ${options.to}: ${options.subject}`);
|
|
} 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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;
|
|
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;
|
|
}
|
|
): Promise<void> {
|
|
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
|
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`;
|
|
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`;
|
|
|
|
const html = await this.emailTemplates.renderCsvBookingRequest({
|
|
...bookingData,
|
|
acceptUrl,
|
|
rejectUrl,
|
|
});
|
|
|
|
await this.send({
|
|
to: carrierEmail,
|
|
subject: `Nouvelle demande de réservation - ${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,
|
|
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,
|
|
subject: '🔑 Réinitialisation de votre mot de passe Xpeditis',
|
|
html,
|
|
});
|
|
|
|
this.logger.log(`Carrier password reset email sent to ${email}`);
|
|
}
|
|
}
|