/** * 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; 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 { const command = `${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 { // Ensure email data ends with CRLF.CRLF let data = emailData; if (!data.endsWith(LINE_ENDINGS.CRLF)) { data += LINE_ENDINGS.CRLF; } data += '.' + LINE_ENDINGS.CRLF; // Perform dot stuffing (escape lines starting with a dot) data = data.replace(/\n\./g, '\n..'); 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 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 { 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 { 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 >= 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; } }