447 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			447 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import * as plugins from '../../plugins.ts'; | ||
|  | import * as paths from '../../paths.ts'; | ||
|  | import { Email } from '../core/classes.email.ts'; | ||
|  | import { EmailSignJob } from './classes.emailsignjob.ts'; | ||
|  | import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.ts'; | ||
|  | import type { SmtpClient } from './smtpclient/smtp-client.ts'; | ||
|  | import type { ISmtpSendResult } from './smtpclient/interfaces.ts'; | ||
|  | 
 | ||
|  | // 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<DeliveryStatus> { | ||
|  |     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<void> { | ||
|  |     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<DeliveryStatus> { | ||
|  |     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<void> { | ||
|  |     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<plugins.dns.MxRecord[]> { | ||
|  |     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<void> { | ||
|  |     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.tson`; | ||
|  |       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<void> { | ||
|  |     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.tson`; | ||
|  |       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<void> { | ||
|  |     return new Promise(resolve => setTimeout(resolve, ms)); | ||
|  |   } | ||
|  | } |