| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  | /** | 
					
						
							|  |  |  |  * SMTP Client Core Implementation | 
					
						
							|  |  |  |  * Main client class with delegation to handlers | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-24 10:00:25 +00:00
										 |  |  | import * as plugins from '../../../plugins.ts'; | 
					
						
							| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  | import type { Email } from '../../core/classes.email.ts'; | 
					
						
							|  |  |  | import type {  | 
					
						
							|  |  |  |   ISmtpClientOptions, | 
					
						
							|  |  |  |   ISmtpSendResult, | 
					
						
							|  |  |  |   ISmtpConnection, | 
					
						
							|  |  |  |   IConnectionPoolStatus, | 
					
						
							|  |  |  |   ConnectionState | 
					
						
							|  |  |  | } from './interfaces.ts'; | 
					
						
							|  |  |  | import { CONNECTION_STATES, SmtpErrorType } from './constants.ts'; | 
					
						
							|  |  |  | import type { ConnectionManager } from './connection-manager.ts'; | 
					
						
							|  |  |  | import type { CommandHandler } from './command-handler.ts'; | 
					
						
							|  |  |  | import type { AuthHandler } from './auth-handler.ts'; | 
					
						
							|  |  |  | import type { TlsHandler } from './tls-handler.ts'; | 
					
						
							|  |  |  | import type { SmtpErrorHandler } from './error-handler.ts'; | 
					
						
							|  |  |  | import { validateSender, validateRecipients } from './utils/validation.ts'; | 
					
						
							|  |  |  | import { logEmailSend, logPerformance, logDebug } from './utils/logging.ts'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | interface ISmtpClientDependencies { | 
					
						
							|  |  |  |   options: ISmtpClientOptions; | 
					
						
							|  |  |  |   connectionManager: ConnectionManager; | 
					
						
							|  |  |  |   commandHandler: CommandHandler; | 
					
						
							|  |  |  |   authHandler: AuthHandler; | 
					
						
							|  |  |  |   tlsHandler: TlsHandler; | 
					
						
							|  |  |  |   errorHandler: SmtpErrorHandler; | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-24 10:00:25 +00:00
										 |  |  | export class SmtpClient extends plugins.EventEmitter { | 
					
						
							| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  |   private options: ISmtpClientOptions; | 
					
						
							|  |  |  |   private connectionManager: ConnectionManager; | 
					
						
							|  |  |  |   private commandHandler: CommandHandler; | 
					
						
							|  |  |  |   private authHandler: AuthHandler; | 
					
						
							|  |  |  |   private tlsHandler: TlsHandler; | 
					
						
							|  |  |  |   private errorHandler: SmtpErrorHandler; | 
					
						
							|  |  |  |   private isShuttingDown: boolean = false; | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   constructor(dependencies: ISmtpClientDependencies) { | 
					
						
							|  |  |  |     super(); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.options = dependencies.options; | 
					
						
							|  |  |  |     this.connectionManager = dependencies.connectionManager; | 
					
						
							|  |  |  |     this.commandHandler = dependencies.commandHandler; | 
					
						
							|  |  |  |     this.authHandler = dependencies.authHandler; | 
					
						
							|  |  |  |     this.tlsHandler = dependencies.tlsHandler; | 
					
						
							|  |  |  |     this.errorHandler = dependencies.errorHandler; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.setupEventForwarding(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send an email | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendMail(email: Email): Promise<ISmtpSendResult> { | 
					
						
							|  |  |  |     const startTime = Date.now(); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Extract clean email addresses without display names for SMTP operations
 | 
					
						
							|  |  |  |     const fromAddress = email.getFromAddress(); | 
					
						
							|  |  |  |     const recipients = email.getToAddresses(); | 
					
						
							|  |  |  |     const ccRecipients = email.getCcAddresses(); | 
					
						
							|  |  |  |     const bccRecipients = email.getBccAddresses(); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Combine all recipients for SMTP operations
 | 
					
						
							|  |  |  |     const allRecipients = [...recipients, ...ccRecipients, ...bccRecipients]; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Validate email addresses
 | 
					
						
							|  |  |  |     if (!validateSender(fromAddress)) { | 
					
						
							|  |  |  |       throw new Error(`Invalid sender address: ${fromAddress}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     const recipientErrors = validateRecipients(allRecipients); | 
					
						
							|  |  |  |     if (recipientErrors.length > 0) { | 
					
						
							|  |  |  |       throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     logEmailSend('start', allRecipients, this.options); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     let connection: ISmtpConnection | null = null; | 
					
						
							|  |  |  |     const result: ISmtpSendResult = { | 
					
						
							|  |  |  |       success: false, | 
					
						
							|  |  |  |       acceptedRecipients: [], | 
					
						
							|  |  |  |       rejectedRecipients: [], | 
					
						
							|  |  |  |       envelope: { | 
					
						
							|  |  |  |         from: fromAddress, | 
					
						
							|  |  |  |         to: allRecipients | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     }; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       // Get connection
 | 
					
						
							|  |  |  |       connection = await this.connectionManager.getConnection(); | 
					
						
							|  |  |  |       connection.state = CONNECTION_STATES.BUSY as ConnectionState; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Wait for greeting if new connection
 | 
					
						
							|  |  |  |       if (!connection.capabilities) { | 
					
						
							|  |  |  |         await this.commandHandler.waitForGreeting(connection); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Perform EHLO
 | 
					
						
							|  |  |  |       await this.commandHandler.sendEhlo(connection, this.options.domain); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Upgrade to TLS if needed
 | 
					
						
							|  |  |  |       if (this.tlsHandler.shouldUseTLS(connection)) { | 
					
						
							|  |  |  |         await this.tlsHandler.upgradeToTLS(connection); | 
					
						
							|  |  |  |         // Re-send EHLO after TLS upgrade
 | 
					
						
							|  |  |  |         await this.commandHandler.sendEhlo(connection, this.options.domain); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Authenticate if needed
 | 
					
						
							|  |  |  |       if (this.options.auth) { | 
					
						
							|  |  |  |         await this.authHandler.authenticate(connection); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send MAIL FROM
 | 
					
						
							|  |  |  |       const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress); | 
					
						
							|  |  |  |       if (mailFromResponse.code >= 400) { | 
					
						
							|  |  |  |         throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send RCPT TO for each recipient (includes TO, CC, and BCC)
 | 
					
						
							|  |  |  |       for (const recipient of allRecipients) { | 
					
						
							|  |  |  |         try { | 
					
						
							|  |  |  |           const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient); | 
					
						
							|  |  |  |           if (rcptResponse.code >= 400) { | 
					
						
							|  |  |  |             result.rejectedRecipients.push(recipient); | 
					
						
							|  |  |  |             logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse }); | 
					
						
							|  |  |  |           } else { | 
					
						
							|  |  |  |             result.acceptedRecipients.push(recipient); | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |         } catch (error) { | 
					
						
							|  |  |  |           result.rejectedRecipients.push(recipient); | 
					
						
							|  |  |  |           logDebug(`Recipient error: ${recipient}`, this.options, { error }); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Check if we have any accepted recipients
 | 
					
						
							|  |  |  |       if (result.acceptedRecipients.length === 0) { | 
					
						
							|  |  |  |         throw new Error('All recipients were rejected'); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send DATA command
 | 
					
						
							|  |  |  |       const dataResponse = await this.commandHandler.sendData(connection); | 
					
						
							|  |  |  |       if (dataResponse.code !== 354) { | 
					
						
							|  |  |  |         throw new Error(`DATA command failed: ${dataResponse.message}`); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send email content
 | 
					
						
							|  |  |  |       const emailData = await this.formatEmailData(email); | 
					
						
							|  |  |  |       const sendResponse = await this.commandHandler.sendDataContent(connection, emailData); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       if (sendResponse.code >= 400) { | 
					
						
							|  |  |  |         throw new Error(`Email data rejected: ${sendResponse.message}`); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Success
 | 
					
						
							|  |  |  |       result.success = true; | 
					
						
							|  |  |  |       result.messageId = this.extractMessageId(sendResponse.message); | 
					
						
							|  |  |  |       result.response = sendResponse.message; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       connection.messageCount++; | 
					
						
							|  |  |  |       logEmailSend('success', recipients, this.options, {  | 
					
						
							|  |  |  |         messageId: result.messageId, | 
					
						
							|  |  |  |         duration: Date.now() - startTime | 
					
						
							|  |  |  |       }); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |     } catch (error) { | 
					
						
							|  |  |  |       result.success = false; | 
					
						
							|  |  |  |       result.error = error instanceof Error ? error : new Error(String(error)); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Classify error and determine if we should retry
 | 
					
						
							|  |  |  |       const errorType = this.errorHandler.classifyError(result.error); | 
					
						
							|  |  |  |       result.error = this.errorHandler.createError( | 
					
						
							|  |  |  |         result.error.message, | 
					
						
							|  |  |  |         errorType, | 
					
						
							|  |  |  |         { command: 'SEND_MAIL' }, | 
					
						
							|  |  |  |         result.error | 
					
						
							|  |  |  |       ); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       logEmailSend('failure', recipients, this.options, {  | 
					
						
							|  |  |  |         error: result.error, | 
					
						
							|  |  |  |         duration: Date.now() - startTime | 
					
						
							|  |  |  |       }); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |     } finally { | 
					
						
							|  |  |  |       // Release connection
 | 
					
						
							|  |  |  |       if (connection) { | 
					
						
							|  |  |  |         connection.state = CONNECTION_STATES.READY as ConnectionState; | 
					
						
							|  |  |  |         this.connectionManager.updateActivity(connection); | 
					
						
							|  |  |  |         this.connectionManager.releaseConnection(connection); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       logPerformance('sendMail', Date.now() - startTime, this.options); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     return result; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Test connection to SMTP server | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async verify(): Promise<boolean> { | 
					
						
							|  |  |  |     let connection: ISmtpConnection | null = null; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       connection = await this.connectionManager.createConnection(); | 
					
						
							|  |  |  |       await this.commandHandler.waitForGreeting(connection); | 
					
						
							|  |  |  |       await this.commandHandler.sendEhlo(connection, this.options.domain); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       if (this.tlsHandler.shouldUseTLS(connection)) { | 
					
						
							|  |  |  |         await this.tlsHandler.upgradeToTLS(connection); | 
					
						
							|  |  |  |         await this.commandHandler.sendEhlo(connection, this.options.domain); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       if (this.options.auth) { | 
					
						
							|  |  |  |         await this.authHandler.authenticate(connection); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       await this.commandHandler.sendQuit(connection); | 
					
						
							|  |  |  |       return true; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |     } catch (error) { | 
					
						
							|  |  |  |       logDebug('Connection verification failed', this.options, { error }); | 
					
						
							|  |  |  |       return false; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |     } finally { | 
					
						
							|  |  |  |       if (connection) { | 
					
						
							|  |  |  |         this.connectionManager.closeConnection(connection); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Check if client is connected | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public isConnected(): boolean { | 
					
						
							|  |  |  |     const status = this.connectionManager.getPoolStatus(); | 
					
						
							|  |  |  |     return status.total > 0; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Get connection pool status | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public getPoolStatus(): IConnectionPoolStatus { | 
					
						
							|  |  |  |     return this.connectionManager.getPoolStatus(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Update client options | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public updateOptions(newOptions: Partial<ISmtpClientOptions>): void { | 
					
						
							|  |  |  |     this.options = { ...this.options, ...newOptions }; | 
					
						
							|  |  |  |     logDebug('Client options updated', this.options); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Close all connections and shutdown client | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async close(): Promise<void> { | 
					
						
							|  |  |  |     if (this.isShuttingDown) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.isShuttingDown = true; | 
					
						
							|  |  |  |     logDebug('Shutting down SMTP client', this.options); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     try { | 
					
						
							|  |  |  |       this.connectionManager.closeAllConnections(); | 
					
						
							|  |  |  |       this.emit('close'); | 
					
						
							|  |  |  |     } catch (error) { | 
					
						
							|  |  |  |       logDebug('Error during client shutdown', this.options, { error }); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   private async formatEmailData(email: Email): Promise<string> { | 
					
						
							|  |  |  |     // Convert Email object to raw SMTP data
 | 
					
						
							|  |  |  |     const headers: string[] = []; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Required headers
 | 
					
						
							|  |  |  |     headers.push(`From: ${email.from}`); | 
					
						
							|  |  |  |     headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`); | 
					
						
							|  |  |  |     headers.push(`Subject: ${email.subject || ''}`); | 
					
						
							|  |  |  |     headers.push(`Date: ${new Date().toUTCString()}`); | 
					
						
							|  |  |  |     headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Optional headers
 | 
					
						
							|  |  |  |     if (email.cc) { | 
					
						
							|  |  |  |       const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc; | 
					
						
							|  |  |  |       headers.push(`Cc: ${cc}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     if (email.bcc) { | 
					
						
							|  |  |  |       const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc; | 
					
						
							|  |  |  |       headers.push(`Bcc: ${bcc}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Content headers
 | 
					
						
							|  |  |  |     if (email.html && email.text) { | 
					
						
							|  |  |  |       // Multipart message
 | 
					
						
							|  |  |  |       const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`; | 
					
						
							|  |  |  |       headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); | 
					
						
							|  |  |  |       headers.push('MIME-Version: 1.0'); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       const body = [ | 
					
						
							|  |  |  |         `--${boundary}`, | 
					
						
							|  |  |  |         'Content-Type: text/plain; charset=utf-8', | 
					
						
							|  |  |  |         'Content-Transfer-Encoding: quoted-printable', | 
					
						
							|  |  |  |         '', | 
					
						
							|  |  |  |         email.text, | 
					
						
							|  |  |  |         '', | 
					
						
							|  |  |  |         `--${boundary}`, | 
					
						
							|  |  |  |         'Content-Type: text/html; charset=utf-8', | 
					
						
							|  |  |  |         'Content-Transfer-Encoding: quoted-printable', | 
					
						
							|  |  |  |         '', | 
					
						
							|  |  |  |         email.html, | 
					
						
							|  |  |  |         '', | 
					
						
							|  |  |  |         `--${boundary}--` | 
					
						
							|  |  |  |       ].join('\r\n'); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       return headers.join('\r\n') + '\r\n\r\n' + body; | 
					
						
							|  |  |  |     } else if (email.html) { | 
					
						
							|  |  |  |       headers.push('Content-Type: text/html; charset=utf-8'); | 
					
						
							|  |  |  |       headers.push('MIME-Version: 1.0'); | 
					
						
							|  |  |  |       return headers.join('\r\n') + '\r\n\r\n' + email.html; | 
					
						
							|  |  |  |     } else { | 
					
						
							|  |  |  |       headers.push('Content-Type: text/plain; charset=utf-8'); | 
					
						
							|  |  |  |       headers.push('MIME-Version: 1.0'); | 
					
						
							|  |  |  |       return headers.join('\r\n') + '\r\n\r\n' + (email.text || ''); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   private extractMessageId(response: string): string | undefined { | 
					
						
							|  |  |  |     // Try to extract message ID from server response
 | 
					
						
							|  |  |  |     const match = response.match(/queued as ([^\s]+)/i) ||  | 
					
						
							|  |  |  |                   response.match(/id=([^\s]+)/i) || | 
					
						
							|  |  |  |                   response.match(/Message-ID: <([^>]+)>/i); | 
					
						
							|  |  |  |     return match ? match[1] : undefined; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   private setupEventForwarding(): void { | 
					
						
							|  |  |  |     // Forward events from connection manager
 | 
					
						
							|  |  |  |     this.connectionManager.on('connection', (connection) => { | 
					
						
							|  |  |  |       this.emit('connection', connection); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.connectionManager.on('disconnect', (connection) => { | 
					
						
							|  |  |  |       this.emit('disconnect', connection); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.connectionManager.on('error', (error) => { | 
					
						
							|  |  |  |       this.emit('error', error); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } |