dcrouter/ts/mail/delivery/smtp/connection-manager.ts

363 lines
11 KiB
TypeScript
Raw Normal View History

2025-05-21 12:52:24 +00:00
/**
* SMTP Connection Manager
* Responsible for managing socket connections to the SMTP server
*/
import * as plugins from '../../../plugins.js';
import { IConnectionManager } from './interfaces.js';
import { ISessionManager } from './interfaces.js';
import { SmtpResponseCode, SMTP_DEFAULTS } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { getSocketDetails, formatMultilineResponse } from './utils/helpers.js';
/**
* Manager for SMTP connections
* Handles connection setup, event listeners, and lifecycle management
*/
export class ConnectionManager implements IConnectionManager {
/**
* Set of active socket connections
*/
private activeConnections: Set<plugins.net.Socket | plugins.tls.TLSSocket> = new Set();
/**
* Reference to the session manager
*/
private sessionManager: ISessionManager;
/**
* SMTP server options
*/
private options: {
hostname: string;
maxConnections: number;
socketTimeout: number;
};
/**
* Command handler function
*/
private commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void;
/**
* Creates a new connection manager
* @param sessionManager - Session manager instance
* @param commandHandler - Command handler function
* @param options - Connection manager options
*/
constructor(
sessionManager: ISessionManager,
commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void,
options: {
hostname?: string;
maxConnections?: number;
socketTimeout?: number;
} = {}
) {
this.sessionManager = sessionManager;
this.commandHandler = commandHandler;
this.options = {
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT
};
}
/**
* Handle a new connection
* @param socket - Client socket
*/
public handleNewConnection(socket: plugins.net.Socket): void {
// Check if maximum connections reached
if (this.hasReachedMaxConnections()) {
this.rejectConnection(socket, 'Too many connections');
return;
}
// Add socket to active connections
this.activeConnections.add(socket);
// Set up socket options
socket.setKeepAlive(true);
socket.setTimeout(this.options.socketTimeout);
// Set up event handlers
this.setupSocketEventHandlers(socket);
// Create a session for this connection
this.sessionManager.createSession(socket, false);
// Log the new connection
const socketDetails = getSocketDetails(socket);
SmtpLogger.logConnection(socket, 'connect');
// Send greeting
this.sendGreeting(socket);
}
/**
* Handle a new secure TLS connection
* @param socket - Client TLS socket
*/
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
// Check if maximum connections reached
if (this.hasReachedMaxConnections()) {
this.rejectConnection(socket, 'Too many connections');
return;
}
// Add socket to active connections
this.activeConnections.add(socket);
// Set up socket options
socket.setKeepAlive(true);
socket.setTimeout(this.options.socketTimeout);
// Set up event handlers
this.setupSocketEventHandlers(socket);
// Create a session for this connection
this.sessionManager.createSession(socket, true);
// Log the new secure connection
SmtpLogger.logConnection(socket, 'connect');
// Send greeting
this.sendGreeting(socket);
}
/**
* Set up event handlers for a socket
* @param socket - Client socket
*/
public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Store existing socket event handlers before adding new ones
const existingDataHandler = socket.listeners('data')[0];
const existingCloseHandler = socket.listeners('close')[0];
const existingErrorHandler = socket.listeners('error')[0];
const existingTimeoutHandler = socket.listeners('timeout')[0];
// Remove existing event handlers if they exist
if (existingDataHandler) socket.removeListener('data', existingDataHandler);
if (existingCloseHandler) socket.removeListener('close', existingCloseHandler);
if (existingErrorHandler) socket.removeListener('error', existingErrorHandler);
if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler);
// Data event - process incoming data from the client
let buffer = '';
socket.on('data', (data) => {
// Get current session and update activity timestamp
const session = this.sessionManager.getSession(socket);
if (session) {
this.sessionManager.updateSessionActivity(session);
}
// Buffer incoming data
buffer += data.toString();
// Process complete lines
let lineEndPos;
while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) {
// Extract a complete line
const line = buffer.substring(0, lineEndPos);
buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF
// Process non-empty lines
if (line.length > 0) {
// In DATA state, the command handler will process the data differently
this.commandHandler(socket, line);
}
}
});
// Close event - clean up when connection is closed
socket.on('close', (hadError) => {
this.handleSocketClose(socket, hadError);
});
// Error event - handle socket errors
socket.on('error', (err) => {
this.handleSocketError(socket, err);
});
// Timeout event - handle socket timeouts
socket.on('timeout', () => {
this.handleSocketTimeout(socket);
});
}
/**
* Get the current connection count
* @returns Number of active connections
*/
public getConnectionCount(): number {
return this.activeConnections.size;
}
/**
* Check if the server has reached the maximum number of connections
* @returns True if max connections reached
*/
public hasReachedMaxConnections(): boolean {
return this.activeConnections.size >= this.options.maxConnections;
}
/**
* Close all active connections
*/
public closeAllConnections(): void {
const connectionCount = this.activeConnections.size;
if (connectionCount === 0) {
return;
}
SmtpLogger.info(`Closing all connections (count: ${connectionCount})`);
for (const socket of this.activeConnections) {
try {
// Send service closing notification
this.sendServiceClosing(socket);
// End the socket
socket.end();
} catch (error) {
SmtpLogger.error(`Error closing connection: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Clear active connections
this.activeConnections.clear();
}
/**
* Handle socket close event
* @param socket - Client socket
* @param hadError - Whether the socket was closed due to error
*/
private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void {
// Remove from active connections
this.activeConnections.delete(socket);
// Get the session before removing it
const session = this.sessionManager.getSession(socket);
// Remove from session manager
this.sessionManager.removeSession(socket);
// Log connection close
SmtpLogger.logConnection(socket, 'close', session);
}
/**
* Handle socket error event
* @param socket - Client socket
* @param error - Error object
*/
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void {
// Get the session
const session = this.sessionManager.getSession(socket);
// Log the error
SmtpLogger.logConnection(socket, 'error', session, error);
// Close the socket if not already closed
if (!socket.destroyed) {
socket.destroy();
}
}
/**
* Handle socket timeout event
* @param socket - Client socket
*/
private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session
const session = this.sessionManager.getSession(socket);
if (session) {
// Log the timeout
SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
state: session.state,
timeout: this.options.socketTimeout
});
// Send timeout notification
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`);
} else {
// Log timeout without session context
const socketDetails = getSocketDetails(socket);
SmtpLogger.warn(`Socket timeout without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
}
// Close the socket
try {
socket.end();
} catch (error) {
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Reject a connection
* @param socket - Client socket
* @param reason - Reason for rejection
*/
private rejectConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, reason: string): void {
// Log the rejection
const socketDetails = getSocketDetails(socket);
SmtpLogger.warn(`Connection rejected from ${socketDetails.remoteAddress}:${socketDetails.remotePort}: ${reason}`);
// Send rejection message
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} ${this.options.hostname} Service temporarily unavailable - ${reason}`);
// Close the socket
try {
socket.end();
} catch (error) {
SmtpLogger.error(`Error ending rejected socket: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Send greeting message
* @param socket - Client socket
*/
private sendGreeting(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const greeting = `${SmtpResponseCode.SERVICE_READY} ${this.options.hostname} ESMTP service ready`;
this.sendResponse(socket, greeting);
}
/**
* Send service closing notification
* @param socket - Client socket
*/
private sendServiceClosing(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const message = `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`;
this.sendResponse(socket, message);
}
/**
* Send response to client
* @param socket - Client socket
* @param response - Response to send
*/
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
try {
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
SmtpLogger.logResponse(response, socket);
} catch (error) {
// Log error and destroy socket
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
response,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
socket.destroy();
}
}
}