357 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			357 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * SMTP Client Core Implementation | ||
|  |  * Main client class with delegation to handlers | ||
|  |  */ | ||
|  | 
 | ||
|  | import { EventEmitter } from 'node:events'; | ||
|  | 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; | ||
|  | } | ||
|  | 
 | ||
|  | export class SmtpClient extends EventEmitter { | ||
|  |   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); | ||
|  |     }); | ||
|  |   } | ||
|  | } |