/** * SMTP Command Handler * Responsible for parsing and handling SMTP commands */ import * as plugins from '../../../plugins.js'; import { SmtpState } from './interfaces.js'; import type { ISmtpSession, IEnvelopeRecipient } from './interfaces.js'; import type { ICommandHandler, ISessionManager, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js'; import { SmtpCommand, SmtpResponseCode, SMTP_DEFAULTS, SMTP_EXTENSIONS } from './constants.js'; import { SmtpLogger } from './utils/logging.js'; import { extractCommandName, extractCommandArgs, formatMultilineResponse } from './utils/helpers.js'; import { validateEhlo, validateMailFrom, validateRcptTo, isValidCommandSequence } from './utils/validation.js'; /** * Handles SMTP commands and responses */ export class CommandHandler implements ICommandHandler { /** * Session manager instance */ private sessionManager: ISessionManager; /** * Data handler instance (optional, injected when processing DATA command) */ private dataHandler?: IDataHandler; /** * TLS handler instance (optional, injected when processing STARTTLS command) */ private tlsHandler?: ITlsHandler; /** * Security handler instance (optional, used for IP reputation and authentication) */ private securityHandler?: ISecurityHandler; /** * SMTP server options */ private options: { hostname: string; size?: number; maxRecipients: number; auth?: { required: boolean; methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; }; }; /** * Creates a new command handler * @param sessionManager - Session manager instance * @param options - Command handler options * @param dataHandler - Optional data handler instance * @param tlsHandler - Optional TLS handler instance * @param securityHandler - Optional security handler instance */ constructor( sessionManager: ISessionManager, options: { hostname?: string; size?: number; maxRecipients?: number; auth?: { required: boolean; methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[]; }; } = {}, dataHandler?: IDataHandler, tlsHandler?: ITlsHandler, securityHandler?: ISecurityHandler ) { this.sessionManager = sessionManager; this.dataHandler = dataHandler; this.tlsHandler = tlsHandler; this.securityHandler = securityHandler; this.options = { hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME, size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE, maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS, auth: options.auth }; } /** * Process a command from the client * @param socket - Client socket * @param commandLine - Command line from client */ public processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { SmtpLogger.warn(`No session found for socket from ${socket.remoteAddress}`); this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); socket.end(); return; } // Handle data state differently - pass to data handler if (session.state === SmtpState.DATA_RECEIVING) { if (this.dataHandler) { // Let the data handler process the line this.dataHandler.processEmailData(socket, commandLine) .catch(error => { SmtpLogger.error(`Error processing email data: ${error.message}`, { sessionId: session.id, error }); this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email data: ${error.message}`); this.resetSession(session); }); } else { // No data handler available SmtpLogger.error('Data handler not available', { sessionId: session.id }); this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - data handler not available`); this.resetSession(session); } return; } // Log received command SmtpLogger.logCommand(commandLine, socket, session); // Extract command and arguments const command = extractCommandName(commandLine); const args = extractCommandArgs(commandLine); // Validate command sequence if (!this.validateCommandSequence(command, session)) { this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`); return; } // Process the command switch (command) { case SmtpCommand.EHLO: case SmtpCommand.HELO: this.handleEhlo(socket, args); break; case SmtpCommand.MAIL_FROM: this.handleMailFrom(socket, args); break; case SmtpCommand.RCPT_TO: this.handleRcptTo(socket, args); break; case SmtpCommand.DATA: this.handleData(socket); break; case SmtpCommand.RSET: this.handleRset(socket); break; case SmtpCommand.NOOP: this.handleNoop(socket); break; case SmtpCommand.QUIT: this.handleQuit(socket); break; case SmtpCommand.STARTTLS: if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) { this.tlsHandler.handleStartTls(socket); } else { this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} STARTTLS not available`); } break; case SmtpCommand.AUTH: this.handleAuth(socket, args); break; case SmtpCommand.HELP: this.handleHelp(socket, args); break; default: this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`); break; } } /** * Send a response to the client * @param socket - Client socket * @param response - Response to send */ public sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { try { socket.write(`${response}${SMTP_DEFAULTS.CRLF}`); SmtpLogger.logResponse(response, socket); } catch (error) { // Log error and destroy socket SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, { response, remoteAddress: socket.remoteAddress, remotePort: socket.remotePort, error: error instanceof Error ? error : new Error(String(error)) }); socket.destroy(); } } /** * Handle EHLO command * @param socket - Client socket * @param clientHostname - Client hostname from EHLO command */ public handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Validate EHLO hostname const validation = validateEhlo(clientHostname); if (!validation.isValid) { this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); return; } // Update session state and client hostname session.clientHostname = validation.hostname || clientHostname; this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO); // Set up EHLO response lines const responseLines = [ `${this.options.hostname} greets ${session.clientHostname}`, SMTP_EXTENSIONS.PIPELINING, SMTP_EXTENSIONS.formatExtension(SMTP_EXTENSIONS.SIZE, this.options.size), SMTP_EXTENSIONS.EIGHTBITMIME, SMTP_EXTENSIONS.ENHANCEDSTATUSCODES ]; // Add TLS extension if available and not already using TLS if (this.tlsHandler && this.tlsHandler.isTlsEnabled() && !session.useTLS) { responseLines.push(SMTP_EXTENSIONS.STARTTLS); } // Add AUTH extension if configured if (this.options.auth && this.options.auth.methods && this.options.auth.methods.length > 0) { responseLines.push(`${SMTP_EXTENSIONS.AUTH} ${this.options.auth.methods.join(' ')}`); } // Send multiline response this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.OK, responseLines)); } /** * Handle MAIL FROM command * @param socket - Client socket * @param args - Command arguments */ public handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Check if authentication is required but not provided if (this.options.auth && this.options.auth.required && !session.authenticated) { this.sendResponse(socket, `${SmtpResponseCode.AUTH_REQUIRED} Authentication required`); return; } // Validate MAIL FROM syntax const validation = validateMailFrom(args); if (!validation.isValid) { this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); return; } // Check size parameter if provided if (validation.params && validation.params.SIZE) { const size = parseInt(validation.params.SIZE, 10); if (isNaN(size)) { this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter`); return; } if (size > this.options.size!) { this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit`); return; } } // Reset email data and recipients for new transaction session.mailFrom = validation.address || ''; session.rcptTo = []; session.emailData = ''; session.emailDataChunks = []; // Update envelope information session.envelope = { mailFrom: { address: validation.address || '', args: validation.params || {} }, rcptTo: [] }; // Update session state this.sessionManager.updateSessionState(session, SmtpState.MAIL_FROM); // Send success response this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); } /** * Handle RCPT TO command * @param socket - Client socket * @param args - Command arguments */ public handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Validate RCPT TO syntax const validation = validateRcptTo(args); if (!validation.isValid) { this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`); return; } // Check if we've reached maximum recipients if (session.rcptTo.length >= this.options.maxRecipients) { this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Too many recipients`); return; } // Create recipient object const recipient: IEnvelopeRecipient = { address: validation.address || '', args: validation.params || {} }; // Add to session data session.rcptTo.push(validation.address || ''); session.envelope.rcptTo.push(recipient); // Update session state this.sessionManager.updateSessionState(session, SmtpState.RCPT_TO); // Send success response this.sendResponse(socket, `${SmtpResponseCode.OK} Recipient ok`); } /** * Handle DATA command * @param socket - Client socket */ public handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Check if we have recipients if (!session.rcptTo.length) { this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} No recipients specified`); return; } // Update session state this.sessionManager.updateSessionState(session, SmtpState.DATA_RECEIVING); // Reset email data storage session.emailData = ''; session.emailDataChunks = []; // Set up timeout for DATA command const dataTimeout = SMTP_DEFAULTS.DATA_TIMEOUT; if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); } session.dataTimeoutId = setTimeout(() => { if (session.state === SmtpState.DATA_RECEIVING) { SmtpLogger.warn(`DATA command timeout for session ${session.id}`, { sessionId: session.id, timeout: dataTimeout }); this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`); this.resetSession(session); } }, dataTimeout); // Send intermediate response to signal start of data this.sendResponse(socket, `${SmtpResponseCode.START_MAIL_INPUT} Start mail input; end with .`); } /** * Handle RSET command * @param socket - Client socket */ public handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Reset the transaction state this.resetSession(session); // Send success response this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); } /** * Handle NOOP command * @param socket - Client socket */ public handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Update session activity timestamp this.sessionManager.updateSessionActivity(session); // Send success response this.sendResponse(socket, `${SmtpResponseCode.OK} OK`); } /** * Handle QUIT command * @param socket - Client socket */ public handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); // Send goodbye message this.sendResponse(socket, `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`); // End the connection socket.end(); // Clean up session if we have one if (session) { this.sessionManager.removeSession(socket); } } /** * Handle AUTH command * @param socket - Client socket * @param args - Command arguments */ private handleAuth(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Check if we have auth config if (!this.options.auth || !this.options.auth.methods || !this.options.auth.methods.length) { this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Authentication not supported`); return; } // Check if TLS is required for authentication if (!session.useTLS) { this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication requires TLS`); return; } // Simple response for now - authentication would be implemented in the security handler this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication not implemented yet`); } /** * Handle HELP command * @param socket - Client socket * @param args - Command arguments */ private handleHelp(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { // Get the session for this socket const session = this.sessionManager.getSession(socket); if (!session) { this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`); return; } // Update session activity timestamp this.sessionManager.updateSessionActivity(session); // Provide help information based on arguments const helpCommand = args.trim().toUpperCase(); if (!helpCommand) { // General help const helpLines = [ 'Supported commands:', 'EHLO/HELO domain - Identify yourself to the server', 'MAIL FROM:
- Start a new mail transaction', 'RCPT TO:
- Specify recipients for the message', 'DATA - Start message data input', 'RSET - Reset the transaction', 'NOOP - No operation', 'QUIT - Close the connection', 'HELP [command] - Show help' ]; // Add conditional commands if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) { helpLines.push('STARTTLS - Start TLS negotiation'); } if (this.options.auth && this.options.auth.methods.length) { helpLines.push('AUTH mechanism - Authenticate with the server'); } this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.HELP_MESSAGE, helpLines)); return; } // Command-specific help let helpText: string; switch (helpCommand) { case 'EHLO': case 'HELO': helpText = 'EHLO/HELO domain - Identify yourself to the server'; break; case 'MAIL': helpText = 'MAIL FROM:
[SIZE=size] - Start a new mail transaction'; break; case 'RCPT': helpText = 'RCPT TO:
- Specify a recipient for the message'; break; case 'DATA': helpText = 'DATA - Start message data input, end with .'; break; case 'RSET': helpText = 'RSET - Reset the transaction'; break; case 'NOOP': helpText = 'NOOP - No operation'; break; case 'QUIT': helpText = 'QUIT - Close the connection'; break; case 'STARTTLS': helpText = 'STARTTLS - Start TLS negotiation'; break; case 'AUTH': helpText = `AUTH mechanism - Authenticate with the server. Supported methods: ${this.options.auth?.methods.join(', ')}`; break; default: helpText = `Unknown command: ${helpCommand}`; break; } this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`); } /** * Reset session to after-EHLO state * @param session - SMTP session to reset */ private resetSession(session: ISmtpSession): void { // Clear any data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } // Reset data fields but keep authentication state session.mailFrom = ''; session.rcptTo = []; session.emailData = ''; session.emailDataChunks = []; session.envelope = { mailFrom: { address: '', args: {} }, rcptTo: [] }; // Reset state to after EHLO this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO); } /** * Validate command sequence based on current state * @param command - Command to validate * @param session - Current session * @returns Whether the command is valid in the current state */ private validateCommandSequence(command: string, session: ISmtpSession): boolean { return isValidCommandSequence(command, session.state); } }