/** * Email Service * * Core email sending service with nodemailer * Supports both test accounts (preview) and production SMTP */ import nodemailer from 'nodemailer'; import logger from '@utils/logger'; interface EmailOptions { to: string | string[]; subject: string; html: string; cc?: string | string[]; bcc?: string | string[]; attachments?: any[]; } export class EmailService { private transporter: nodemailer.Transporter | null = null; private useTestAccount: boolean = false; private testAccountInfo: any = null; /** * Initialize email service * If SMTP credentials are not configured, uses test account for preview */ async initialize(): Promise { const smtpHost = process.env.SMTP_HOST; const smtpUser = process.env.SMTP_USER; const smtpPassword = process.env.SMTP_PASSWORD; // Check if SMTP is configured if (!smtpHost || !smtpUser || !smtpPassword) { logger.warn('⚠️ SMTP not configured - using test account for preview'); await this.initializeTestAccount(); return; } // Production SMTP configuration try { this.transporter = nodemailer.createTransport({ host: smtpHost, port: parseInt(process.env.SMTP_PORT || '587'), secure: process.env.SMTP_SECURE === 'true', auth: { user: smtpUser, pass: smtpPassword }, pool: true, // Use connection pooling maxConnections: 5, maxMessages: 100, rateDelta: 1000, rateLimit: 5 }); // Verify connection await this.transporter.verify(); logger.info('✅ Email service initialized with production SMTP'); this.useTestAccount = false; } catch (error) { logger.error('❌ Failed to initialize production SMTP:', error); logger.warn('⚠️ Falling back to test account'); await this.initializeTestAccount(); } } /** * Initialize test account for preview (no real SMTP needed) */ private async initializeTestAccount(): Promise { try { this.testAccountInfo = await nodemailer.createTestAccount(); this.transporter = nodemailer.createTransport({ host: this.testAccountInfo.smtp.host, port: this.testAccountInfo.smtp.port, secure: this.testAccountInfo.smtp.secure, auth: { user: this.testAccountInfo.user, pass: this.testAccountInfo.pass } }); this.useTestAccount = true; logger.info('✅ Email service initialized with test account (preview mode)'); logger.info(`📧 Test account: ${this.testAccountInfo.user}`); } catch (error) { logger.error('❌ Failed to initialize test account:', error); throw new Error('Email service initialization failed'); } } /** * Send email with retry logic */ async sendEmail(options: EmailOptions): Promise<{ messageId: string; previewUrl?: string }> { if (!this.transporter) { await this.initialize(); } const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to; const fromAddress = process.env.EMAIL_FROM || 'RE Flow '; const mailOptions = { from: fromAddress, to: recipients, cc: options.cc, bcc: options.bcc, subject: options.subject, html: options.html, attachments: options.attachments }; // Retry logic const maxRetries = parseInt(process.env.EMAIL_RETRY_ATTEMPTS || '3'); let lastError: any; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const info = await this.transporter!.sendMail(mailOptions); if (!info || !info.messageId) { throw new Error('Email sent but no messageId returned'); } const result: { messageId: string; previewUrl?: string } = { messageId: info.messageId }; // If using test account, generate preview URL if (this.useTestAccount) { try { const previewUrl = nodemailer.getTestMessageUrl(info); if (previewUrl) { result.previewUrl = previewUrl; // Always log to console for visibility console.log('\n' + '='.repeat(80)); console.log(`📧 EMAIL PREVIEW (${options.subject})`); console.log(`To: ${recipients}`); console.log(`Preview URL: ${previewUrl}`); console.log(`Message ID: ${info.messageId}`); console.log('='.repeat(80) + '\n'); logger.info(`✅ Email sent (TEST MODE) to ${recipients}`); logger.info(`📧 Preview URL: ${previewUrl}`); } else { logger.warn(`⚠️ Email sent but preview URL not available. Message ID: ${info.messageId}`); logger.warn(`💡 This can happen if the email service is rate-limited or the message hasn't been processed yet.`); } } catch (previewError: any) { logger.error(`❌ Failed to generate preview URL:`, previewError); logger.warn(`⚠️ Email was sent successfully (Message ID: ${info.messageId}) but preview URL generation failed.`); logger.warn(`💡 You can try sending the email again to get a new preview URL.`); // Don't throw - email was sent successfully, just preview URL failed } } else { logger.info(`✅ Email sent to ${recipients}: ${options.subject}`); } return result; } catch (error) { lastError = error; logger.error(`❌ Email send attempt ${attempt}/${maxRetries} failed:`, error); if (attempt < maxRetries) { const delay = parseInt(process.env.EMAIL_RETRY_DELAY || '5000') * attempt; logger.info(`⏳ Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } // All retries failed logger.error(`❌ Failed to send email after ${maxRetries} attempts:`, lastError); throw new Error(`Email delivery failed: ${lastError?.message || 'Unknown error'}`); } /** * Send email to multiple recipients (batch) */ async sendBatch(emails: EmailOptions[]): Promise { logger.info(`📧 Sending batch of ${emails.length} emails`); const batchSize = parseInt(process.env.EMAIL_BATCH_SIZE || '10'); for (let i = 0; i < emails.length; i += batchSize) { const batch = emails.slice(i, i + batchSize); await Promise.allSettled( batch.map(email => this.sendEmail(email)) ); // Small delay between batches to avoid rate limiting if (i + batchSize < emails.length) { await new Promise(resolve => setTimeout(resolve, 1000)); } } logger.info(`✅ Batch email sending complete`); } /** * Check if email service is in test mode */ isTestMode(): boolean { return this.useTestAccount; } /** * Get test account info (for preview URLs) */ getTestAccountInfo(): any { return this.testAccountInfo; } /** * Close transporter (cleanup) */ async close(): Promise { if (this.transporter) { this.transporter.close(); logger.info('📧 Email service closed'); } } } // Singleton instance export const emailService = new EmailService(); // Initialize on import (will use test account if SMTP not configured) emailService.initialize().catch(error => { logger.error('Failed to initialize email service:', error); });