277 lines
21 KiB
JavaScript
277 lines
21 KiB
JavaScript
|
|
/**
|
||
|
|
* 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 { parseSmtpResponse, parseEhloResponse, formatCommand, isSuccessCode } from './utils/helpers.js';
|
||
|
|
import { logCommand, logDebug } from './utils/logging.js';
|
||
|
|
export class CommandHandler extends EventEmitter {
|
||
|
|
options;
|
||
|
|
responseBuffer = '';
|
||
|
|
pendingCommand = null;
|
||
|
|
commandTimeout = null;
|
||
|
|
constructor(options) {
|
||
|
|
super();
|
||
|
|
this.options = options;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send EHLO command and parse capabilities
|
||
|
|
*/
|
||
|
|
async sendEhlo(connection, domain) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async sendMailFrom(connection, fromAddress) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async sendRcptTo(connection, toAddress) {
|
||
|
|
const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`;
|
||
|
|
return this.sendCommand(connection, command);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send DATA command
|
||
|
|
*/
|
||
|
|
async sendData(connection) {
|
||
|
|
return this.sendCommand(connection, SMTP_COMMANDS.DATA);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send email data content
|
||
|
|
*/
|
||
|
|
async sendDataContent(connection, emailData) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async sendRset(connection) {
|
||
|
|
return this.sendCommand(connection, SMTP_COMMANDS.RSET);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send NOOP command
|
||
|
|
*/
|
||
|
|
async sendNoop(connection) {
|
||
|
|
return this.sendCommand(connection, SMTP_COMMANDS.NOOP);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send QUIT command
|
||
|
|
*/
|
||
|
|
async sendQuit(connection) {
|
||
|
|
return this.sendCommand(connection, SMTP_COMMANDS.QUIT);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send STARTTLS command
|
||
|
|
*/
|
||
|
|
async sendStartTls(connection) {
|
||
|
|
return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send AUTH command
|
||
|
|
*/
|
||
|
|
async sendAuth(connection, method, credentials) {
|
||
|
|
const command = credentials ?
|
||
|
|
`${SMTP_COMMANDS.AUTH} ${method} ${credentials}` :
|
||
|
|
`${SMTP_COMMANDS.AUTH} ${method}`;
|
||
|
|
return this.sendCommand(connection, command);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send a generic SMTP command
|
||
|
|
*/
|
||
|
|
async sendCommand(connection, command) {
|
||
|
|
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) => {
|
||
|
|
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) => {
|
||
|
|
cleanup();
|
||
|
|
this.pendingCommand = null;
|
||
|
|
logCommand(command, response, this.options);
|
||
|
|
originalResolve(response);
|
||
|
|
};
|
||
|
|
this.pendingCommand.reject = (error) => {
|
||
|
|
cleanup();
|
||
|
|
this.pendingCommand = null;
|
||
|
|
originalReject(error);
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Send raw data without command formatting
|
||
|
|
*/
|
||
|
|
async sendRawData(connection, data) {
|
||
|
|
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) => {
|
||
|
|
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) => {
|
||
|
|
cleanup();
|
||
|
|
this.pendingCommand = null;
|
||
|
|
originalResolve(response);
|
||
|
|
};
|
||
|
|
this.pendingCommand.reject = (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
|
||
|
|
*/
|
||
|
|
async waitForGreeting(connection) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timeout = 30000; // 30 seconds
|
||
|
|
let timeoutHandler;
|
||
|
|
const dataHandler = (data) => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
handleIncomingData(data) {
|
||
|
|
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}`));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
isCompleteResponse(buffer) {
|
||
|
|
// 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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29tbWFuZC1oYW5kbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vLi4vdHMvbWFpbC9kZWxpdmVyeS9zbXRwY2xpZW50L2NvbW1hbmQtaGFuZGxlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7O0dBR0c7QUFFSCxPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sYUFBYSxDQUFDO0FBQzNDLE9BQU8sRUFBRSxhQUFhLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBT3pFLE9BQU8sRUFDTCxpQkFBaUIsRUFDakIsaUJBQWlCLEVBQ2pCLGFBQWEsRUFDYixhQUFhLEVBQ2QsTUFBTSxvQkFBb0IsQ0FBQztBQUM1QixPQUFPLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRTFELE1BQU0sT0FBTyxjQUFlLFNBQVEsWUFBWTtJQUN0QyxPQUFPLENBQXFCO0lBQzVCLGNBQWMsR0FBVyxFQUFFLENBQUM7SUFDNUIsY0FBYyxHQUFvRSxJQUFJLENBQUM7SUFDdkYsY0FBYyxHQUEwQixJQUFJLENBQUM7SUFFckQsWUFBWSxPQUEyQjtRQUNyQyxLQUFLLEVBQUUsQ0FBQztRQUNSLElBQUksQ0FBQyxPQUFPLEdBQUcsT0FBTyxDQUFDO0lBQ3pCLENBQUM7SUFFRDs7T0FFRztJQUNJLEtBQUssQ0FBQyxRQUFRLENBQUMsVUFBMkIsRUFBRSxNQUFlO1FBQ2hFLE1BQU0sUUFBUSxHQUFHLE1BQU0sSUFBSSxJQUFJLENBQUMsT0FBTyxDQUFDLE1BQU0sSUFBSSxXQUFXLENBQUM7UUFDOUQsTUFBTSxPQUFPLEdBQUcsR0FBRyxhQUFhLENBQUMsSUFBSSxJQUFJLFFBQVEsRUFBRSxDQUFDO1FBRXBELE1BQU0sUUFBUSxHQUFHLE1BQU0sSUFBSSxDQUFDLFdBQVcsQ0FBQyxVQUFVLEVBQUUsT0FBTyxDQUFDLENBQUM7UUFFN0QsSUFBSSxDQUFDLGFBQWEsQ0FBQyxRQUFRLENB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
|