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

392 lines
11 KiB
TypeScript
Raw Normal View History

2025-05-21 12:52:24 +00:00
/**
* SMTP Session Manager
* Responsible for creating, managing, and cleaning up SMTP sessions
*/
import * as plugins from '../../../plugins.js';
import { SmtpState, ISmtpSession, ISmtpEnvelope } from '../interfaces.js';
import { ISessionManager, ISessionEvents } from './interfaces.js';
import { SMTP_DEFAULTS } from './constants.js';
import { generateSessionId, getSocketDetails } from './utils/helpers.js';
import { SmtpLogger } from './utils/logging.js';
/**
* Manager for SMTP sessions
* Handles session creation, tracking, timeout management, and cleanup
*/
export class SessionManager implements ISessionManager {
/**
* Map of socket ID to session
*/
private sessions: Map<string, ISmtpSession> = new Map();
/**
* Map of socket to socket ID
*/
private socketIds: Map<plugins.net.Socket | plugins.tls.TLSSocket, string> = new Map();
/**
* SMTP server options
*/
private options: {
socketTimeout: number;
connectionTimeout: number;
cleanupInterval: number;
};
/**
* Event listeners
*/
private eventListeners: {
[K in keyof ISessionEvents]?: Set<ISessionEvents[K]>;
} = {};
/**
* Timer for cleanup interval
*/
private cleanupTimer: NodeJS.Timeout | null = null;
/**
* Creates a new session manager
* @param options - Session manager options
*/
constructor(options: {
socketTimeout?: number;
connectionTimeout?: number;
cleanupInterval?: number;
} = {}) {
this.options = {
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT,
connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT,
cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL
};
// Start the cleanup timer
this.startCleanupTimer();
}
/**
* Creates a new session for a socket connection
* @param socket - Client socket
* @param secure - Whether the connection is secure (TLS)
* @returns New SMTP session
*/
public createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession {
const sessionId = generateSessionId();
const socketDetails = getSocketDetails(socket);
// Create a new session
const session: ISmtpSession = {
id: sessionId,
state: SmtpState.GREETING,
clientHostname: '',
mailFrom: '',
rcptTo: [],
emailData: '',
emailDataChunks: [],
useTLS: secure || false,
connectionEnded: false,
remoteAddress: socketDetails.remoteAddress,
secure: secure || false,
authenticated: false,
envelope: {
mailFrom: { address: '', args: {} },
rcptTo: []
},
lastActivity: Date.now()
};
// Store session with unique ID
const socketKey = this.getSocketKey(socket);
this.socketIds.set(socket, socketKey);
this.sessions.set(socketKey, session);
// Set socket timeout
socket.setTimeout(this.options.socketTimeout);
// Emit session created event
this.emitEvent('created', session, socket);
// Log session creation
SmtpLogger.info(`Created SMTP session ${sessionId}`, {
sessionId,
remoteAddress: session.remoteAddress,
remotePort: socketDetails.remotePort,
secure: session.secure
});
return session;
}
/**
* Updates the session state
* @param session - SMTP session
* @param newState - New state
*/
public updateSessionState(session: ISmtpSession, newState: SmtpState): void {
if (session.state === newState) {
return;
}
const previousState = session.state;
session.state = newState;
// Update activity timestamp
this.updateSessionActivity(session);
// Emit state changed event
this.emitEvent('stateChanged', session, previousState, newState);
// Log state change
SmtpLogger.debug(`Session ${session.id} state changed from ${previousState} to ${newState}`, {
sessionId: session.id,
previousState,
newState,
remoteAddress: session.remoteAddress
});
}
/**
* Updates the session's last activity timestamp
* @param session - SMTP session
*/
public updateSessionActivity(session: ISmtpSession): void {
session.lastActivity = Date.now();
}
/**
* Removes a session
* @param socket - Client socket
*/
public removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const socketKey = this.socketIds.get(socket);
if (!socketKey) {
return;
}
const session = this.sessions.get(socketKey);
if (session) {
// Mark the session as ended
session.connectionEnded = true;
// Clear any data timeout if it exists
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
session.dataTimeoutId = undefined;
}
// Emit session completed event
this.emitEvent('completed', session, socket);
// Log session removal
SmtpLogger.info(`Removed SMTP session ${session.id}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
finalState: session.state
});
}
// Remove from maps
this.sessions.delete(socketKey);
this.socketIds.delete(socket);
}
/**
* Gets a session for a socket
* @param socket - Client socket
* @returns SMTP session or undefined if not found
*/
public getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined {
const socketKey = this.socketIds.get(socket);
if (!socketKey) {
return undefined;
}
return this.sessions.get(socketKey);
}
/**
* Cleans up idle sessions
*/
public cleanupIdleSessions(): void {
const now = Date.now();
let timedOutCount = 0;
for (const [socketKey, session] of this.sessions.entries()) {
if (session.connectionEnded) {
// Session already marked as ended, but still in map
this.sessions.delete(socketKey);
continue;
}
// Calculate how long the session has been idle
const lastActivity = session.lastActivity || 0;
const idleTime = now - lastActivity;
// Use appropriate timeout based on session state
const timeout = session.state === SmtpState.DATA_RECEIVING
? this.options.socketTimeout * 2 // Double timeout for data receiving
: session.state === SmtpState.GREETING
? this.options.connectionTimeout // Initial connection timeout
: this.options.socketTimeout; // Standard timeout for other states
// Check if session has timed out
if (idleTime > timeout) {
// Find the socket for this session
let timedOutSocket: plugins.net.Socket | plugins.tls.TLSSocket | undefined;
for (const [socket, key] of this.socketIds.entries()) {
if (key === socketKey) {
timedOutSocket = socket;
break;
}
}
if (timedOutSocket) {
// Emit timeout event
this.emitEvent('timeout', session, timedOutSocket);
// Log timeout
SmtpLogger.warn(`Session ${session.id} timed out after ${Math.round(idleTime / 1000)}s of inactivity`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
state: session.state,
idleTime
});
// End the socket connection
try {
timedOutSocket.end();
} catch (error) {
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
error: error instanceof Error ? error : new Error(String(error))
});
}
// Remove from maps
this.sessions.delete(socketKey);
this.socketIds.delete(timedOutSocket);
timedOutCount++;
}
}
}
if (timedOutCount > 0) {
SmtpLogger.info(`Cleaned up ${timedOutCount} timed out sessions`, {
totalSessions: this.sessions.size
});
}
}
/**
* Gets the current number of active sessions
* @returns Number of active sessions
*/
public getSessionCount(): number {
return this.sessions.size;
}
/**
* Clears all sessions (used when shutting down)
*/
public clearAllSessions(): void {
// Log the action
SmtpLogger.info(`Clearing all sessions (count: ${this.sessions.size})`);
// Clear the sessions and socket IDs maps
this.sessions.clear();
this.socketIds.clear();
// Stop the cleanup timer
this.stopCleanupTimer();
}
/**
* Register an event listener
* @param event - Event name
* @param listener - Event listener function
*/
public on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void {
if (!this.eventListeners[event]) {
this.eventListeners[event] = new Set();
}
(this.eventListeners[event] as Set<ISessionEvents[K]>).add(listener);
}
/**
* Remove an event listener
* @param event - Event name
* @param listener - Event listener function
*/
public off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void {
if (!this.eventListeners[event]) {
return;
}
(this.eventListeners[event] as Set<ISessionEvents[K]>).delete(listener);
}
/**
* Emit an event to registered listeners
* @param event - Event name
* @param args - Event arguments
*/
private emitEvent<K extends keyof ISessionEvents>(event: K, ...args: Parameters<ISessionEvents[K]>): void {
const listeners = this.eventListeners[event] as Set<ISessionEvents[K]> | undefined;
if (!listeners) {
return;
}
for (const listener of listeners) {
try {
listener(...args);
} catch (error) {
SmtpLogger.error(`Error in session event listener for ${String(event)}: ${error instanceof Error ? error.message : String(error)}`, {
error: error instanceof Error ? error : new Error(String(error))
});
}
}
}
/**
* Start the cleanup timer
*/
private startCleanupTimer(): void {
if (this.cleanupTimer) {
return;
}
this.cleanupTimer = setInterval(() => {
this.cleanupIdleSessions();
}, this.options.cleanupInterval);
// Prevent the timer from keeping the process alive
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref();
}
}
/**
* Stop the cleanup timer
*/
private stopCleanupTimer(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
/**
* Gets a unique key for a socket
* @param socket - Client socket
* @returns Socket key
*/
private getSocketKey(socket: plugins.net.Socket | plugins.tls.TLSSocket): string {
const details = getSocketDetails(socket);
return `${details.remoteAddress}:${details.remotePort}-${Date.now()}`;
}
}