import * as plugins from '../../plugins.js'; import * as paths from '../../paths.js'; import { Email } from '../core/classes.email.js'; import { EmailSignJob } from './classes.emailsignjob.js'; import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; import type { SmtpClient } from './smtpclient/smtp-client.js'; import type { ISmtpSendResult } from './smtpclient/interfaces.js'; // Configuration options for email sending export interface IEmailSendOptions { maxRetries?: number; retryDelay?: number; // in milliseconds connectionTimeout?: number; // in milliseconds tlsOptions?: plugins.tls.ConnectionOptions; debugMode?: boolean; } // Email delivery status export enum DeliveryStatus { PENDING = 'pending', SENDING = 'sending', DELIVERED = 'delivered', FAILED = 'failed', DEFERRED = 'deferred' // Temporary failure, will retry } // Detailed information about delivery attempts export interface DeliveryInfo { status: DeliveryStatus; attempts: number; error?: Error; lastAttempt?: Date; nextAttempt?: Date; mxServer?: string; deliveryTime?: Date; logs: string[]; } export class EmailSendJob { emailServerRef: UnifiedEmailServer; private email: Email; private mxServers: string[] = []; private currentMxIndex = 0; private options: IEmailSendOptions; public deliveryInfo: DeliveryInfo; constructor(emailServerRef: UnifiedEmailServer, emailArg: Email, options: IEmailSendOptions = {}) { this.email = emailArg; this.emailServerRef = emailServerRef; // Set default options this.options = { maxRetries: options.maxRetries || 3, retryDelay: options.retryDelay || 30000, // 30 seconds connectionTimeout: options.connectionTimeout || 60000, // 60 seconds tlsOptions: options.tlsOptions || {}, debugMode: options.debugMode || false }; // Initialize delivery info this.deliveryInfo = { status: DeliveryStatus.PENDING, attempts: 0, logs: [] }; } /** * Send the email to its recipients */ async send(): Promise { try { // Check if the email is valid before attempting to send this.validateEmail(); // Resolve MX records for the recipient domain await this.resolveMxRecords(); // Try to send the email return await this.attemptDelivery(); } catch (error) { this.log(`Critical error in send process: ${error.message}`); this.deliveryInfo.status = DeliveryStatus.FAILED; this.deliveryInfo.error = error; // Save failed email for potential future retry or analysis await this.saveFailed(); return DeliveryStatus.FAILED; } } /** * Validate the email before sending */ private validateEmail(): void { if (!this.email.to || this.email.to.length === 0) { throw new Error('No recipients specified'); } if (!this.email.from) { throw new Error('No sender specified'); } const fromDomain = this.email.getFromDomain(); if (!fromDomain) { throw new Error('Invalid sender domain'); } } /** * Resolve MX records for the recipient domain */ private async resolveMxRecords(): Promise { const domain = this.email.getPrimaryRecipient()?.split('@')[1]; if (!domain) { throw new Error('Invalid recipient domain'); } this.log(`Resolving MX records for domain: ${domain}`); try { const addresses = await this.resolveMx(domain); // Sort by priority (lowest number = highest priority) addresses.sort((a, b) => a.priority - b.priority); this.mxServers = addresses.map(mx => mx.exchange); this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`); if (this.mxServers.length === 0) { throw new Error(`No MX records found for domain: ${domain}`); } } catch (error) { this.log(`Failed to resolve MX records: ${error.message}`); throw new Error(`MX lookup failed for ${domain}: ${error.message}`); } } /** * Attempt to deliver the email with retries */ private async attemptDelivery(): Promise { while (this.deliveryInfo.attempts < this.options.maxRetries) { this.deliveryInfo.attempts++; this.deliveryInfo.lastAttempt = new Date(); this.deliveryInfo.status = DeliveryStatus.SENDING; try { this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`); // Try each MX server in order of priority while (this.currentMxIndex < this.mxServers.length) { const currentMx = this.mxServers[this.currentMxIndex]; this.deliveryInfo.mxServer = currentMx; try { this.log(`Attempting delivery to MX server: ${currentMx}`); await this.connectAndSend(currentMx); // If we get here, email was sent successfully this.deliveryInfo.status = DeliveryStatus.DELIVERED; this.deliveryInfo.deliveryTime = new Date(); this.log(`Email delivered successfully to ${currentMx}`); // Record delivery for sender reputation monitoring this.recordDeliveryEvent('delivered'); // Save successful email record await this.saveSuccess(); return DeliveryStatus.DELIVERED; } catch (error) { this.log(`Failed to deliver to ${currentMx}: ${error.message}`); this.currentMxIndex++; // If this MX server failed, try the next one if (this.currentMxIndex >= this.mxServers.length) { throw error; // No more MX servers to try } } } throw new Error('All MX servers failed'); } catch (error) { this.deliveryInfo.error = error; // Check if this is a permanent failure if (this.isPermanentFailure(error)) { this.log('Permanent failure detected, not retrying'); this.deliveryInfo.status = DeliveryStatus.FAILED; // Record permanent failure for bounce management this.recordDeliveryEvent('bounced', true); await this.saveFailed(); return DeliveryStatus.FAILED; } // This is a temporary failure if (this.deliveryInfo.attempts < this.options.maxRetries) { this.log(`Temporary failure, will retry in ${this.options.retryDelay}ms`); this.deliveryInfo.status = DeliveryStatus.DEFERRED; this.deliveryInfo.nextAttempt = new Date(Date.now() + this.options.retryDelay); // Record temporary failure for monitoring this.recordDeliveryEvent('deferred'); // Reset MX server index for next retry this.currentMxIndex = 0; // Wait before retrying await this.delay(this.options.retryDelay); } } } // If we get here, all retries failed this.deliveryInfo.status = DeliveryStatus.FAILED; await this.saveFailed(); return DeliveryStatus.FAILED; } /** * Connect to a specific MX server and send the email using SmtpClient */ private async connectAndSend(mxServer: string): Promise { this.log(`Connecting to ${mxServer}:25`); try { // Check if IP warmup is enabled and get an IP to use let localAddress: string | undefined = undefined; try { const fromDomain = this.email.getFromDomain(); const bestIP = this.emailServerRef.getBestIPForSending({ from: this.email.from, to: this.email.getAllRecipients(), domain: fromDomain, isTransactional: this.email.priority === 'high' }); if (bestIP) { this.log(`Using warmed-up IP ${bestIP} for sending`); localAddress = bestIP; // Record the send for warm-up tracking this.emailServerRef.recordIPSend(bestIP); } } catch (error) { this.log(`Error selecting IP address: ${error.message}`); } // Get SMTP client from UnifiedEmailServer const smtpClient = this.emailServerRef.getSmtpClient(mxServer, 25); // Sign the email with DKIM if available let signedEmail = this.email; try { const fromDomain = this.email.getFromDomain(); if (fromDomain && this.emailServerRef.hasDkimKey(fromDomain)) { // Convert email to RFC822 format for signing const emailMessage = this.email.toRFC822String(); // Create sign job with proper options const emailSignJob = new EmailSignJob(this.emailServerRef, { domain: fromDomain, selector: 'default', // Using default selector headers: {}, // Headers will be extracted from emailMessage body: emailMessage }); // Get the DKIM signature header const signatureHeader = await emailSignJob.getSignatureHeader(emailMessage); // Add the signature to the email if (signatureHeader) { // For now, we'll use the email as-is since SmtpClient will handle DKIM this.log(`Email ready for DKIM signing for domain: ${fromDomain}`); } } } catch (error) { this.log(`Failed to prepare DKIM: ${error.message}`); } // Send the email using SmtpClient const result: ISmtpSendResult = await smtpClient.sendMail(signedEmail); if (result.success) { this.log(`Email sent successfully: ${result.response}`); // Record the send for reputation monitoring this.recordDeliveryEvent('delivered'); } else { throw new Error(result.error?.message || 'Failed to send email'); } } catch (error) { this.log(`Failed to send email via ${mxServer}: ${error.message}`); throw error; } } /** * Record delivery event for monitoring */ private recordDeliveryEvent( eventType: 'delivered' | 'bounced' | 'deferred', isHardBounce: boolean = false ): void { try { const domain = this.email.getFromDomain(); if (domain) { if (eventType === 'delivered') { this.emailServerRef.recordDelivery(domain); } else if (eventType === 'bounced') { // Get the receiving domain for bounce recording let receivingDomain = null; const primaryRecipient = this.email.getPrimaryRecipient(); if (primaryRecipient) { receivingDomain = primaryRecipient.split('@')[1]; } if (receivingDomain) { this.emailServerRef.recordBounce( domain, receivingDomain, isHardBounce ? 'hard' : 'soft', this.deliveryInfo.error?.message || 'Unknown error' ); } } } } catch (error) { this.log(`Failed to record delivery event: ${error.message}`); } } /** * Check if an error represents a permanent failure */ private isPermanentFailure(error: Error): boolean { const permanentFailurePatterns = [ 'User unknown', 'No such user', 'Mailbox not found', 'Invalid recipient', 'Account disabled', 'Account suspended', 'Domain not found', 'No such domain', 'Invalid domain', 'Relay access denied', 'Access denied', 'Blacklisted', 'Blocked', '550', // Permanent failure SMTP code '551', '552', '553', '554' ]; const errorMessage = error.message.toLowerCase(); return permanentFailurePatterns.some(pattern => errorMessage.includes(pattern.toLowerCase()) ); } /** * Resolve MX records for a domain */ private resolveMx(domain: string): Promise { return new Promise((resolve, reject) => { plugins.dns.resolveMx(domain, (err, addresses) => { if (err) { reject(err); } else { resolve(addresses || []); } }); }); } /** * Log a message with timestamp */ private log(message: string): void { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] ${message}`; this.deliveryInfo.logs.push(logEntry); if (this.options.debugMode) { console.log(`[EmailSendJob] ${logEntry}`); } } /** * Save successful email to storage */ private async saveSuccess(): Promise { try { // Use the existing email storage path const emailContent = this.email.toRFC822String(); const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_success.eml`; const filePath = plugins.path.join(paths.sentEmailsDir, fileName); await plugins.smartfile.fs.ensureDir(paths.sentEmailsDir); await plugins.smartfile.memory.toFs(emailContent, filePath); // Also save delivery info const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_info.json`; const infoPath = plugins.path.join(paths.sentEmailsDir, infoFileName); await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath); this.log(`Email saved to ${fileName}`); } catch (error) { this.log(`Failed to save email: ${error.message}`); } } /** * Save failed email to storage */ private async saveFailed(): Promise { try { // Use the existing email storage path const emailContent = this.email.toRFC822String(); const fileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_failed.eml`; const filePath = plugins.path.join(paths.failedEmailsDir, fileName); await plugins.smartfile.fs.ensureDir(paths.failedEmailsDir); await plugins.smartfile.memory.toFs(emailContent, filePath); // Also save delivery info with error details const infoFileName = `${Date.now()}_${this.email.from}_to_${this.email.to[0]}_error.json`; const infoPath = plugins.path.join(paths.failedEmailsDir, infoFileName); await plugins.smartfile.memory.toFs(JSON.stringify(this.deliveryInfo, null, 2), infoPath); this.log(`Failed email saved to ${fileName}`); } catch (error) { this.log(`Failed to save failed email: ${error.message}`); } } /** * Delay for specified milliseconds */ private delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } }