update
This commit is contained in:
336
ts/mail/delivery/smtpclient/command-handler.ts
Normal file
336
ts/mail/delivery/smtpclient/command-handler.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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<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> {
|
||||
const command = `${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> {
|
||||
// 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<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 >= 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user