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