/** * SMTP Client Command Handler * SMTP command sending and response parsing */ import { EventEmitter } from 'node:events'; import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.js'; import type { ISmtpConnection, ISmtpResponse, ISmtpClientOptions, ISmtpCapabilities } from './interfaces.js'; import { parseSmtpResponse, parseEhloResponse, formatCommand, isSuccessCode } from './utils/helpers.js'; import { logCommand, logDebug } from './utils/logging.js'; export class CommandHandler extends EventEmitter { private options: ISmtpClientOptions; private responseBuffer: string = ''; private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null; private commandTimeout: NodeJS.Timeout | null = null; // Maximum buffer size to prevent memory exhaustion from rogue servers private static readonly MAX_BUFFER_SIZE = 1024 * 1024; // 1MB max constructor(options: ISmtpClientOptions) { super(); this.options = options; } /** * Send EHLO command and parse capabilities */ public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise { 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 { // 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 { const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`; return this.sendCommand(connection, command); } /** * Send DATA command */ public async sendData(connection: ISmtpConnection): Promise { return this.sendCommand(connection, SMTP_COMMANDS.DATA); } /** * Send email data content */ public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise { // 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 { return this.sendCommand(connection, SMTP_COMMANDS.RSET); } /** * Send NOOP command */ public async sendNoop(connection: ISmtpConnection): Promise { return this.sendCommand(connection, SMTP_COMMANDS.NOOP); } /** * Send QUIT command */ public async sendQuit(connection: ISmtpConnection): Promise { return this.sendCommand(connection, SMTP_COMMANDS.QUIT); } /** * Send STARTTLS command */ public async sendStartTls(connection: ISmtpConnection): Promise { return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS); } /** * Send AUTH command */ public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise { 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 { return new Promise((resolve, reject) => { if (this.pendingCommand) { reject(new Error('Another command is already pending')); return; } this.pendingCommand = { resolve, reject, command }; // Set up data handler const dataHandler = (data: Buffer) => { this.handleIncomingData(data.toString()); }; // Set up socket close/error handlers to reject pending promises const closeHandler = () => { if (this.pendingCommand) { this.pendingCommand.reject(new Error('Socket closed during command')); } }; const errorHandler = (err: Error) => { if (this.pendingCommand) { this.pendingCommand.reject(err); } }; connection.socket.on('data', dataHandler); connection.socket.once('close', closeHandler); connection.socket.once('error', errorHandler); // Clean up function - removes all listeners and clears buffer const cleanup = () => { connection.socket.removeListener('data', dataHandler); connection.socket.removeListener('close', closeHandler); connection.socket.removeListener('error', errorHandler); if (this.commandTimeout) { clearTimeout(this.commandTimeout); this.commandTimeout = null; } // Clear response buffer to prevent corrupted data for next command this.responseBuffer = ''; }; // Override resolve/reject to include cleanup BEFORE setting timeout 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); }; // Set command timeout - uses wrapped reject that includes cleanup const timeout = 30000; // 30 seconds this.commandTimeout = setTimeout(() => { if (this.pendingCommand) { this.pendingCommand.reject(new Error(`Command timeout: ${command}`)); } }, timeout); // 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) { if (this.pendingCommand) { this.pendingCommand.reject(error); } } }); }); } /** * Send raw data without command formatting */ public async sendRawData(connection: ISmtpConnection, data: string): Promise { 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 up data handler const dataHandler = (chunk: Buffer) => { this.handleIncomingData(chunk.toString()); }; // Set up socket close/error handlers to reject pending promises const closeHandler = () => { if (this.pendingCommand) { this.pendingCommand.reject(new Error('Socket closed during data transmission')); } }; const errorHandler = (err: Error) => { if (this.pendingCommand) { this.pendingCommand.reject(err); } }; connection.socket.on('data', dataHandler); connection.socket.once('close', closeHandler); connection.socket.once('error', errorHandler); // Clean up function - removes all listeners and clears buffer const cleanup = () => { connection.socket.removeListener('data', dataHandler); connection.socket.removeListener('close', closeHandler); connection.socket.removeListener('error', errorHandler); if (this.commandTimeout) { clearTimeout(this.commandTimeout); this.commandTimeout = null; } // Clear response buffer to prevent corrupted data for next command this.responseBuffer = ''; }; // Override resolve/reject to include cleanup BEFORE setting timeout 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); }; // Set data timeout - uses wrapped reject that includes cleanup const timeout = 60000; // 60 seconds for data this.commandTimeout = setTimeout(() => { if (this.pendingCommand) { this.pendingCommand.reject(new Error('Data transmission timeout')); } }, timeout); // Send data connection.socket.write(data, (error) => { if (error) { if (this.pendingCommand) { this.pendingCommand.reject(error); } } }); }); } /** * Wait for server greeting */ public async waitForGreeting(connection: ISmtpConnection): Promise { return new Promise((resolve, reject) => { const timeout = 30000; // 30 seconds let timeoutHandler: NodeJS.Timeout; let resolved = false; const cleanup = () => { if (resolved) return; resolved = true; clearTimeout(timeoutHandler); connection.socket.removeListener('data', dataHandler); connection.socket.removeListener('close', closeHandler); connection.socket.removeListener('error', errorHandler); this.responseBuffer = ''; }; const dataHandler = (data: Buffer) => { if (resolved) return; // Check buffer size if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) { cleanup(); reject(new Error('Greeting response too large')); return; } this.responseBuffer += data.toString(); if (this.isCompleteResponse(this.responseBuffer)) { const response = parseSmtpResponse(this.responseBuffer); cleanup(); if (isSuccessCode(response.code)) { resolve(response); } else { reject(new Error(`Server greeting failed: ${response.message}`)); } } }; const closeHandler = () => { if (resolved) return; cleanup(); reject(new Error('Socket closed while waiting for greeting')); }; const errorHandler = (err: Error) => { if (resolved) return; cleanup(); reject(err); }; timeoutHandler = setTimeout(() => { if (resolved) return; cleanup(); reject(new Error('Greeting timeout')); }, timeout); connection.socket.on('data', dataHandler); connection.socket.once('close', closeHandler); connection.socket.once('error', errorHandler); }); } private handleIncomingData(data: string): void { if (!this.pendingCommand) { return; } // Check buffer size to prevent memory exhaustion from rogue servers if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) { this.pendingCommand.reject(new Error('Response too large')); 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; } }