fix(mail): migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies

This commit is contained in:
2026-02-01 14:17:54 +00:00
parent dd4ac9fa3d
commit d54831765b
38 changed files with 3923 additions and 5335 deletions

View File

@@ -24,6 +24,9 @@ export class CommandHandler extends EventEmitter {
private responseBuffer: string = '';
private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null;
private commandTimeout: NodeJS.Timeout | null = null;
// Maximum buffer size to prevent memory exhaustion from rogue servers
private static readonly MAX_BUFFER_SIZE = 1024 * 1024; // 1MB max
constructor(options: ISmtpClientOptions) {
super();
@@ -144,63 +147,82 @@ export class CommandHandler extends EventEmitter {
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());
};
// Set up socket close/error handlers to reject pending promises
const closeHandler = () => {
if (this.pendingCommand) {
this.pendingCommand.reject(new Error('Socket closed during command'));
}
};
const errorHandler = (err: Error) => {
if (this.pendingCommand) {
this.pendingCommand.reject(err);
}
};
connection.socket.on('data', dataHandler);
// Clean up function
connection.socket.once('close', closeHandler);
connection.socket.once('error', errorHandler);
// Clean up function - removes all listeners and clears buffer
const cleanup = () => {
connection.socket.removeListener('data', dataHandler);
connection.socket.removeListener('close', closeHandler);
connection.socket.removeListener('error', errorHandler);
if (this.commandTimeout) {
clearTimeout(this.commandTimeout);
this.commandTimeout = null;
}
// Clear response buffer to prevent corrupted data for next command
this.responseBuffer = '';
};
// 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
// Override resolve/reject to include cleanup BEFORE setting timeout
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);
};
// Set command timeout - uses wrapped reject that includes cleanup
const timeout = 30000; // 30 seconds
this.commandTimeout = setTimeout(() => {
if (this.pendingCommand) {
this.pendingCommand.reject(new Error(`Command timeout: ${command}`));
}
}, timeout);
// 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) {
if (this.pendingCommand) {
this.pendingCommand.reject(error);
}
}
});
});
}
@@ -213,55 +235,74 @@ export class CommandHandler extends EventEmitter {
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());
};
// Set up socket close/error handlers to reject pending promises
const closeHandler = () => {
if (this.pendingCommand) {
this.pendingCommand.reject(new Error('Socket closed during data transmission'));
}
};
const errorHandler = (err: Error) => {
if (this.pendingCommand) {
this.pendingCommand.reject(err);
}
};
connection.socket.on('data', dataHandler);
// Clean up function
connection.socket.once('close', closeHandler);
connection.socket.once('error', errorHandler);
// Clean up function - removes all listeners and clears buffer
const cleanup = () => {
connection.socket.removeListener('data', dataHandler);
connection.socket.removeListener('close', closeHandler);
connection.socket.removeListener('error', errorHandler);
if (this.commandTimeout) {
clearTimeout(this.commandTimeout);
this.commandTimeout = null;
}
// Clear response buffer to prevent corrupted data for next command
this.responseBuffer = '';
};
// Override resolve/reject to include cleanup
// Override resolve/reject to include cleanup BEFORE setting timeout
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);
};
// Set data timeout - uses wrapped reject that includes cleanup
const timeout = 60000; // 60 seconds for data
this.commandTimeout = setTimeout(() => {
if (this.pendingCommand) {
this.pendingCommand.reject(new Error('Data transmission timeout'));
}
}, timeout);
// Send data
connection.socket.write(data, (error) => {
if (error) {
cleanup();
this.pendingCommand = null;
reject(error);
if (this.pendingCommand) {
this.pendingCommand.reject(error);
}
}
});
});
@@ -274,17 +315,34 @@ export class CommandHandler extends EventEmitter {
return new Promise((resolve, reject) => {
const timeout = 30000; // 30 seconds
let timeoutHandler: NodeJS.Timeout;
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutHandler);
connection.socket.removeListener('data', dataHandler);
connection.socket.removeListener('close', closeHandler);
connection.socket.removeListener('error', errorHandler);
this.responseBuffer = '';
};
const dataHandler = (data: Buffer) => {
if (resolved) return;
// Check buffer size
if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) {
cleanup();
reject(new Error('Greeting response too large'));
return;
}
this.responseBuffer += data.toString();
if (this.isCompleteResponse(this.responseBuffer)) {
clearTimeout(timeoutHandler);
connection.socket.removeListener('data', dataHandler);
const response = parseSmtpResponse(this.responseBuffer);
this.responseBuffer = '';
cleanup();
if (isSuccessCode(response.code)) {
resolve(response);
} else {
@@ -292,13 +350,28 @@ export class CommandHandler extends EventEmitter {
}
}
};
const closeHandler = () => {
if (resolved) return;
cleanup();
reject(new Error('Socket closed while waiting for greeting'));
};
const errorHandler = (err: Error) => {
if (resolved) return;
cleanup();
reject(err);
};
timeoutHandler = setTimeout(() => {
connection.socket.removeListener('data', dataHandler);
if (resolved) return;
cleanup();
reject(new Error('Greeting timeout'));
}, timeout);
connection.socket.on('data', dataHandler);
connection.socket.once('close', closeHandler);
connection.socket.once('error', errorHandler);
});
}
@@ -306,13 +379,19 @@ export class CommandHandler extends EventEmitter {
if (!this.pendingCommand) {
return;
}
// Check buffer size to prevent memory exhaustion from rogue servers
if (this.responseBuffer.length + data.length > CommandHandler.MAX_BUFFER_SIZE) {
this.pendingCommand.reject(new Error('Response too large'));
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 {