/** * SMTP Client Core Implementation * Main client class with delegation to handlers */ import { EventEmitter } from 'node:events'; import type { Email } from '../../core/classes.email.js'; import type { ISmtpClientOptions, ISmtpSendResult, ISmtpConnection, IConnectionPoolStatus, ConnectionState } from './interfaces.js'; import { CONNECTION_STATES, SmtpErrorType } from './constants.js'; import type { ConnectionManager } from './connection-manager.js'; import type { CommandHandler } from './command-handler.js'; import type { AuthHandler } from './auth-handler.js'; import type { TlsHandler } from './tls-handler.js'; import type { SmtpErrorHandler } from './error-handler.js'; import { validateSender, validateRecipients } from './utils/validation.js'; import { logEmailSend, logPerformance, logDebug } from './utils/logging.js'; 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 { 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 { 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): void { this.options = { ...this.options, ...newOptions }; logDebug('Client options updated', this.options); } /** * Close all connections and shutdown client */ public async close(): Promise { 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 { // 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); }); } }