254 lines
8.0 KiB
TypeScript
254 lines
8.0 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|
|
|
|
// If using test account, check if SMTP credentials are now available and re-initialize
|
|
if (this.useTestAccount) {
|
|
const smtpHost = process.env.SMTP_HOST;
|
|
const smtpUser = process.env.SMTP_USER;
|
|
const smtpPassword = process.env.SMTP_PASSWORD;
|
|
|
|
if (smtpHost && smtpUser && smtpPassword) {
|
|
logger.info('📧 SMTP credentials detected - re-initializing email service with production SMTP');
|
|
await this.initialize();
|
|
}
|
|
}
|
|
|
|
const recipients = Array.isArray(options.to) ? options.to.join(', ') : options.to;
|
|
const fromAddress = process.env.EMAIL_FROM || 'RE Flow <noreply@royalenfield.com>';
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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)
|
|
// Note: If secrets are loaded later, the service will re-initialize automatically
|
|
// when sendEmail is called (if SMTP credentials become available)
|
|
emailService.initialize().catch(error => {
|
|
logger.error('Failed to initialize email service:', error);
|
|
});
|
|
|