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));
 | |
|   }
 | |
| } |