1422 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			1422 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import * as plugins from '../../plugins.ts'; | ||
|  | import { logger } from '../../logger.ts'; | ||
|  | import {  | ||
|  |   SecurityLogger,  | ||
|  |   SecurityLogLevel,  | ||
|  |   SecurityEventType  | ||
|  | } from '../../security/index.ts'; | ||
|  | 
 | ||
|  | import {  | ||
|  |   MtaConnectionError,  | ||
|  |   MtaAuthenticationError, | ||
|  |   MtaDeliveryError, | ||
|  |   MtaConfigurationError, | ||
|  |   MtaTimeoutError, | ||
|  |   MtaProtocolError | ||
|  | } from '../../errors/index.ts'; | ||
|  | 
 | ||
|  | import { Email } from '../core/classes.email.ts'; | ||
|  | import type { EmailProcessingMode } from './interfaces.ts'; | ||
|  | 
 | ||
|  | // Custom error type extension
 | ||
|  | interface NodeNetworkError extends Error { | ||
|  |   code?: string; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * SMTP client connection options | ||
|  |  */ | ||
|  | export type ISmtpClientOptions = { | ||
|  |   /** | ||
|  |    * Hostname of the SMTP server | ||
|  |    */ | ||
|  |   host: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * Port to connect to | ||
|  |    */ | ||
|  |   port: number; | ||
|  |    | ||
|  |   /** | ||
|  |    * Whether to use TLS for the connection | ||
|  |    */ | ||
|  |   secure?: boolean; | ||
|  |    | ||
|  |   /** | ||
|  |    * Connection timeout in milliseconds | ||
|  |    */ | ||
|  |   connectionTimeout?: number; | ||
|  |    | ||
|  |   /** | ||
|  |    * Socket timeout in milliseconds | ||
|  |    */ | ||
|  |   socketTimeout?: number; | ||
|  |    | ||
|  |   /** | ||
|  |    * Command timeout in milliseconds | ||
|  |    */ | ||
|  |   commandTimeout?: number; | ||
|  |    | ||
|  |   /** | ||
|  |    * TLS options | ||
|  |    */ | ||
|  |   tls?: { | ||
|  |     /** | ||
|  |      * Whether to verify certificates | ||
|  |      */ | ||
|  |     rejectUnauthorized?: boolean; | ||
|  |      | ||
|  |     /** | ||
|  |      * Minimum TLS version | ||
|  |      */ | ||
|  |     minVersion?: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * CA certificate path | ||
|  |      */ | ||
|  |     ca?: string; | ||
|  |   }; | ||
|  |    | ||
|  |   /** | ||
|  |    * Authentication options | ||
|  |    */ | ||
|  |   auth?: { | ||
|  |     /** | ||
|  |      * Authentication user | ||
|  |      */ | ||
|  |     user: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * Authentication password | ||
|  |      */ | ||
|  |     pass: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * Authentication method | ||
|  |      */ | ||
|  |     method?: 'PLAIN' | 'LOGIN' | 'OAUTH2'; | ||
|  |   }; | ||
|  |    | ||
|  |   /** | ||
|  |    * Domain name for EHLO | ||
|  |    */ | ||
|  |   domain?: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * DKIM options for signing outgoing emails | ||
|  |    */ | ||
|  |   dkim?: { | ||
|  |     /** | ||
|  |      * Whether to sign emails with DKIM | ||
|  |      */ | ||
|  |     enabled: boolean; | ||
|  |      | ||
|  |     /** | ||
|  |      * Domain name for DKIM | ||
|  |      */ | ||
|  |     domain: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * Selector for DKIM | ||
|  |      */ | ||
|  |     selector: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * Private key for DKIM signing | ||
|  |      */ | ||
|  |     privateKey: string; | ||
|  |      | ||
|  |     /** | ||
|  |      * Headers to sign | ||
|  |      */ | ||
|  |     headers?: string[]; | ||
|  |   }; | ||
|  | }; | ||
|  | 
 | ||
|  | /** | ||
|  |  * SMTP delivery result | ||
|  |  */ | ||
|  | export type ISmtpDeliveryResult = { | ||
|  |   /** | ||
|  |    * Whether the delivery was successful | ||
|  |    */ | ||
|  |   success: boolean; | ||
|  |    | ||
|  |   /** | ||
|  |    * Message ID if successful | ||
|  |    */ | ||
|  |   messageId?: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * Error message if failed | ||
|  |    */ | ||
|  |   error?: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * SMTP response code | ||
|  |    */ | ||
|  |   responseCode?: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * Recipients successfully delivered to | ||
|  |    */ | ||
|  |   acceptedRecipients: string[]; | ||
|  |    | ||
|  |   /** | ||
|  |    * Recipients rejected during delivery | ||
|  |    */ | ||
|  |   rejectedRecipients: string[]; | ||
|  |    | ||
|  |   /** | ||
|  |    * Server response | ||
|  |    */ | ||
|  |   response?: string; | ||
|  |    | ||
|  |   /** | ||
|  |    * Timestamp of the delivery attempt | ||
|  |    */ | ||
|  |   timestamp: number; | ||
|  |    | ||
|  |   /** | ||
|  |    * Whether DKIM signing was applied | ||
|  |    */ | ||
|  |   dkimSigned?: boolean; | ||
|  |    | ||
|  |   /** | ||
|  |    * Whether this was a TLS secured delivery | ||
|  |    */ | ||
|  |   secure?: boolean; | ||
|  |    | ||
|  |   /** | ||
|  |    * Whether authentication was used | ||
|  |    */ | ||
|  |   authenticated?: boolean; | ||
|  | }; | ||
|  | 
 | ||
|  | /** | ||
|  |  * SMTP client for sending emails to remote mail servers | ||
|  |  */ | ||
|  | export class SmtpClient { | ||
|  |   private options: ISmtpClientOptions; | ||
|  |   private connected: boolean = false; | ||
|  |   private socket?: plugins.net.Socket | plugins.tls.TLSSocket; | ||
|  |   private supportedExtensions: Set<string> = new Set(); | ||
|  |    | ||
|  |   /** | ||
|  |    * Create a new SMTP client instance | ||
|  |    * @param options SMTP client connection options | ||
|  |    */ | ||
|  |   constructor(options: ISmtpClientOptions) { | ||
|  |     // Set default options
 | ||
|  |     this.options = { | ||
|  |       ...options, | ||
|  |       connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
 | ||
|  |       socketTimeout: options.socketTimeout || 60000, // 60 seconds
 | ||
|  |       commandTimeout: options.commandTimeout || 30000, // 30 seconds
 | ||
|  |       secure: options.secure || false, | ||
|  |       domain: options.domain || 'localhost', | ||
|  |       tls: { | ||
|  |         rejectUnauthorized: options.tls?.rejectUnauthorized !== false, // Default to true
 | ||
|  |         minVersion: options.tls?.minVersion || 'TLSv1.2' | ||
|  |       } | ||
|  |     }; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Connect to the SMTP server | ||
|  |    */ | ||
|  |   public async connect(): Promise<void> { | ||
|  |     if (this.connected && this.socket) { | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     try { | ||
|  |       logger.log('info', `Connecting to SMTP server ${this.options.host}:${this.options.port}`); | ||
|  |        | ||
|  |       // Create socket
 | ||
|  |       const socket = new plugins.net.Socket(); | ||
|  |        | ||
|  |       // Set timeouts
 | ||
|  |       socket.setTimeout(this.options.socketTimeout); | ||
|  |        | ||
|  |       // Connect to the server
 | ||
|  |       await new Promise<void>((resolve, reject) => { | ||
|  |         // Handle connection events
 | ||
|  |         socket.once('connect', () => { | ||
|  |           logger.log('debug', `Connected to ${this.options.host}:${this.options.port}`); | ||
|  |           resolve(); | ||
|  |         }); | ||
|  |          | ||
|  |         socket.once('timeout', () => { | ||
|  |           reject(MtaConnectionError.timeout( | ||
|  |             this.options.host, | ||
|  |             this.options.port, | ||
|  |             this.options.connectionTimeout | ||
|  |           )); | ||
|  |         }); | ||
|  |          | ||
|  |         socket.once('error', (err: NodeNetworkError) => { | ||
|  |           if (err.code === 'ECONNREFUSED') { | ||
|  |             reject(MtaConnectionError.refused( | ||
|  |               this.options.host, | ||
|  |               this.options.port | ||
|  |             )); | ||
|  |           } else if (err.code === 'ENOTFOUND') { | ||
|  |             reject(MtaConnectionError.dnsError( | ||
|  |               this.options.host, | ||
|  |               err | ||
|  |             )); | ||
|  |           } else { | ||
|  |             reject(new MtaConnectionError( | ||
|  |               `Connection error to ${this.options.host}:${this.options.port}: ${err.message}`, | ||
|  |               { | ||
|  |                 data: { | ||
|  |                   host: this.options.host, | ||
|  |                   port: this.options.port, | ||
|  |                   error: err.message, | ||
|  |                   code: err.code | ||
|  |                 } | ||
|  |               } | ||
|  |             )); | ||
|  |           } | ||
|  |         }); | ||
|  |          | ||
|  |         // Connect to the server
 | ||
|  |         const connectOptions = { | ||
|  |           host: this.options.host, | ||
|  |           port: this.options.port | ||
|  |         }; | ||
|  |          | ||
|  |         // For direct TLS connections
 | ||
|  |         if (this.options.secure) { | ||
|  |           const tlsSocket = plugins.tls.connect({ | ||
|  |             ...connectOptions, | ||
|  |             rejectUnauthorized: this.options.tls.rejectUnauthorized, | ||
|  |             minVersion: this.options.tls.minVersion as any, | ||
|  |             ca: this.options.tls.ca ? [this.options.tls.ca] : undefined | ||
|  |           } as plugins.tls.ConnectionOptions); | ||
|  |            | ||
|  |           tlsSocket.once('secureConnect', () => { | ||
|  |             logger.log('debug', `Secure connection established to ${this.options.host}:${this.options.port}`); | ||
|  |             this.socket = tlsSocket; | ||
|  |             resolve(); | ||
|  |           }); | ||
|  |            | ||
|  |           tlsSocket.once('error', (err: NodeNetworkError) => { | ||
|  |             reject(new MtaConnectionError( | ||
|  |               `TLS connection error to ${this.options.host}:${this.options.port}: ${err.message}`, | ||
|  |               { | ||
|  |                 data: { | ||
|  |                   host: this.options.host, | ||
|  |                   port: this.options.port, | ||
|  |                   error: err.message, | ||
|  |                   code: err.code | ||
|  |                 } | ||
|  |               } | ||
|  |             )); | ||
|  |           }); | ||
|  |            | ||
|  |           tlsSocket.setTimeout(this.options.socketTimeout); | ||
|  |            | ||
|  |           tlsSocket.once('timeout', () => { | ||
|  |             reject(MtaConnectionError.timeout( | ||
|  |               this.options.host, | ||
|  |               this.options.port, | ||
|  |               this.options.connectionTimeout | ||
|  |             )); | ||
|  |           }); | ||
|  |         } else { | ||
|  |           socket.connect(connectOptions); | ||
|  |           this.socket = socket; | ||
|  |         } | ||
|  |       }); | ||
|  |        | ||
|  |       // Wait for server greeting
 | ||
|  |       const greeting = await this.readResponse(); | ||
|  |        | ||
|  |       if (!greeting.startsWith('220')) { | ||
|  |         throw new MtaConnectionError( | ||
|  |           `Unexpected greeting from server: ${greeting}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               host: this.options.host, | ||
|  |               port: this.options.port, | ||
|  |               greeting | ||
|  |             } | ||
|  |           } | ||
|  |         ); | ||
|  |       } | ||
|  |        | ||
|  |       // Send EHLO
 | ||
|  |       await this.sendEhlo(); | ||
|  |        | ||
|  |       // Start TLS if not secure and supported
 | ||
|  |       if (!this.options.secure && this.supportedExtensions.has('STARTTLS')) { | ||
|  |         await this.startTls(); | ||
|  |          | ||
|  |         // Send EHLO again after STARTTLS
 | ||
|  |         await this.sendEhlo(); | ||
|  |       } | ||
|  |        | ||
|  |       // Authenticate if credentials provided
 | ||
|  |       if (this.options.auth) { | ||
|  |         await this.authenticate(); | ||
|  |       } | ||
|  |        | ||
|  |       this.connected = true; | ||
|  |       logger.log('info', `Successfully connected to SMTP server ${this.options.host}:${this.options.port}`); | ||
|  |        | ||
|  |       // Set up error handling for the socket
 | ||
|  |       this.socket.on('error', (err) => { | ||
|  |         logger.log('error', `Socket error: ${err.message}`); | ||
|  |         this.connected = false; | ||
|  |         this.socket = undefined; | ||
|  |       }); | ||
|  |        | ||
|  |       this.socket.on('close', () => { | ||
|  |         logger.log('debug', 'Socket closed'); | ||
|  |         this.connected = false; | ||
|  |         this.socket = undefined; | ||
|  |       }); | ||
|  |        | ||
|  |       this.socket.on('timeout', () => { | ||
|  |         logger.log('error', 'Socket timeout'); | ||
|  |         this.connected = false; | ||
|  |         if (this.socket) { | ||
|  |           this.socket.destroy(); | ||
|  |           this.socket = undefined; | ||
|  |         } | ||
|  |       }); | ||
|  |        | ||
|  |     } catch (error) { | ||
|  |       // Clean up socket if connection failed
 | ||
|  |       if (this.socket) { | ||
|  |         this.socket.destroy(); | ||
|  |         this.socket = undefined; | ||
|  |       } | ||
|  |        | ||
|  |       logger.log('error', `Failed to connect to SMTP server: ${error.message}`); | ||
|  |       throw error; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Send EHLO command to the server | ||
|  |    */ | ||
|  |   private async sendEhlo(): Promise<void> { | ||
|  |     // Clear previous extensions
 | ||
|  |     this.supportedExtensions.clear(); | ||
|  |      | ||
|  |     // Send EHLO - don't allow pipelining for this command
 | ||
|  |     const response = await this.sendCommand(`EHLO ${this.options.domain}`, false); | ||
|  |      | ||
|  |     // Parse supported extensions
 | ||
|  |     const lines = response.split('\r\n'); | ||
|  |     for (let i = 1; i < lines.length; i++) { | ||
|  |       const line = lines[i]; | ||
|  |       if (line.startsWith('250-') || line.startsWith('250 ')) { | ||
|  |         const extension = line.substring(4).split(' ')[0]; | ||
|  |         this.supportedExtensions.add(extension); | ||
|  |       } | ||
|  |     } | ||
|  |      | ||
|  |     // Check if server supports pipelining
 | ||
|  |     this.supportsPipelining = this.supportedExtensions.has('PIPELINING'); | ||
|  |      | ||
|  |     logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`); | ||
|  |     if (this.supportsPipelining) { | ||
|  |       logger.log('info', 'Server supports PIPELINING - will use for improved performance'); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Start TLS negotiation | ||
|  |    */ | ||
|  |   private async startTls(): Promise<void> { | ||
|  |     logger.log('debug', 'Starting TLS negotiation'); | ||
|  |      | ||
|  |     // Send STARTTLS command
 | ||
|  |     const response = await this.sendCommand('STARTTLS'); | ||
|  |      | ||
|  |     if (!response.startsWith('220')) { | ||
|  |       throw new MtaConnectionError( | ||
|  |         `Failed to start TLS: ${response}`, | ||
|  |         { | ||
|  |           data: { | ||
|  |             host: this.options.host, | ||
|  |             port: this.options.port, | ||
|  |             response | ||
|  |           } | ||
|  |         } | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     if (!this.socket) { | ||
|  |       throw new MtaConnectionError( | ||
|  |         'No socket available for TLS upgrade', | ||
|  |         { | ||
|  |           data: { | ||
|  |             host: this.options.host, | ||
|  |             port: this.options.port | ||
|  |           } | ||
|  |         } | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     // Upgrade socket to TLS
 | ||
|  |     const currentSocket = this.socket; | ||
|  |     this.socket = await this.upgradeTls(currentSocket); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Upgrade socket to TLS | ||
|  |    * @param socket Original socket | ||
|  |    */ | ||
|  |   private async upgradeTls(socket: plugins.net.Socket): Promise<plugins.tls.TLSSocket> { | ||
|  |     return new Promise<plugins.tls.TLSSocket>((resolve, reject) => { | ||
|  |       const tlsOptions: plugins.tls.ConnectionOptions = { | ||
|  |         socket, | ||
|  |         servername: this.options.host, | ||
|  |         rejectUnauthorized: this.options.tls.rejectUnauthorized, | ||
|  |         minVersion: this.options.tls.minVersion as any, | ||
|  |         ca: this.options.tls.ca ? [this.options.tls.ca] : undefined | ||
|  |       }; | ||
|  |        | ||
|  |       const tlsSocket = plugins.tls.connect(tlsOptions); | ||
|  |        | ||
|  |       tlsSocket.once('secureConnect', () => { | ||
|  |         logger.log('debug', 'TLS negotiation successful'); | ||
|  |         resolve(tlsSocket); | ||
|  |       }); | ||
|  |        | ||
|  |       tlsSocket.once('error', (err: NodeNetworkError) => { | ||
|  |         reject(new MtaConnectionError( | ||
|  |           `TLS error: ${err.message}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               host: this.options.host, | ||
|  |               port: this.options.port, | ||
|  |               error: err.message, | ||
|  |               code: err.code | ||
|  |             } | ||
|  |           } | ||
|  |         )); | ||
|  |       }); | ||
|  |        | ||
|  |       tlsSocket.setTimeout(this.options.socketTimeout); | ||
|  |        | ||
|  |       tlsSocket.once('timeout', () => { | ||
|  |         reject(MtaTimeoutError.commandTimeout( | ||
|  |           'STARTTLS', | ||
|  |           this.options.host, | ||
|  |           this.options.socketTimeout | ||
|  |         )); | ||
|  |       }); | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Authenticate with the server | ||
|  |    */ | ||
|  |   private async authenticate(): Promise<void> { | ||
|  |     if (!this.options.auth) { | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     const { user, pass, method = 'LOGIN' } = this.options.auth; | ||
|  |      | ||
|  |     logger.log('debug', `Authenticating as ${user} using ${method}`); | ||
|  |      | ||
|  |     try { | ||
|  |       switch (method) { | ||
|  |         case 'PLAIN': | ||
|  |           await this.authPlain(user, pass); | ||
|  |           break; | ||
|  |            | ||
|  |         case 'LOGIN': | ||
|  |           await this.authLogin(user, pass); | ||
|  |           break; | ||
|  |            | ||
|  |         case 'OAUTH2': | ||
|  |           await this.authOAuth2(user, pass); | ||
|  |           break; | ||
|  |            | ||
|  |         default: | ||
|  |           throw new MtaAuthenticationError( | ||
|  |             `Authentication method ${method} not supported by client`, | ||
|  |             { | ||
|  |               data: { | ||
|  |                 method | ||
|  |               } | ||
|  |             } | ||
|  |           ); | ||
|  |       } | ||
|  |        | ||
|  |       logger.log('info', `Successfully authenticated as ${user}`); | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Authentication failed: ${error.message}`); | ||
|  |       throw error; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Authenticate using PLAIN method | ||
|  |    * @param user Username | ||
|  |    * @param pass Password | ||
|  |    */ | ||
|  |   private async authPlain(user: string, pass: string): Promise<void> { | ||
|  |     // PLAIN authentication format: \0username\0password
 | ||
|  |     const authString = Buffer.from(`\0${user}\0${pass}`).toString('base64'); | ||
|  |     const response = await this.sendCommand(`AUTH PLAIN ${authString}`); | ||
|  |      | ||
|  |     if (!response.startsWith('235')) { | ||
|  |       throw MtaAuthenticationError.invalidCredentials( | ||
|  |         this.options.host, | ||
|  |         user | ||
|  |       ); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Authenticate using LOGIN method | ||
|  |    * @param user Username | ||
|  |    * @param pass Password | ||
|  |    */ | ||
|  |   private async authLogin(user: string, pass: string): Promise<void> { | ||
|  |     // Start LOGIN authentication
 | ||
|  |     const response = await this.sendCommand('AUTH LOGIN'); | ||
|  |      | ||
|  |     if (!response.startsWith('334')) { | ||
|  |       throw new MtaAuthenticationError( | ||
|  |         `Server did not accept AUTH LOGIN: ${response}`, | ||
|  |         { | ||
|  |           data: { | ||
|  |             host: this.options.host, | ||
|  |             response | ||
|  |           } | ||
|  |         } | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     // Send username (base64)
 | ||
|  |     const userResponse = await this.sendCommand(Buffer.from(user).toString('base64')); | ||
|  |      | ||
|  |     if (!userResponse.startsWith('334')) { | ||
|  |       throw MtaAuthenticationError.invalidCredentials( | ||
|  |         this.options.host, | ||
|  |         user | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     // Send password (base64)
 | ||
|  |     const passResponse = await this.sendCommand(Buffer.from(pass).toString('base64')); | ||
|  |      | ||
|  |     if (!passResponse.startsWith('235')) { | ||
|  |       throw MtaAuthenticationError.invalidCredentials( | ||
|  |         this.options.host, | ||
|  |         user | ||
|  |       ); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Authenticate using OAuth2 method | ||
|  |    * @param user Username | ||
|  |    * @param token OAuth2 token | ||
|  |    */ | ||
|  |   private async authOAuth2(user: string, token: string): Promise<void> { | ||
|  |     // XOAUTH2 format
 | ||
|  |     const authString = `user=${user}\x01auth=Bearer ${token}\x01\x01`; | ||
|  |     const response = await this.sendCommand(`AUTH XOAUTH2 ${Buffer.from(authString).toString('base64')}`); | ||
|  |      | ||
|  |     if (!response.startsWith('235')) { | ||
|  |       throw MtaAuthenticationError.invalidCredentials( | ||
|  |         this.options.host, | ||
|  |         user | ||
|  |       ); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Send an email through the SMTP client | ||
|  |    * @param email Email to send | ||
|  |    * @param processingMode Optional processing mode | ||
|  |    */ | ||
|  |   public async sendMail(email: Email, processingMode?: EmailProcessingMode): Promise<ISmtpDeliveryResult> { | ||
|  |     // Ensure we're connected
 | ||
|  |     if (!this.connected || !this.socket) { | ||
|  |       await this.connect(); | ||
|  |     } | ||
|  |      | ||
|  |     const startTime = Date.now(); | ||
|  |     const result: ISmtpDeliveryResult = { | ||
|  |       success: false, | ||
|  |       acceptedRecipients: [], | ||
|  |       rejectedRecipients: [], | ||
|  |       timestamp: startTime, | ||
|  |       secure: this.options.secure || this.socket instanceof plugins.tls.TLSSocket, | ||
|  |       authenticated: !!this.options.auth | ||
|  |     }; | ||
|  |      | ||
|  |     try { | ||
|  |       logger.log('info', `Sending email to ${email.getAllRecipients().join(', ')}`); | ||
|  |        | ||
|  |       // Apply DKIM signing if configured
 | ||
|  |       if (this.options.dkim?.enabled) { | ||
|  |         await this.applyDkimSignature(email); | ||
|  |         result.dkimSigned = true; | ||
|  |       } | ||
|  |        | ||
|  |       // Get envelope and recipients
 | ||
|  |       const envelope_from = email.getEnvelopeFrom() || email.from; | ||
|  |       const recipients = email.getAllRecipients(); | ||
|  |        | ||
|  |       // Check if we can use pipelining for MAIL FROM and RCPT TO commands
 | ||
|  |       if (this.supportsPipelining && recipients.length > 0) { | ||
|  |         logger.log('debug', 'Using SMTP pipelining for sending'); | ||
|  |          | ||
|  |         // Send MAIL FROM command first (always needed)
 | ||
|  |         const mailFromCmd = `MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`; | ||
|  |         let mailFromResponse: string; | ||
|  |          | ||
|  |         try { | ||
|  |           mailFromResponse = await this.sendCommand(mailFromCmd); | ||
|  |            | ||
|  |           if (!mailFromResponse.startsWith('250')) { | ||
|  |             throw new MtaDeliveryError( | ||
|  |               `MAIL FROM command failed: ${mailFromResponse}`, | ||
|  |               { | ||
|  |                 data: { | ||
|  |                   command: mailFromCmd, | ||
|  |                   response: mailFromResponse | ||
|  |                 } | ||
|  |               } | ||
|  |             ); | ||
|  |           } | ||
|  |         } catch (error) { | ||
|  |           logger.log('error', `MAIL FROM failed: ${error.message}`); | ||
|  |           throw error; | ||
|  |         } | ||
|  |          | ||
|  |         // Pipeline all RCPT TO commands
 | ||
|  |         const rcptPromises = recipients.map(recipient => { | ||
|  |           return this.sendCommand(`RCPT TO:<${recipient}>`) | ||
|  |             .then(response => { | ||
|  |               if (response.startsWith('250')) { | ||
|  |                 result.acceptedRecipients.push(recipient); | ||
|  |                 return { recipient, accepted: true, response }; | ||
|  |               } else { | ||
|  |                 result.rejectedRecipients.push(recipient); | ||
|  |                 logger.log('warn', `Recipient ${recipient} rejected: ${response}`); | ||
|  |                 return { recipient, accepted: false, response }; | ||
|  |               } | ||
|  |             }) | ||
|  |             .catch(error => { | ||
|  |               result.rejectedRecipients.push(recipient); | ||
|  |               logger.log('warn', `Recipient ${recipient} rejected with error: ${error.message}`); | ||
|  |               return { recipient, accepted: false, error: error.message }; | ||
|  |             }); | ||
|  |         }); | ||
|  |          | ||
|  |         // Wait for all RCPT TO commands to complete
 | ||
|  |         await Promise.all(rcptPromises); | ||
|  |       } else { | ||
|  |         // Fall back to sequential commands if pipelining not supported
 | ||
|  |         logger.log('debug', 'Using sequential SMTP commands for sending'); | ||
|  |          | ||
|  |         // Send MAIL FROM
 | ||
|  |         await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`); | ||
|  |          | ||
|  |         // Send RCPT TO for each recipient
 | ||
|  |         for (const recipient of recipients) { | ||
|  |           try { | ||
|  |             await this.sendCommand(`RCPT TO:<${recipient}>`); | ||
|  |             result.acceptedRecipients.push(recipient); | ||
|  |           } catch (error) { | ||
|  |             logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`); | ||
|  |             result.rejectedRecipients.push(recipient); | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  |        | ||
|  |       // Check if at least one recipient was accepted
 | ||
|  |       if (result.acceptedRecipients.length === 0) { | ||
|  |         throw new MtaDeliveryError( | ||
|  |           'All recipients were rejected', | ||
|  |           { | ||
|  |             data: { | ||
|  |               recipients, | ||
|  |               rejectedRecipients: result.rejectedRecipients | ||
|  |             } | ||
|  |           } | ||
|  |         ); | ||
|  |       } | ||
|  |        | ||
|  |       // Send DATA
 | ||
|  |       const dataResponse = await this.sendCommand('DATA'); | ||
|  |        | ||
|  |       if (!dataResponse.startsWith('354')) { | ||
|  |         throw new MtaProtocolError( | ||
|  |           `Failed to start DATA phase: ${dataResponse}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               response: dataResponse | ||
|  |             } | ||
|  |           } | ||
|  |         ); | ||
|  |       } | ||
|  |        | ||
|  |       // Format email content efficiently
 | ||
|  |       const emailContent = await this.getFormattedEmail(email); | ||
|  |        | ||
|  |       // Send email content
 | ||
|  |       const finalResponse = await this.sendCommand(emailContent + '\r\n.'); | ||
|  |        | ||
|  |       // Extract message ID if available
 | ||
|  |       const messageIdMatch = finalResponse.match(/\[(.*?)\]/); | ||
|  |       if (messageIdMatch) { | ||
|  |         result.messageId = messageIdMatch[1]; | ||
|  |       } | ||
|  |        | ||
|  |       result.success = true; | ||
|  |       result.response = finalResponse; | ||
|  |        | ||
|  |       logger.log('info', `Email sent successfully to ${result.acceptedRecipients.join(', ')}`); | ||
|  |        | ||
|  |       // Log security event
 | ||
|  |       SecurityLogger.getInstance().logEvent({ | ||
|  |         level: SecurityLogLevel.INFO, | ||
|  |         type: SecurityEventType.EMAIL_DELIVERY, | ||
|  |         message: 'Email sent successfully', | ||
|  |         details: { | ||
|  |           recipients: result.acceptedRecipients, | ||
|  |           rejectedRecipients: result.rejectedRecipients, | ||
|  |           messageId: result.messageId, | ||
|  |           secure: result.secure, | ||
|  |           authenticated: result.authenticated, | ||
|  |           server: `${this.options.host}:${this.options.port}`, | ||
|  |           dkimSigned: result.dkimSigned | ||
|  |         }, | ||
|  |         success: true | ||
|  |       }); | ||
|  |        | ||
|  |       return result; | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Failed to send email: ${error.message}`); | ||
|  |        | ||
|  |       // Format error for result
 | ||
|  |       result.error = error.message; | ||
|  |        | ||
|  |       // Extract SMTP code if available
 | ||
|  |       if (error.context?.data?.statusCode) { | ||
|  |         result.responseCode = error.context.data.statusCode; | ||
|  |       } | ||
|  |        | ||
|  |       // Log security event
 | ||
|  |       SecurityLogger.getInstance().logEvent({ | ||
|  |         level: SecurityLogLevel.ERROR, | ||
|  |         type: SecurityEventType.EMAIL_DELIVERY, | ||
|  |         message: 'Email delivery failed', | ||
|  |         details: { | ||
|  |           error: error.message, | ||
|  |           server: `${this.options.host}:${this.options.port}`, | ||
|  |           recipients: email.getAllRecipients(), | ||
|  |           acceptedRecipients: result.acceptedRecipients, | ||
|  |           rejectedRecipients: result.rejectedRecipients, | ||
|  |           secure: result.secure, | ||
|  |           authenticated: result.authenticated | ||
|  |         }, | ||
|  |         success: false | ||
|  |       }); | ||
|  |        | ||
|  |       return result; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Apply DKIM signature to email | ||
|  |    * @param email Email to sign | ||
|  |    */ | ||
|  |   private async applyDkimSignature(email: Email): Promise<void> { | ||
|  |     if (!this.options.dkim?.enabled || !this.options.dkim?.privateKey) { | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     try { | ||
|  |       logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`); | ||
|  |        | ||
|  |       // Format email for DKIM signing
 | ||
|  |       const { dkimSign } = plugins; | ||
|  |       const emailContent = await this.getFormattedEmail(email); | ||
|  |        | ||
|  |       // Sign email
 | ||
|  |       const signOptions = { | ||
|  |         domainName: this.options.dkim.domain, | ||
|  |         keySelector: this.options.dkim.selector, | ||
|  |         privateKey: this.options.dkim.privateKey, | ||
|  |         headerFieldNames: this.options.dkim.headers || [ | ||
|  |           'from', 'to', 'subject', 'date', 'message-id' | ||
|  |         ] | ||
|  |       }; | ||
|  |        | ||
|  |       const signedEmail = await dkimSign(emailContent, signOptions); | ||
|  |        | ||
|  |       // Replace headers in original email
 | ||
|  |       const dkimHeader = signedEmail.substring(0, signedEmail.indexOf('\r\n\r\n')).split('\r\n') | ||
|  |         .find(line => line.startsWith('DKIM-Signature: ')); | ||
|  |        | ||
|  |       if (dkimHeader) { | ||
|  |         email.addHeader('DKIM-Signature', dkimHeader.substring('DKIM-Signature: '.length)); | ||
|  |       } | ||
|  |        | ||
|  |       logger.log('debug', 'DKIM signature applied successfully'); | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Failed to apply DKIM signature: ${error.message}`); | ||
|  |       throw error; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Format email for SMTP transmission | ||
|  |    * @param email Email to format | ||
|  |    */ | ||
|  |   private async getFormattedEmail(email: Email): Promise<string> { | ||
|  |     // This is a simplified implementation
 | ||
|  |     // In a full implementation, this would use proper MIME formatting
 | ||
|  |      | ||
|  |     let content = ''; | ||
|  |      | ||
|  |     // Add headers
 | ||
|  |     content += `From: ${email.from}\r\n`; | ||
|  |     content += `To: ${email.to.join(', ')}\r\n`; | ||
|  |     content += `Subject: ${email.subject}\r\n`; | ||
|  |     content += `Date: ${new Date().toUTCString()}\r\n`; | ||
|  |     content += `Message-ID: <${plugins.uuid.v4()}@${this.options.domain}>\r\n`; | ||
|  |      | ||
|  |     // Add additional headers
 | ||
|  |     for (const [name, value] of Object.entries(email.headers || {})) { | ||
|  |       content += `${name}: ${value}\r\n`; | ||
|  |     } | ||
|  |      | ||
|  |     // Add content type for multipart
 | ||
|  |     if (email.attachments && email.attachments.length > 0) { | ||
|  |       const boundary = `----_=_NextPart_${Math.random().toString(36).substr(2)}`; | ||
|  |       content += `MIME-Version: 1.0\r\n`; | ||
|  |       content += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`; | ||
|  |       content += `\r\n`; | ||
|  |        | ||
|  |       // Add text part
 | ||
|  |       content += `--${boundary}\r\n`; | ||
|  |       content += `Content-Type: text/plain; charset="UTF-8"\r\n`; | ||
|  |       content += `\r\n`; | ||
|  |       content += `${email.text}\r\n`; | ||
|  |        | ||
|  |       // Add HTML part if present
 | ||
|  |       if (email.html) { | ||
|  |         content += `--${boundary}\r\n`; | ||
|  |         content += `Content-Type: text/html; charset="UTF-8"\r\n`; | ||
|  |         content += `\r\n`; | ||
|  |         content += `${email.html}\r\n`; | ||
|  |       } | ||
|  |        | ||
|  |       // Add attachments
 | ||
|  |       for (const attachment of email.attachments) { | ||
|  |         content += `--${boundary}\r\n`; | ||
|  |         content += `Content-Type: ${attachment.contentType || 'application/octet-stream'}; name="${attachment.filename}"\r\n`; | ||
|  |         content += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`; | ||
|  |         content += `Content-Transfer-Encoding: base64\r\n`; | ||
|  |         content += `\r\n`; | ||
|  |          | ||
|  |         // Add base64 encoded content
 | ||
|  |         const base64Content = attachment.content.toString('base64'); | ||
|  |          | ||
|  |         // Split into lines of 76 characters
 | ||
|  |         for (let i = 0; i < base64Content.length; i += 76) { | ||
|  |           content += base64Content.substring(i, i + 76) + '\r\n'; | ||
|  |         } | ||
|  |       } | ||
|  |        | ||
|  |       // End boundary
 | ||
|  |       content += `--${boundary}--\r\n`; | ||
|  |     } else { | ||
|  |       // Simple email with just text
 | ||
|  |       content += `Content-Type: text/plain; charset="UTF-8"\r\n`; | ||
|  |       content += `\r\n`; | ||
|  |       content += `${email.text}\r\n`; | ||
|  |     } | ||
|  |      | ||
|  |     return content; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Get size of email in bytes | ||
|  |    * @param email Email to measure | ||
|  |    */ | ||
|  |   private getEmailSize(email: Email): number { | ||
|  |     // Simplified size estimation
 | ||
|  |     let size = 0; | ||
|  |      | ||
|  |     // Headers
 | ||
|  |     size += `From: ${email.from}\r\n`.length; | ||
|  |     size += `To: ${email.to.join(', ')}\r\n`.length; | ||
|  |     size += `Subject: ${email.subject}\r\n`.length; | ||
|  |      | ||
|  |     // Body
 | ||
|  |     size += (email.text?.length || 0) + 2; // +2 for CRLF
 | ||
|  |      | ||
|  |     // HTML part if present
 | ||
|  |     if (email.html) { | ||
|  |       size += email.html.length + 2; | ||
|  |     } | ||
|  |      | ||
|  |     // Attachments
 | ||
|  |     for (const attachment of email.attachments || []) { | ||
|  |       size += attachment.content.length; | ||
|  |     } | ||
|  |      | ||
|  |     // Add overhead for MIME boundaries and headers
 | ||
|  |     const overhead = email.attachments?.length ? 1000 + (email.attachments.length * 200) : 200; | ||
|  |      | ||
|  |     return size + overhead; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Send SMTP command and wait for response | ||
|  |    * @param command SMTP command to send | ||
|  |    */ | ||
|  |   // Queue for command pipelining
 | ||
|  |   private commandQueue: Array<{ | ||
|  |     command: string; | ||
|  |     resolve: (response: string) => void; | ||
|  |     reject: (error: any) => void; | ||
|  |     timeout: NodeJS.Timeout; | ||
|  |   }> = []; | ||
|  |    | ||
|  |   // Flag to indicate if we're currently processing commands
 | ||
|  |   private processingCommands = false; | ||
|  |    | ||
|  |   // Flag to indicate if server supports pipelining
 | ||
|  |   private supportsPipelining = false; | ||
|  |    | ||
|  |   /** | ||
|  |    * Send an SMTP command and wait for response | ||
|  |    * @param command SMTP command to send | ||
|  |    * @param allowPipelining Whether this command can be pipelined | ||
|  |    */ | ||
|  |   private async sendCommand(command: string, allowPipelining = true): Promise<string> { | ||
|  |     if (!this.socket) { | ||
|  |       throw new MtaConnectionError( | ||
|  |         'Not connected to server', | ||
|  |         { | ||
|  |           data: { | ||
|  |             host: this.options.host, | ||
|  |             port: this.options.port | ||
|  |           } | ||
|  |         } | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     // Log command if not sensitive
 | ||
|  |     if (!command.startsWith('AUTH')) { | ||
|  |       logger.log('debug', `> ${command}`); | ||
|  |     } else { | ||
|  |       logger.log('debug', '> AUTH ***'); | ||
|  |     } | ||
|  |      | ||
|  |     return new Promise<string>((resolve, reject) => { | ||
|  |       // Set up timeout for command
 | ||
|  |       const timeout = setTimeout(() => { | ||
|  |         // Remove this command from the queue if it times out
 | ||
|  |         const index = this.commandQueue.findIndex(item => item.command === command); | ||
|  |         if (index !== -1) { | ||
|  |           this.commandQueue.splice(index, 1); | ||
|  |         } | ||
|  |          | ||
|  |         reject(MtaTimeoutError.commandTimeout( | ||
|  |           command.split(' ')[0], | ||
|  |           this.options.host, | ||
|  |           this.options.commandTimeout | ||
|  |         )); | ||
|  |       }, this.options.commandTimeout); | ||
|  |        | ||
|  |       // Add command to the queue
 | ||
|  |       this.commandQueue.push({ | ||
|  |         command, | ||
|  |         resolve, | ||
|  |         reject, | ||
|  |         timeout | ||
|  |       }); | ||
|  |        | ||
|  |       // Process command queue if we can pipeline or if not currently processing commands
 | ||
|  |       if ((this.supportsPipelining && allowPipelining) || !this.processingCommands) { | ||
|  |         this.processCommandQueue(); | ||
|  |       } | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Process the command queue - either one by one or pipelined if supported | ||
|  |    */ | ||
|  |   private processCommandQueue(): void { | ||
|  |     if (this.processingCommands || this.commandQueue.length === 0 || !this.socket) { | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     this.processingCommands = true; | ||
|  |      | ||
|  |     try { | ||
|  |       // If pipelining is supported, send all commands at once
 | ||
|  |       if (this.supportsPipelining) { | ||
|  |         // Send all commands in queue at once
 | ||
|  |         const commands = this.commandQueue.map(item => item.command).join('\r\n') + '\r\n'; | ||
|  |          | ||
|  |         this.socket.write(commands, (err) => { | ||
|  |           if (err) { | ||
|  |             // Handle write error for all commands
 | ||
|  |             const error = new MtaConnectionError( | ||
|  |               `Failed to send commands: ${err.message}`, | ||
|  |               { | ||
|  |                 data: { | ||
|  |                   error: err.message | ||
|  |                 } | ||
|  |               } | ||
|  |             ); | ||
|  |              | ||
|  |             // Fail all pending commands
 | ||
|  |             while (this.commandQueue.length > 0) { | ||
|  |               const item = this.commandQueue.shift(); | ||
|  |               clearTimeout(item.timeout); | ||
|  |               item.reject(error); | ||
|  |             } | ||
|  |              | ||
|  |             this.processingCommands = false; | ||
|  |           } | ||
|  |         }); | ||
|  |          | ||
|  |         // Process responses one by one in order
 | ||
|  |         this.processResponses(); | ||
|  |       } else { | ||
|  |         // Process commands one by one if pipelining not supported
 | ||
|  |         this.processNextCommand(); | ||
|  |       } | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Error processing command queue: ${error.message}`); | ||
|  |       this.processingCommands = false; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Process the next command in the queue (non-pipelined mode) | ||
|  |    */ | ||
|  |   private processNextCommand(): void { | ||
|  |     if (this.commandQueue.length === 0 || !this.socket) { | ||
|  |       this.processingCommands = false; | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     const currentCommand = this.commandQueue[0]; | ||
|  |      | ||
|  |     this.socket.write(currentCommand.command + '\r\n', (err) => { | ||
|  |       if (err) { | ||
|  |         // Handle write error
 | ||
|  |         const error = new MtaConnectionError( | ||
|  |           `Failed to send command: ${err.message}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               command: currentCommand.command.split(' ')[0], | ||
|  |               error: err.message | ||
|  |             } | ||
|  |           } | ||
|  |         ); | ||
|  |          | ||
|  |         // Remove from queue
 | ||
|  |         this.commandQueue.shift(); | ||
|  |         clearTimeout(currentCommand.timeout); | ||
|  |         currentCommand.reject(error); | ||
|  |          | ||
|  |         // Continue with next command
 | ||
|  |         this.processNextCommand(); | ||
|  |         return; | ||
|  |       } | ||
|  |        | ||
|  |       // Read response
 | ||
|  |       this.readResponse() | ||
|  |         .then((response) => { | ||
|  |           // Remove from queue and resolve
 | ||
|  |           this.commandQueue.shift(); | ||
|  |           clearTimeout(currentCommand.timeout); | ||
|  |           currentCommand.resolve(response); | ||
|  |            | ||
|  |           // Process next command
 | ||
|  |           this.processNextCommand(); | ||
|  |         }) | ||
|  |         .catch((err) => { | ||
|  |           // Remove from queue and reject
 | ||
|  |           this.commandQueue.shift(); | ||
|  |           clearTimeout(currentCommand.timeout); | ||
|  |           currentCommand.reject(err); | ||
|  |            | ||
|  |           // Process next command
 | ||
|  |           this.processNextCommand(); | ||
|  |         }); | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Process responses for pipelined commands | ||
|  |    */ | ||
|  |   private async processResponses(): Promise<void> { | ||
|  |     try { | ||
|  |       // Process responses for each command in order
 | ||
|  |       while (this.commandQueue.length > 0) { | ||
|  |         const currentCommand = this.commandQueue[0]; | ||
|  |          | ||
|  |         try { | ||
|  |           // Wait for response
 | ||
|  |           const response = await this.readResponse(); | ||
|  |            | ||
|  |           // Remove from queue and resolve
 | ||
|  |           this.commandQueue.shift(); | ||
|  |           clearTimeout(currentCommand.timeout); | ||
|  |           currentCommand.resolve(response); | ||
|  |         } catch (error) { | ||
|  |           // Remove from queue and reject
 | ||
|  |           this.commandQueue.shift(); | ||
|  |           clearTimeout(currentCommand.timeout); | ||
|  |           currentCommand.reject(error); | ||
|  |            | ||
|  |           // Stop processing if this is a critical error
 | ||
|  |           if ( | ||
|  |             error instanceof MtaConnectionError && | ||
|  |             (error.message.includes('Connection closed') || error.message.includes('Not connected')) | ||
|  |           ) { | ||
|  |             break; | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Error processing responses: ${error.message}`); | ||
|  |     } finally { | ||
|  |       this.processingCommands = false; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Read response from the server | ||
|  |    */ | ||
|  |   private async readResponse(): Promise<string> { | ||
|  |     if (!this.socket) { | ||
|  |       throw new MtaConnectionError( | ||
|  |         'Not connected to server', | ||
|  |         { | ||
|  |           data: { | ||
|  |             host: this.options.host, | ||
|  |             port: this.options.port | ||
|  |           } | ||
|  |         } | ||
|  |       ); | ||
|  |     } | ||
|  |      | ||
|  |     return new Promise<string>((resolve, reject) => { | ||
|  |       // Use an array to collect response chunks instead of string concatenation
 | ||
|  |       const responseChunks: Buffer[] = []; | ||
|  |        | ||
|  |       // Single function to clean up all listeners
 | ||
|  |       const cleanupListeners = () => { | ||
|  |         if (!this.socket) return; | ||
|  |         this.socket.removeListener('data', onData); | ||
|  |         this.socket.removeListener('error', onError); | ||
|  |         this.socket.removeListener('close', onClose); | ||
|  |         this.socket.removeListener('end', onEnd); | ||
|  |       }; | ||
|  |        | ||
|  |       const onData = (data: Buffer) => { | ||
|  |         // Store buffer directly, avoiding unnecessary string conversion
 | ||
|  |         responseChunks.push(data); | ||
|  |          | ||
|  |         // Convert to string only for response checking
 | ||
|  |         const responseData = Buffer.concat(responseChunks).toString(); | ||
|  |          | ||
|  |         // Check if this is a complete response
 | ||
|  |         if (this.isCompleteResponse(responseData)) { | ||
|  |           // Clean up listeners
 | ||
|  |           cleanupListeners(); | ||
|  |            | ||
|  |           const trimmedResponse = responseData.trim(); | ||
|  |           logger.log('debug', `< ${trimmedResponse}`); | ||
|  |            | ||
|  |           // Check if this is an error response
 | ||
|  |           if (this.isErrorResponse(responseData)) { | ||
|  |             const code = responseData.substring(0, 3); | ||
|  |             reject(this.createErrorFromResponse(trimmedResponse, code)); | ||
|  |           } else { | ||
|  |             resolve(trimmedResponse); | ||
|  |           } | ||
|  |         } | ||
|  |       }; | ||
|  |        | ||
|  |       const onError = (err: Error) => { | ||
|  |         cleanupListeners(); | ||
|  |          | ||
|  |         reject(new MtaConnectionError( | ||
|  |           `Socket error while waiting for response: ${err.message}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               error: err.message | ||
|  |             } | ||
|  |           } | ||
|  |         )); | ||
|  |       }; | ||
|  |        | ||
|  |       const onClose = () => { | ||
|  |         cleanupListeners(); | ||
|  |          | ||
|  |         const responseData = Buffer.concat(responseChunks).toString(); | ||
|  |         reject(new MtaConnectionError( | ||
|  |           'Connection closed while waiting for response', | ||
|  |           { | ||
|  |             data: { | ||
|  |               partialResponse: responseData | ||
|  |             } | ||
|  |           } | ||
|  |         )); | ||
|  |       }; | ||
|  |        | ||
|  |       const onEnd = () => { | ||
|  |         cleanupListeners(); | ||
|  |          | ||
|  |         const responseData = Buffer.concat(responseChunks).toString(); | ||
|  |         reject(new MtaConnectionError( | ||
|  |           'Connection ended while waiting for response', | ||
|  |           { | ||
|  |             data: { | ||
|  |               partialResponse: responseData | ||
|  |             } | ||
|  |           } | ||
|  |         )); | ||
|  |       }; | ||
|  |        | ||
|  |       // Set up listeners
 | ||
|  |       this.socket.on('data', onData); | ||
|  |       this.socket.once('error', onError); | ||
|  |       this.socket.once('close', onClose); | ||
|  |       this.socket.once('end', onEnd); | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if the response is complete | ||
|  |    * @param response Response to check | ||
|  |    */ | ||
|  |   private isCompleteResponse(response: string): boolean { | ||
|  |     // Check if it's a multi-line response
 | ||
|  |     const lines = response.split('\r\n'); | ||
|  |     const lastLine = lines[lines.length - 2]; // Second to last because of the trailing CRLF
 | ||
|  |      | ||
|  |     // Check if the last line starts with a code followed by a space
 | ||
|  |     // If it does, this is a complete response
 | ||
|  |     if (lastLine && /^\d{3} /.test(lastLine)) { | ||
|  |       return true; | ||
|  |     } | ||
|  |      | ||
|  |     // For single line responses
 | ||
|  |     if (lines.length === 2 && lines[0].length >= 3 && /^\d{3} /.test(lines[0])) { | ||
|  |       return true; | ||
|  |     } | ||
|  |      | ||
|  |     return false; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Check if the response is an error | ||
|  |    * @param response Response to check | ||
|  |    */ | ||
|  |   private isErrorResponse(response: string): boolean { | ||
|  |     // Get the status code (first 3 characters)
 | ||
|  |     const code = response.substring(0, 3); | ||
|  |      | ||
|  |     // 4xx and 5xx are error codes
 | ||
|  |     return code.startsWith('4') || code.startsWith('5'); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Create appropriate error from response | ||
|  |    * @param response Error response | ||
|  |    * @param code SMTP status code | ||
|  |    */ | ||
|  |   private createErrorFromResponse(response: string, code: string): Error { | ||
|  |     // Extract message part
 | ||
|  |     const message = response.substring(4).trim(); | ||
|  |      | ||
|  |     switch (code.charAt(0)) { | ||
|  |       case '4': // Temporary errors
 | ||
|  |         return MtaDeliveryError.temporary( | ||
|  |           message, | ||
|  |           'recipient', | ||
|  |           code, | ||
|  |           response | ||
|  |         ); | ||
|  |          | ||
|  |       case '5': // Permanent errors
 | ||
|  |         return MtaDeliveryError.permanent( | ||
|  |           message, | ||
|  |           'recipient', | ||
|  |           code, | ||
|  |           response | ||
|  |         ); | ||
|  |          | ||
|  |       default: | ||
|  |         return new MtaDeliveryError( | ||
|  |           `Unexpected error response: ${response}`, | ||
|  |           { | ||
|  |             data: { | ||
|  |               response, | ||
|  |               code | ||
|  |             } | ||
|  |           } | ||
|  |         ); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Close the connection to the server | ||
|  |    */ | ||
|  |   public async close(): Promise<void> { | ||
|  |     if (!this.connected || !this.socket) { | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     try { | ||
|  |       // Send QUIT
 | ||
|  |       await this.sendCommand('QUIT'); | ||
|  |     } catch (error) { | ||
|  |       logger.log('warn', `Error sending QUIT command: ${error.message}`); | ||
|  |     } finally { | ||
|  |       // Close socket
 | ||
|  |       this.socket.destroy(); | ||
|  |       this.socket = undefined; | ||
|  |       this.connected = false; | ||
|  |       logger.log('info', 'SMTP connection closed'); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Checks if the connection is active | ||
|  |    */ | ||
|  |   public isConnected(): boolean { | ||
|  |     return this.connected && !!this.socket; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Update SMTP client options | ||
|  |    * @param options New options | ||
|  |    */ | ||
|  |   public updateOptions(options: Partial<ISmtpClientOptions>): void { | ||
|  |     this.options = { | ||
|  |       ...this.options, | ||
|  |       ...options | ||
|  |     }; | ||
|  |      | ||
|  |     logger.log('info', 'SMTP client options updated'); | ||
|  |   } | ||
|  | } |