update
This commit is contained in:
@@ -407,8 +407,8 @@ export class SmtpClient {
|
|||||||
// Clear previous extensions
|
// Clear previous extensions
|
||||||
this.supportedExtensions.clear();
|
this.supportedExtensions.clear();
|
||||||
|
|
||||||
// Send EHLO
|
// Send EHLO - don't allow pipelining for this command
|
||||||
const response = await this.sendCommand(`EHLO ${this.options.domain}`);
|
const response = await this.sendCommand(`EHLO ${this.options.domain}`, false);
|
||||||
|
|
||||||
// Parse supported extensions
|
// Parse supported extensions
|
||||||
const lines = response.split('\r\n');
|
const lines = response.split('\r\n');
|
||||||
@@ -420,7 +420,13 @@ export class SmtpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if server supports pipelining
|
||||||
|
this.supportsPipelining = this.supportedExtensions.has('PIPELINING');
|
||||||
|
|
||||||
logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`);
|
logger.log('debug', `Server supports extensions: ${Array.from(this.supportedExtensions).join(', ')}`);
|
||||||
|
if (this.supportsPipelining) {
|
||||||
|
logger.log('info', 'Server supports PIPELINING - will use for improved performance');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -661,20 +667,75 @@ export class SmtpClient {
|
|||||||
result.dkimSigned = true;
|
result.dkimSigned = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send MAIL FROM
|
// Get envelope and recipients
|
||||||
const envelope_from = email.getEnvelopeFrom() || email.from;
|
const envelope_from = email.getEnvelopeFrom() || email.from;
|
||||||
await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`);
|
|
||||||
|
|
||||||
// Send RCPT TO for each recipient
|
|
||||||
const recipients = email.getAllRecipients();
|
const recipients = email.getAllRecipients();
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
// Check if we can use pipelining for MAIL FROM and RCPT TO commands
|
||||||
|
if (this.supportsPipelining && recipients.length > 0) {
|
||||||
|
logger.log('debug', 'Using SMTP pipelining for sending');
|
||||||
|
|
||||||
|
// Send MAIL FROM command first (always needed)
|
||||||
|
const mailFromCmd = `MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`;
|
||||||
|
let mailFromResponse: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.sendCommand(`RCPT TO:<${recipient}>`);
|
mailFromResponse = await this.sendCommand(mailFromCmd);
|
||||||
result.acceptedRecipients.push(recipient);
|
|
||||||
|
if (!mailFromResponse.startsWith('250')) {
|
||||||
|
throw new MtaDeliveryError(
|
||||||
|
`MAIL FROM command failed: ${mailFromResponse}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
command: mailFromCmd,
|
||||||
|
response: mailFromResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`);
|
logger.log('error', `MAIL FROM failed: ${error.message}`);
|
||||||
result.rejectedRecipients.push(recipient);
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline all RCPT TO commands
|
||||||
|
const rcptPromises = recipients.map(recipient => {
|
||||||
|
return this.sendCommand(`RCPT TO:<${recipient}>`)
|
||||||
|
.then(response => {
|
||||||
|
if (response.startsWith('250')) {
|
||||||
|
result.acceptedRecipients.push(recipient);
|
||||||
|
return { recipient, accepted: true, response };
|
||||||
|
} else {
|
||||||
|
result.rejectedRecipients.push(recipient);
|
||||||
|
logger.log('warn', `Recipient ${recipient} rejected: ${response}`);
|
||||||
|
return { recipient, accepted: false, response };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
result.rejectedRecipients.push(recipient);
|
||||||
|
logger.log('warn', `Recipient ${recipient} rejected with error: ${error.message}`);
|
||||||
|
return { recipient, accepted: false, error: error.message };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all RCPT TO commands to complete
|
||||||
|
await Promise.all(rcptPromises);
|
||||||
|
} else {
|
||||||
|
// Fall back to sequential commands if pipelining not supported
|
||||||
|
logger.log('debug', 'Using sequential SMTP commands for sending');
|
||||||
|
|
||||||
|
// Send MAIL FROM
|
||||||
|
await this.sendCommand(`MAIL FROM:<${envelope_from}> SIZE=${this.getEmailSize(email)}`);
|
||||||
|
|
||||||
|
// Send RCPT TO for each recipient
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
try {
|
||||||
|
await this.sendCommand(`RCPT TO:<${recipient}>`);
|
||||||
|
result.acceptedRecipients.push(recipient);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('warn', `Recipient ${recipient} rejected: ${error.message}`);
|
||||||
|
result.rejectedRecipients.push(recipient);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +766,7 @@ export class SmtpClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format email content (simplified for now)
|
// Format email content efficiently
|
||||||
const emailContent = await this.getFormattedEmail(email);
|
const emailContent = await this.getFormattedEmail(email);
|
||||||
|
|
||||||
// Send email content
|
// Send email content
|
||||||
@@ -923,7 +984,26 @@ export class SmtpClient {
|
|||||||
* Send SMTP command and wait for response
|
* Send SMTP command and wait for response
|
||||||
* @param command SMTP command to send
|
* @param command SMTP command to send
|
||||||
*/
|
*/
|
||||||
private async sendCommand(command: string): Promise<string> {
|
// Queue for command pipelining
|
||||||
|
private commandQueue: Array<{
|
||||||
|
command: string;
|
||||||
|
resolve: (response: string) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Flag to indicate if we're currently processing commands
|
||||||
|
private processingCommands = false;
|
||||||
|
|
||||||
|
// Flag to indicate if server supports pipelining
|
||||||
|
private supportsPipelining = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an SMTP command and wait for response
|
||||||
|
* @param command SMTP command to send
|
||||||
|
* @param allowPipelining Whether this command can be pipelined
|
||||||
|
*/
|
||||||
|
private async sendCommand(command: string, allowPipelining = true): Promise<string> {
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
throw new MtaConnectionError(
|
throw new MtaConnectionError(
|
||||||
'Not connected to server',
|
'Not connected to server',
|
||||||
@@ -946,6 +1026,12 @@ export class SmtpClient {
|
|||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
// Set up timeout for command
|
// Set up timeout for command
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
// Remove this command from the queue if it times out
|
||||||
|
const index = this.commandQueue.findIndex(item => item.command === command);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.commandQueue.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
reject(MtaTimeoutError.commandTimeout(
|
reject(MtaTimeoutError.commandTimeout(
|
||||||
command.split(' ')[0],
|
command.split(' ')[0],
|
||||||
this.options.host,
|
this.options.host,
|
||||||
@@ -953,35 +1039,168 @@ export class SmtpClient {
|
|||||||
));
|
));
|
||||||
}, this.options.commandTimeout);
|
}, this.options.commandTimeout);
|
||||||
|
|
||||||
// Send command
|
// Add command to the queue
|
||||||
this.socket.write(command + '\r\n', (err) => {
|
this.commandQueue.push({
|
||||||
if (err) {
|
command,
|
||||||
clearTimeout(timeout);
|
resolve,
|
||||||
reject(new MtaConnectionError(
|
reject,
|
||||||
`Failed to send command: ${err.message}`,
|
timeout
|
||||||
{
|
|
||||||
data: {
|
|
||||||
command: command.split(' ')[0],
|
|
||||||
error: err.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process command queue if we can pipeline or if not currently processing commands
|
||||||
|
if ((this.supportsPipelining && allowPipelining) || !this.processingCommands) {
|
||||||
|
this.processCommandQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the command queue - either one by one or pipelined if supported
|
||||||
|
*/
|
||||||
|
private processCommandQueue(): void {
|
||||||
|
if (this.processingCommands || this.commandQueue.length === 0 || !this.socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingCommands = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If pipelining is supported, send all commands at once
|
||||||
|
if (this.supportsPipelining) {
|
||||||
|
// Send all commands in queue at once
|
||||||
|
const commands = this.commandQueue.map(item => item.command).join('\r\n') + '\r\n';
|
||||||
|
|
||||||
|
this.socket.write(commands, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// Handle write error for all commands
|
||||||
|
const error = new MtaConnectionError(
|
||||||
|
`Failed to send commands: ${err.message}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fail all pending commands
|
||||||
|
while (this.commandQueue.length > 0) {
|
||||||
|
const item = this.commandQueue.shift();
|
||||||
|
clearTimeout(item.timeout);
|
||||||
|
item.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingCommands = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process responses one by one in order
|
||||||
|
this.processResponses();
|
||||||
|
} else {
|
||||||
|
// Process commands one by one if pipelining not supported
|
||||||
|
this.processNextCommand();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error processing command queue: ${error.message}`);
|
||||||
|
this.processingCommands = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the next command in the queue (non-pipelined mode)
|
||||||
|
*/
|
||||||
|
private processNextCommand(): void {
|
||||||
|
if (this.commandQueue.length === 0 || !this.socket) {
|
||||||
|
this.processingCommands = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCommand = this.commandQueue[0];
|
||||||
|
|
||||||
|
this.socket.write(currentCommand.command + '\r\n', (err) => {
|
||||||
|
if (err) {
|
||||||
|
// Handle write error
|
||||||
|
const error = new MtaConnectionError(
|
||||||
|
`Failed to send command: ${err.message}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
command: currentCommand.command.split(' ')[0],
|
||||||
|
error: err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
this.commandQueue.shift();
|
||||||
|
clearTimeout(currentCommand.timeout);
|
||||||
|
currentCommand.reject(error);
|
||||||
|
|
||||||
|
// Continue with next command
|
||||||
|
this.processNextCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Read response
|
// Read response
|
||||||
this.readResponse()
|
this.readResponse()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
clearTimeout(timeout);
|
// Remove from queue and resolve
|
||||||
resolve(response);
|
this.commandQueue.shift();
|
||||||
|
clearTimeout(currentCommand.timeout);
|
||||||
|
currentCommand.resolve(response);
|
||||||
|
|
||||||
|
// Process next command
|
||||||
|
this.processNextCommand();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
clearTimeout(timeout);
|
// Remove from queue and reject
|
||||||
reject(err);
|
this.commandQueue.shift();
|
||||||
|
clearTimeout(currentCommand.timeout);
|
||||||
|
currentCommand.reject(err);
|
||||||
|
|
||||||
|
// Process next command
|
||||||
|
this.processNextCommand();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process responses for pipelined commands
|
||||||
|
*/
|
||||||
|
private async processResponses(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Process responses for each command in order
|
||||||
|
while (this.commandQueue.length > 0) {
|
||||||
|
const currentCommand = this.commandQueue[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for response
|
||||||
|
const response = await this.readResponse();
|
||||||
|
|
||||||
|
// Remove from queue and resolve
|
||||||
|
this.commandQueue.shift();
|
||||||
|
clearTimeout(currentCommand.timeout);
|
||||||
|
currentCommand.resolve(response);
|
||||||
|
} catch (error) {
|
||||||
|
// Remove from queue and reject
|
||||||
|
this.commandQueue.shift();
|
||||||
|
clearTimeout(currentCommand.timeout);
|
||||||
|
currentCommand.reject(error);
|
||||||
|
|
||||||
|
// Stop processing if this is a critical error
|
||||||
|
if (
|
||||||
|
error instanceof MtaConnectionError &&
|
||||||
|
(error.message.includes('Connection closed') || error.message.includes('Not connected'))
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error processing responses: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
this.processingCommands = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read response from the server
|
* Read response from the server
|
||||||
*/
|
*/
|
||||||
@@ -999,37 +1218,45 @@ export class SmtpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
let responseData = '';
|
// Use an array to collect response chunks instead of string concatenation
|
||||||
|
const responseChunks: Buffer[] = [];
|
||||||
|
|
||||||
|
// Single function to clean up all listeners
|
||||||
|
const cleanupListeners = () => {
|
||||||
|
if (!this.socket) return;
|
||||||
|
this.socket.removeListener('data', onData);
|
||||||
|
this.socket.removeListener('error', onError);
|
||||||
|
this.socket.removeListener('close', onClose);
|
||||||
|
this.socket.removeListener('end', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
responseData += data.toString();
|
// Store buffer directly, avoiding unnecessary string conversion
|
||||||
|
responseChunks.push(data);
|
||||||
|
|
||||||
|
// Convert to string only for response checking
|
||||||
|
const responseData = Buffer.concat(responseChunks).toString();
|
||||||
|
|
||||||
// Check if this is a complete response
|
// Check if this is a complete response
|
||||||
if (this.isCompleteResponse(responseData)) {
|
if (this.isCompleteResponse(responseData)) {
|
||||||
// Clean up listeners
|
// Clean up listeners
|
||||||
this.socket.removeListener('data', onData);
|
cleanupListeners();
|
||||||
this.socket.removeListener('error', onError);
|
|
||||||
this.socket.removeListener('close', onClose);
|
|
||||||
this.socket.removeListener('end', onEnd);
|
|
||||||
|
|
||||||
logger.log('debug', `< ${responseData.trim()}`);
|
const trimmedResponse = responseData.trim();
|
||||||
|
logger.log('debug', `< ${trimmedResponse}`);
|
||||||
|
|
||||||
// Check if this is an error response
|
// Check if this is an error response
|
||||||
if (this.isErrorResponse(responseData)) {
|
if (this.isErrorResponse(responseData)) {
|
||||||
const code = responseData.substring(0, 3);
|
const code = responseData.substring(0, 3);
|
||||||
reject(this.createErrorFromResponse(responseData, code));
|
reject(this.createErrorFromResponse(trimmedResponse, code));
|
||||||
} else {
|
} else {
|
||||||
resolve(responseData.trim());
|
resolve(trimmedResponse);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (err: Error) => {
|
const onError = (err: Error) => {
|
||||||
// Clean up listeners
|
cleanupListeners();
|
||||||
this.socket.removeListener('data', onData);
|
|
||||||
this.socket.removeListener('error', onError);
|
|
||||||
this.socket.removeListener('close', onClose);
|
|
||||||
this.socket.removeListener('end', onEnd);
|
|
||||||
|
|
||||||
reject(new MtaConnectionError(
|
reject(new MtaConnectionError(
|
||||||
`Socket error while waiting for response: ${err.message}`,
|
`Socket error while waiting for response: ${err.message}`,
|
||||||
@@ -1042,12 +1269,9 @@ export class SmtpClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
// Clean up listeners
|
cleanupListeners();
|
||||||
this.socket.removeListener('data', onData);
|
|
||||||
this.socket.removeListener('error', onError);
|
|
||||||
this.socket.removeListener('close', onClose);
|
|
||||||
this.socket.removeListener('end', onEnd);
|
|
||||||
|
|
||||||
|
const responseData = Buffer.concat(responseChunks).toString();
|
||||||
reject(new MtaConnectionError(
|
reject(new MtaConnectionError(
|
||||||
'Connection closed while waiting for response',
|
'Connection closed while waiting for response',
|
||||||
{
|
{
|
||||||
@@ -1059,12 +1283,9 @@ export class SmtpClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onEnd = () => {
|
const onEnd = () => {
|
||||||
// Clean up listeners
|
cleanupListeners();
|
||||||
this.socket.removeListener('data', onData);
|
|
||||||
this.socket.removeListener('error', onError);
|
|
||||||
this.socket.removeListener('close', onClose);
|
|
||||||
this.socket.removeListener('end', onEnd);
|
|
||||||
|
|
||||||
|
const responseData = Buffer.concat(responseChunks).toString();
|
||||||
reject(new MtaConnectionError(
|
reject(new MtaConnectionError(
|
||||||
'Connection ended while waiting for response',
|
'Connection ended while waiting for response',
|
||||||
{
|
{
|
||||||
|
@@ -23,7 +23,11 @@ export class SMTPServer {
|
|||||||
private smtpServerOptions: ISmtpServerOptions;
|
private smtpServerOptions: ISmtpServerOptions;
|
||||||
private server: plugins.net.Server;
|
private server: plugins.net.Server;
|
||||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, ISmtpSession>;
|
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, ISmtpSession>;
|
||||||
|
private sessionTimeouts: Map<string, NodeJS.Timeout>;
|
||||||
private hostname: string;
|
private hostname: string;
|
||||||
|
private sessionIdCounter: number = 0;
|
||||||
|
private connectionCount: number = 0;
|
||||||
|
private maxConnections: number = 100; // Default max connections
|
||||||
|
|
||||||
constructor(emailServerRefArg: UnifiedEmailServer, optionsArg: ISmtpServerOptions) {
|
constructor(emailServerRefArg: UnifiedEmailServer, optionsArg: ISmtpServerOptions) {
|
||||||
console.log('SMTPServer instance is being created...');
|
console.log('SMTPServer instance is being created...');
|
||||||
@@ -31,21 +35,113 @@ export class SMTPServer {
|
|||||||
this.emailServerRef = emailServerRefArg;
|
this.emailServerRef = emailServerRefArg;
|
||||||
this.smtpServerOptions = optionsArg;
|
this.smtpServerOptions = optionsArg;
|
||||||
this.sessions = new Map();
|
this.sessions = new Map();
|
||||||
|
this.sessionTimeouts = new Map();
|
||||||
this.hostname = optionsArg.hostname || 'mail.lossless.one';
|
this.hostname = optionsArg.hostname || 'mail.lossless.one';
|
||||||
|
this.maxConnections = optionsArg.maxSize || 100;
|
||||||
|
|
||||||
|
// Start session cleanup interval
|
||||||
|
setInterval(() => this.cleanupIdleSessions(), 30000); // Run every 30 seconds
|
||||||
|
|
||||||
this.server = plugins.net.createServer((socket) => {
|
this.server = plugins.net.createServer((socket) => {
|
||||||
|
// Check if we've exceeded maximum connections
|
||||||
|
if (this.connectionCount >= this.maxConnections) {
|
||||||
|
logger.log('warn', `Connection limit reached (${this.maxConnections}), rejecting new connection`);
|
||||||
|
socket.write('421 Too many connections, try again later\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.handleNewConnection(socket);
|
this.handleNewConnection(socket);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up idle sessions
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private cleanupIdleSessions(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionTimeout = this.smtpServerOptions.socketTimeout || 300000; // Default 5 minutes
|
||||||
|
|
||||||
|
// Check all sessions for timeout
|
||||||
|
for (const [socket, session] of this.sessions.entries()) {
|
||||||
|
if (!session.lastActivity) continue;
|
||||||
|
|
||||||
|
const idleTime = now - session.lastActivity;
|
||||||
|
if (idleTime > sessionTimeout) {
|
||||||
|
logger.log('info', `Session ${session.id} timed out after ${idleTime}ms of inactivity`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send timeout message and end connection
|
||||||
|
this.sendResponse(socket, '421 Timeout - closing connection');
|
||||||
|
socket.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error closing timed out session: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up session
|
||||||
|
this.removeSession(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session ID
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private generateSessionId(): string {
|
||||||
|
return `${Date.now()}-${++this.sessionIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properly remove a session and clean up resources
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||||
|
const session = this.sessions.get(socket);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
// Clear session timeout if exists
|
||||||
|
const timeoutId = this.sessionTimeouts.get(session.id);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.sessionTimeouts.delete(session.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove session from map
|
||||||
|
this.sessions.delete(socket);
|
||||||
|
|
||||||
|
// Decrement connection count
|
||||||
|
this.connectionCount--;
|
||||||
|
|
||||||
|
logger.log('debug', `Session ${session.id} removed, active connections: ${this.connectionCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last activity timestamp for a session
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private updateSessionActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||||
|
const session = this.sessions.get(socket);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
session.lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
private async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
|
private async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
|
||||||
const clientIp = socket.remoteAddress;
|
const clientIp = socket.remoteAddress;
|
||||||
const clientPort = socket.remotePort;
|
const clientPort = socket.remotePort;
|
||||||
console.log(`New connection from ${clientIp}:${clientPort}`);
|
console.log(`New connection from ${clientIp}:${clientPort}`);
|
||||||
|
|
||||||
|
// Increment connection count
|
||||||
|
this.connectionCount++;
|
||||||
|
|
||||||
|
// Generate unique session ID
|
||||||
|
const sessionId = this.generateSessionId();
|
||||||
|
|
||||||
// Initialize a new session
|
// Initialize a new session
|
||||||
this.sessions.set(socket, {
|
this.sessions.set(socket, {
|
||||||
id: `${socket.remoteAddress}:${socket.remotePort}`,
|
id: sessionId,
|
||||||
state: SmtpState.GREETING,
|
state: SmtpState.GREETING,
|
||||||
clientHostname: '',
|
clientHostname: '',
|
||||||
mailFrom: '',
|
mailFrom: '',
|
||||||
@@ -56,6 +152,7 @@ export class SMTPServer {
|
|||||||
remoteAddress: socket.remoteAddress || '',
|
remoteAddress: socket.remoteAddress || '',
|
||||||
secure: false,
|
secure: false,
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
lastActivity: Date.now(),
|
||||||
envelope: {
|
envelope: {
|
||||||
mailFrom: {
|
mailFrom: {
|
||||||
address: '',
|
address: '',
|
||||||
@@ -129,7 +226,29 @@ export class SMTPServer {
|
|||||||
// Send greeting
|
// Send greeting
|
||||||
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
||||||
|
|
||||||
|
// Set session timeout
|
||||||
|
const sessionTimeout = setTimeout(() => {
|
||||||
|
logger.log('info', `Initial connection timeout for session ${sessionId}`);
|
||||||
|
this.sendResponse(socket, '421 Connection timeout');
|
||||||
|
socket.destroy();
|
||||||
|
this.removeSession(socket);
|
||||||
|
}, this.smtpServerOptions.connectionTimeout || 30000);
|
||||||
|
|
||||||
|
// Store timeout reference
|
||||||
|
this.sessionTimeouts.set(sessionId, sessionTimeout);
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
socket.on('data', (data) => {
|
||||||
|
// Clear initial connection timeout on first data
|
||||||
|
const timeoutId = this.sessionTimeouts.get(sessionId);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.sessionTimeouts.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
this.updateSessionActivity(socket);
|
||||||
|
|
||||||
|
// Process the data
|
||||||
this.processData(socket, data);
|
this.processData(socket, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,16 +270,21 @@ export class SMTPServer {
|
|||||||
details: {
|
details: {
|
||||||
clientPort,
|
clientPort,
|
||||||
state: SmtpState[session.state],
|
state: SmtpState[session.state],
|
||||||
from: session.mailFrom || 'not set'
|
from: session.mailFrom || 'not set',
|
||||||
|
sessionId: session.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up session
|
||||||
|
this.removeSession(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
socket.on('error', (err) => {
|
||||||
const clientIp = socket.remoteAddress;
|
const clientIp = socket.remoteAddress;
|
||||||
const clientPort = socket.remotePort;
|
const clientPort = socket.remotePort;
|
||||||
console.error(`Socket error: ${err.message}`);
|
const session = this.sessions.get(socket);
|
||||||
|
console.error(`Socket error for session ${session?.id}: ${err.message}`);
|
||||||
|
|
||||||
// Log connection error as security event
|
// Log connection error as security event
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
@@ -172,18 +296,21 @@ export class SMTPServer {
|
|||||||
clientPort,
|
clientPort,
|
||||||
error: err.message,
|
error: err.message,
|
||||||
errorCode: (err as any).code,
|
errorCode: (err as any).code,
|
||||||
from: this.sessions.get(socket)?.mailFrom || 'not set'
|
from: session?.mailFrom || 'not set',
|
||||||
|
sessionId: session?.id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.delete(socket);
|
// Clean up session resources
|
||||||
|
this.removeSession(socket);
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
const clientIp = socket.remoteAddress;
|
const clientIp = socket.remoteAddress;
|
||||||
const clientPort = socket.remotePort;
|
const clientPort = socket.remotePort;
|
||||||
console.log(`Connection closed from ${clientIp}:${clientPort}`);
|
const session = this.sessions.get(socket);
|
||||||
|
console.log(`Connection closed for session ${session?.id} from ${clientIp}:${clientPort}`);
|
||||||
|
|
||||||
// Log connection closure as security event
|
// Log connection closure as security event
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
@@ -193,11 +320,13 @@ export class SMTPServer {
|
|||||||
ipAddress: clientIp,
|
ipAddress: clientIp,
|
||||||
details: {
|
details: {
|
||||||
clientPort,
|
clientPort,
|
||||||
sessionEnded: this.sessions.get(socket)?.connectionEnded || false
|
sessionId: session?.id,
|
||||||
|
sessionEnded: session?.connectionEnded || false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sessions.delete(socket);
|
// Clean up session resources
|
||||||
|
this.removeSession(socket);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +369,9 @@ export class SMTPServer {
|
|||||||
const session = this.sessions.get(socket);
|
const session = this.sessions.get(socket);
|
||||||
if (!session || session.connectionEnded) return;
|
if (!session || session.connectionEnded) return;
|
||||||
|
|
||||||
|
// Update session activity timestamp
|
||||||
|
this.updateSessionActivity(socket);
|
||||||
|
|
||||||
const [command, ...args] = commandLine.split(' ');
|
const [command, ...args] = commandLine.split(' ');
|
||||||
const upperCommand = command.toUpperCase();
|
const upperCommand = command.toUpperCase();
|
||||||
|
|
||||||
@@ -431,19 +563,32 @@ export class SMTPServer {
|
|||||||
const session = this.sessions.get(socket);
|
const session = this.sessions.get(socket);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
|
||||||
|
// Initialize email data buffer if it doesn't exist
|
||||||
|
if (!session.emailDataChunks) {
|
||||||
|
session.emailDataChunks = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Check for end of data marker
|
// Check for end of data marker
|
||||||
if (data.endsWith('\r\n.\r\n')) {
|
if (data.endsWith('\r\n.\r\n')) {
|
||||||
// Remove the end of data marker
|
// Remove the end of data marker
|
||||||
const emailData = data.slice(0, -5);
|
const emailData = data.slice(0, -5);
|
||||||
session.emailData += emailData;
|
|
||||||
|
// Add final chunk
|
||||||
|
session.emailDataChunks.push(emailData);
|
||||||
|
|
||||||
|
// Join chunks efficiently
|
||||||
|
session.emailData = session.emailDataChunks.join('');
|
||||||
|
|
||||||
|
// Free memory
|
||||||
|
session.emailDataChunks = undefined;
|
||||||
session.state = SmtpState.FINISHED;
|
session.state = SmtpState.FINISHED;
|
||||||
|
|
||||||
// Save and process the email
|
// Save and process the email
|
||||||
this.saveEmail(socket);
|
this.saveEmail(socket);
|
||||||
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
||||||
} else {
|
} else {
|
||||||
// Accumulate the data
|
// Accumulate the data as chunks
|
||||||
session.emailData += data;
|
session.emailDataChunks.push(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +946,4 @@ export class SMTPServer {
|
|||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// These methods are defined elsewhere in the class, duplicates removed
|
|
||||||
}
|
}
|
@@ -96,6 +96,11 @@ export interface ISmtpSession {
|
|||||||
*/
|
*/
|
||||||
emailData: string;
|
emailData: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunks of email data for more efficient buffer management
|
||||||
|
*/
|
||||||
|
emailDataChunks?: string[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the connection is using TLS
|
* Whether the connection is using TLS
|
||||||
*/
|
*/
|
||||||
@@ -130,6 +135,11 @@ export interface ISmtpSession {
|
|||||||
* Email processing mode to use for this session
|
* Email processing mode to use for this session
|
||||||
*/
|
*/
|
||||||
processingMode?: EmailProcessingMode;
|
processingMode?: EmailProcessingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp of last activity for session timeout tracking
|
||||||
|
*/
|
||||||
|
lastActivity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Reference in New Issue
Block a user