| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  | /** | 
					
						
							|  |  |  |  * SMTP Client Command Handler | 
					
						
							|  |  |  |  * SMTP command sending and response parsing | 
					
						
							|  |  |  |  */ | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-24 10:00:25 +00:00
										 |  |  | import * as plugins from '../../../plugins.ts'; | 
					
						
							| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  | import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.ts'; | 
					
						
							|  |  |  | import type {  | 
					
						
							|  |  |  |   ISmtpConnection,  | 
					
						
							|  |  |  |   ISmtpResponse,  | 
					
						
							|  |  |  |   ISmtpClientOptions, | 
					
						
							|  |  |  |   ISmtpCapabilities  | 
					
						
							|  |  |  | } from './interfaces.ts'; | 
					
						
							|  |  |  | import {  | 
					
						
							|  |  |  |   parseSmtpResponse,  | 
					
						
							|  |  |  |   parseEhloResponse,  | 
					
						
							|  |  |  |   formatCommand, | 
					
						
							|  |  |  |   isSuccessCode  | 
					
						
							|  |  |  | } from './utils/helpers.ts'; | 
					
						
							|  |  |  | import { logCommand, logDebug } from './utils/logging.ts'; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-10-24 10:00:25 +00:00
										 |  |  | export class CommandHandler extends plugins.EventEmitter { | 
					
						
							| 
									
										
										
										
											2025-10-24 08:09:29 +00:00
										 |  |  |   private options: ISmtpClientOptions; | 
					
						
							|  |  |  |   private responseBuffer: string = ''; | 
					
						
							|  |  |  |   private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null; | 
					
						
							|  |  |  |   private commandTimeout: NodeJS.Timeout | null = null; | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   constructor(options: ISmtpClientOptions) { | 
					
						
							|  |  |  |     super(); | 
					
						
							|  |  |  |     this.options = options; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send EHLO command and parse capabilities | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise<ISmtpCapabilities> { | 
					
						
							|  |  |  |     const hostname = domain || this.options.domain || 'localhost'; | 
					
						
							|  |  |  |     const command = `${SMTP_COMMANDS.EHLO} ${hostname}`; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     const response = await this.sendCommand(connection, command); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     if (!isSuccessCode(response.code)) { | 
					
						
							|  |  |  |       throw new Error(`EHLO failed: ${response.message}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     const capabilities = parseEhloResponse(response.raw); | 
					
						
							|  |  |  |     connection.capabilities = capabilities; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     logDebug('EHLO capabilities parsed', this.options, { capabilities }); | 
					
						
							|  |  |  |     return capabilities; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send MAIL FROM command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     // Handle empty return path for bounce messages
 | 
					
						
							|  |  |  |     const command = fromAddress === ''  | 
					
						
							|  |  |  |       ? `${SMTP_COMMANDS.MAIL_FROM}:<>`  | 
					
						
							|  |  |  |       : `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`; | 
					
						
							|  |  |  |     return this.sendCommand(connection, command); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send RCPT TO command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendRcptTo(connection: ISmtpConnection, toAddress: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`; | 
					
						
							|  |  |  |     return this.sendCommand(connection, command); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send DATA command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendData(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return this.sendCommand(connection, SMTP_COMMANDS.DATA); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send email data content | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     // Normalize line endings to CRLF
 | 
					
						
							|  |  |  |     let data = emailData.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n'); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Ensure email data ends with CRLF
 | 
					
						
							|  |  |  |     if (!data.endsWith(LINE_ENDINGS.CRLF)) { | 
					
						
							|  |  |  |       data += LINE_ENDINGS.CRLF; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Perform dot stuffing (escape lines starting with a dot)
 | 
					
						
							|  |  |  |     data = data.replace(/\r\n\./g, '\r\n..'); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Add termination sequence
 | 
					
						
							|  |  |  |     data += '.' + LINE_ENDINGS.CRLF; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     return this.sendRawData(connection, data); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send RSET command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendRset(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return this.sendCommand(connection, SMTP_COMMANDS.RSET); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send NOOP command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendNoop(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return this.sendCommand(connection, SMTP_COMMANDS.NOOP); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send QUIT command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendQuit(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return this.sendCommand(connection, SMTP_COMMANDS.QUIT); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send STARTTLS command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendStartTls(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send AUTH command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     const command = credentials ?  | 
					
						
							|  |  |  |       `${SMTP_COMMANDS.AUTH} ${method} ${credentials}` :  | 
					
						
							|  |  |  |       `${SMTP_COMMANDS.AUTH} ${method}`; | 
					
						
							|  |  |  |     return this.sendCommand(connection, command); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send a generic SMTP command | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendCommand(connection: ISmtpConnection, command: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return new Promise((resolve, reject) => { | 
					
						
							|  |  |  |       if (this.pendingCommand) { | 
					
						
							|  |  |  |         reject(new Error('Another command is already pending')); | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand = { resolve, reject, command }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Set command timeout
 | 
					
						
							|  |  |  |       const timeout = 30000; // 30 seconds
 | 
					
						
							|  |  |  |       this.commandTimeout = setTimeout(() => { | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         this.commandTimeout = null; | 
					
						
							|  |  |  |         reject(new Error(`Command timeout: ${command}`)); | 
					
						
							|  |  |  |       }, timeout); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Set up data handler
 | 
					
						
							|  |  |  |       const dataHandler = (data: Buffer) => { | 
					
						
							|  |  |  |         this.handleIncomingData(data.toString()); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       connection.socket.on('data', dataHandler); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Clean up function
 | 
					
						
							|  |  |  |       const cleanup = () => { | 
					
						
							|  |  |  |         connection.socket.removeListener('data', dataHandler); | 
					
						
							|  |  |  |         if (this.commandTimeout) { | 
					
						
							|  |  |  |           clearTimeout(this.commandTimeout); | 
					
						
							|  |  |  |           this.commandTimeout = null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send command
 | 
					
						
							|  |  |  |       const formattedCommand = command.endsWith(LINE_ENDINGS.CRLF) ? command : formatCommand(command); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       logCommand(command, undefined, this.options); | 
					
						
							|  |  |  |       logDebug(`Sending command: ${command}`, this.options); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       connection.socket.write(formattedCommand, (error) => { | 
					
						
							|  |  |  |         if (error) { | 
					
						
							|  |  |  |           cleanup(); | 
					
						
							|  |  |  |           this.pendingCommand = null; | 
					
						
							|  |  |  |           reject(error); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Override resolve/reject to include cleanup
 | 
					
						
							|  |  |  |       const originalResolve = resolve; | 
					
						
							|  |  |  |       const originalReject = reject; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand.resolve = (response: ISmtpResponse) => { | 
					
						
							|  |  |  |         cleanup(); | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         logCommand(command, response, this.options); | 
					
						
							|  |  |  |         originalResolve(response); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand.reject = (error: Error) => { | 
					
						
							|  |  |  |         cleanup(); | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         originalReject(error); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Send raw data without command formatting | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async sendRawData(connection: ISmtpConnection, data: string): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return new Promise((resolve, reject) => { | 
					
						
							|  |  |  |       if (this.pendingCommand) { | 
					
						
							|  |  |  |         reject(new Error('Another command is already pending')); | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand = { resolve, reject, command: 'DATA_CONTENT' }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Set data timeout
 | 
					
						
							|  |  |  |       const timeout = 60000; // 60 seconds for data
 | 
					
						
							|  |  |  |       this.commandTimeout = setTimeout(() => { | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         this.commandTimeout = null; | 
					
						
							|  |  |  |         reject(new Error('Data transmission timeout')); | 
					
						
							|  |  |  |       }, timeout); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Set up data handler
 | 
					
						
							|  |  |  |       const dataHandler = (chunk: Buffer) => { | 
					
						
							|  |  |  |         this.handleIncomingData(chunk.toString()); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       connection.socket.on('data', dataHandler); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Clean up function
 | 
					
						
							|  |  |  |       const cleanup = () => { | 
					
						
							|  |  |  |         connection.socket.removeListener('data', dataHandler); | 
					
						
							|  |  |  |         if (this.commandTimeout) { | 
					
						
							|  |  |  |           clearTimeout(this.commandTimeout); | 
					
						
							|  |  |  |           this.commandTimeout = null; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Override resolve/reject to include cleanup
 | 
					
						
							|  |  |  |       const originalResolve = resolve; | 
					
						
							|  |  |  |       const originalReject = reject; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand.resolve = (response: ISmtpResponse) => { | 
					
						
							|  |  |  |         cleanup(); | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         originalResolve(response); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       this.pendingCommand.reject = (error: Error) => { | 
					
						
							|  |  |  |         cleanup(); | 
					
						
							|  |  |  |         this.pendingCommand = null; | 
					
						
							|  |  |  |         originalReject(error); | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       // Send data
 | 
					
						
							|  |  |  |       connection.socket.write(data, (error) => { | 
					
						
							|  |  |  |         if (error) { | 
					
						
							|  |  |  |           cleanup(); | 
					
						
							|  |  |  |           this.pendingCommand = null; | 
					
						
							|  |  |  |           reject(error); | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   /** | 
					
						
							|  |  |  |    * Wait for server greeting | 
					
						
							|  |  |  |    */ | 
					
						
							|  |  |  |   public async waitForGreeting(connection: ISmtpConnection): Promise<ISmtpResponse> { | 
					
						
							|  |  |  |     return new Promise((resolve, reject) => { | 
					
						
							|  |  |  |       const timeout = 30000; // 30 seconds
 | 
					
						
							|  |  |  |       let timeoutHandler: NodeJS.Timeout; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       const dataHandler = (data: Buffer) => { | 
					
						
							|  |  |  |         this.responseBuffer += data.toString(); | 
					
						
							|  |  |  |          | 
					
						
							|  |  |  |         if (this.isCompleteResponse(this.responseBuffer)) { | 
					
						
							|  |  |  |           clearTimeout(timeoutHandler); | 
					
						
							|  |  |  |           connection.socket.removeListener('data', dataHandler); | 
					
						
							|  |  |  |            | 
					
						
							|  |  |  |           const response = parseSmtpResponse(this.responseBuffer); | 
					
						
							|  |  |  |           this.responseBuffer = ''; | 
					
						
							|  |  |  |            | 
					
						
							|  |  |  |           if (isSuccessCode(response.code)) { | 
					
						
							|  |  |  |             resolve(response); | 
					
						
							|  |  |  |           } else { | 
					
						
							|  |  |  |             reject(new Error(`Server greeting failed: ${response.message}`)); | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |       }; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       timeoutHandler = setTimeout(() => { | 
					
						
							|  |  |  |         connection.socket.removeListener('data', dataHandler); | 
					
						
							|  |  |  |         reject(new Error('Greeting timeout')); | 
					
						
							|  |  |  |       }, timeout); | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       connection.socket.on('data', dataHandler); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   private handleIncomingData(data: string): void { | 
					
						
							|  |  |  |     if (!this.pendingCommand) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     this.responseBuffer += data; | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     if (this.isCompleteResponse(this.responseBuffer)) { | 
					
						
							|  |  |  |       const response = parseSmtpResponse(this.responseBuffer); | 
					
						
							|  |  |  |       this.responseBuffer = ''; | 
					
						
							|  |  |  |        | 
					
						
							|  |  |  |       if (isSuccessCode(response.code) || (response.code >= 300 && response.code < 400) || response.code >= 400) { | 
					
						
							|  |  |  |         this.pendingCommand.resolve(response); | 
					
						
							|  |  |  |       } else { | 
					
						
							|  |  |  |         this.pendingCommand.reject(new Error(`Command failed: ${response.message}`)); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   private isCompleteResponse(buffer: string): boolean { | 
					
						
							|  |  |  |     // Check if we have a complete response
 | 
					
						
							|  |  |  |     const lines = buffer.split(/\r?\n/); | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     if (lines.length < 1) { | 
					
						
							|  |  |  |       return false; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     // Check the last non-empty line
 | 
					
						
							|  |  |  |     for (let i = lines.length - 1; i >= 0; i--) { | 
					
						
							|  |  |  |       const line = lines[i].trim(); | 
					
						
							|  |  |  |       if (line.length > 0) { | 
					
						
							|  |  |  |         // Response is complete if line starts with "XXX " (space after code)
 | 
					
						
							|  |  |  |         return /^\d{3} /.test(line); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |      | 
					
						
							|  |  |  |     return false; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } |