343 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			343 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * SMTP Client Command Handler
 | |
|  * SMTP command sending and response parsing
 | |
|  */
 | |
| 
 | |
| import * as plugins from '../../../plugins.ts';
 | |
| 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';
 | |
| 
 | |
| export class CommandHandler extends plugins.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<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;
 | |
|   }
 | |
| } |