import nodemailer from 'nodemailer'; import handlebars from 'handlebars'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import type { SMTPConfig } from '~/utils/types'; // ES modules compatibility for __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export interface EmailData { to: string; subject: string; html: string; text?: string; } export interface WelcomeEmailData { firstName: string; lastName: string; verificationLink: string; memberId: string; registrationDate?: string; logoUrl?: string; email?: string; } export interface VerificationEmailData { firstName: string; verificationLink: string; logoUrl?: string; } export interface PasswordResetEmailData { firstName: string; resetLink: string; logoUrl?: string; } export interface DuesReminderEmailData { firstName: string; lastName: string; amount: number; dueDate: string; iban?: string; accountHolder?: string; logoUrl?: string; } export class EmailService { private transporter: nodemailer.Transporter | null = null; private templates: Map = new Map(); constructor(private config: SMTPConfig) { this.initializeTransporter(); this.preloadTemplates(); } /** * Initialize the nodemailer transporter */ private initializeTransporter(): void { if (!this.config.host || !this.config.port) { console.warn('[EmailService] SMTP configuration incomplete, emails will not be sent'); return; } try { // Determine security settings based on port let useSecure = this.config.secure; let requireTLS = false; // Auto-configure based on standard ports if not explicitly set if (this.config.port === 587) { // Port 587 typically uses STARTTLS useSecure = false; requireTLS = true; } else if (this.config.port === 465) { // Port 465 typically uses SSL/TLS useSecure = true; requireTLS = false; } else if (this.config.port === 25) { // Port 25 typically unencrypted (not recommended) useSecure = false; requireTLS = false; } // Build transporter options const transporterOptions: any = { host: this.config.host, port: this.config.port, secure: useSecure, // Increased timeout settings to handle slow servers connectionTimeout: 60000, // 60 seconds greetingTimeout: 60000, socketTimeout: 60000, // Pool configuration for better connection management pool: false, maxConnections: 1, // Debug logging (can be enabled for troubleshooting) logger: false, debug: false }; // Add requireTLS if needed (for STARTTLS) if (requireTLS && !useSecure) { transporterOptions.requireTLS = true; transporterOptions.opportunisticTLS = true; } // Configure TLS options transporterOptions.tls = { rejectUnauthorized: false, // Accept self-signed certificates // Allow various TLS versions for compatibility minVersion: 'TLSv1', // Don't specify ciphers to allow auto-negotiation }; // Add authentication only if credentials are provided if (this.config.username && this.config.password) { transporterOptions.auth = { user: this.config.username, pass: this.config.password, // Try different auth methods for compatibility type: 'login' // Can be 'oauth2', 'login', or omitted for auto-detection }; // For some servers, disabling STARTTLS can help if (this.config.port === 587) { transporterOptions.ignoreTLS = false; transporterOptions.secure = false; transporterOptions.requireTLS = true; } } this.transporter = nodemailer.createTransport(transporterOptions); console.log('[EmailService] ✅ SMTP transporter initialized with options:', { host: this.config.host, port: this.config.port, secure: transporterOptions.secure, requireTLS: transporterOptions.requireTLS, auth: !!transporterOptions.auth }); } catch (error) { console.error('[EmailService] ❌ Failed to initialize SMTP transporter:', error); } } /** * Preload and compile email templates */ private preloadTemplates(): void { const templateNames = ['welcome', 'verification', 'password-reset', 'dues-reminder', 'test']; templateNames.forEach(templateName => { try { // Try multiple possible paths for template files const possiblePaths = [ join(process.cwd(), 'server/templates', `${templateName}.hbs`), join(process.cwd(), '.output/server/templates', `${templateName}.hbs`), join(__dirname, '..', 'templates', `${templateName}.hbs`), join(__dirname, '..', '..', 'server/templates', `${templateName}.hbs`), // Docker container path `/app/server/templates/${templateName}.hbs` ]; let templateContent: string | null = null; let usedPath = ''; for (const templatePath of possiblePaths) { try { templateContent = readFileSync(templatePath, 'utf-8'); usedPath = templatePath; break; } catch (pathError) { // Continue to next path } } if (!templateContent) { console.warn(`[EmailService] ⚠️ Template '${templateName}' not found in any of the expected paths`); return; } const compiledTemplate = handlebars.compile(templateContent); this.templates.set(templateName, compiledTemplate); console.log(`[EmailService] ✅ Template '${templateName}' loaded from: ${usedPath}`); } catch (error) { console.warn(`[EmailService] ⚠️ Template '${templateName}' failed to compile:`, error); } }); } /** * Get a compiled template by name */ private getTemplate(templateName: string): handlebars.TemplateDelegate | null { return this.templates.get(templateName) || null; } /** * Send a generic email */ private async sendEmail(emailData: EmailData): Promise { if (!this.transporter) { throw new Error('SMTP transporter not initialized'); } const mailOptions = { from: `${this.config.fromName} <${this.config.fromAddress}>`, to: emailData.to, subject: emailData.subject, html: emailData.html, text: emailData.text || undefined, // Explicitly set content type to ensure HTML rendering headers: { 'Content-Type': 'text/html; charset=utf-8', 'MIME-Version': '1.0' } }; try { const info = await this.transporter.sendMail(mailOptions); console.log(`[EmailService] ✅ Email sent successfully to ${emailData.to}:`, info.messageId); } catch (error) { console.error(`[EmailService] ❌ Failed to send email to ${emailData.to}:`, error); throw error; } } /** * Send welcome/verification email to new members */ async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise { const template = this.getTemplate('welcome'); if (!template) { console.error('[EmailService] ❌ Welcome email template not found! Available templates:', Array.from(this.templates.keys())); throw new Error('Welcome email template not found'); } const config = useRuntimeConfig(); const templateData = { ...data, logoUrl: data.logoUrl || 'https://portal.monacousa.org/MONACOUSA-Flags_376x376.png', baseUrl: config.public.domain || 'https://portal.monacousa.org', email: data.email || to }; console.log('[EmailService] Template data:', templateData); const html = template(templateData); console.log('[EmailService] Generated HTML length:', html.length); console.log('[EmailService] HTML preview (first 200 chars):', html.substring(0, 200)); await this.sendEmail({ to, subject: 'Welcome to MonacoUSA - Please Verify Your Email', html }); console.log(`[EmailService] ✅ Welcome email sent to ${to}`); } /** * Send email verification email */ async sendVerificationEmail(to: string, data: VerificationEmailData): Promise { const template = this.getTemplate('verification'); if (!template) { throw new Error('Verification email template not found'); } const templateData = { ...data, logoUrl: data.logoUrl || 'https://portal.monacousa.org/MONACOUSA-Flags_376x376.png' }; const html = template(templateData); await this.sendEmail({ to, subject: 'Verify Your Email - MonacoUSA Portal', html }); console.log(`[EmailService] ✅ Verification email sent to ${to}`); } /** * Send password reset email */ async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise { const template = this.getTemplate('password-reset'); if (!template) { throw new Error('Password reset email template not found'); } const templateData = { ...data, logoUrl: data.logoUrl || 'https://portal.monacousa.org/MONACOUSA-Flags_376x376.png' }; const html = template(templateData); await this.sendEmail({ to, subject: 'Reset Your Password - MonacoUSA Portal', html }); console.log(`[EmailService] ✅ Password reset email sent to ${to}`); } /** * Send membership dues reminder email */ async sendDuesReminderEmail(to: string, data: DuesReminderEmailData): Promise { const template = this.getTemplate('dues-reminder'); if (!template) { throw new Error('Dues reminder email template not found'); } const templateData = { ...data, logoUrl: data.logoUrl || 'https://portal.monacousa.org/MONACOUSA-Flags_376x376.png' }; const html = template(templateData); await this.sendEmail({ to, subject: `MonacoUSA Membership Dues Reminder - €${data.amount} Due`, html }); console.log(`[EmailService] ✅ Dues reminder email sent to ${to}`); } /** * Send test email to verify SMTP configuration */ async sendTestEmail(to: string): Promise { const template = this.getTemplate('test'); const templateData = { testTime: new Date().toISOString(), logoUrl: 'https://portal.monacousa.org/MONACOUSA-Flags_376x376.png', smtpHost: this.config.host, fromAddress: this.config.fromAddress }; let html: string; if (template) { html = template(templateData); } else { // Fallback HTML if template is not available html = ` SMTP Test Email
MonacoUSA

SMTP Test Email

This is a test email from the MonacoUSA Portal email system.

Test Details:

  • Sent at: ${templateData.testTime}
  • SMTP Host: ${templateData.smtpHost}
  • From Address: ${templateData.fromAddress}

If you received this email, your SMTP configuration is working correctly!


MonacoUSA Portal Email System

`; } await this.sendEmail({ to, subject: 'SMTP Configuration Test - MonacoUSA Portal', html, text: `This is a test email from MonacoUSA Portal. Sent at: ${templateData.testTime}` }); console.log(`[EmailService] ✅ Test email sent to ${to}`); } /** * Verify SMTP connection */ async verifyConnection(): Promise { if (!this.transporter) { throw new Error('SMTP transporter not initialized'); } try { await this.transporter.verify(); console.log('[EmailService] ✅ SMTP connection verified'); return true; } catch (error) { console.error('[EmailService] ❌ SMTP connection verification failed:', error); return false; } } /** * Update SMTP configuration and reinitialize transporter */ updateConfig(newConfig: SMTPConfig): void { this.config = newConfig; this.initializeTransporter(); } /** * Close the transporter connection */ close(): void { if (this.transporter) { this.transporter.close(); this.transporter = null; console.log('[EmailService] SMTP connection closed'); } } } // Singleton instance for reuse across the application let emailServiceInstance: EmailService | null = null; /** * Get or create EmailService instance with current SMTP config */ export async function getEmailService(): Promise { const { getSMTPConfig } = await import('./admin-config'); const config = getSMTPConfig(); if (!emailServiceInstance) { emailServiceInstance = new EmailService(config); } else { // Update config in case it changed emailServiceInstance.updateConfig(config); } return emailServiceInstance; } /** * Create a new EmailService instance with custom config */ export function createEmailService(config: SMTPConfig): EmailService { return new EmailService(config); }