/** * SMTP Session Manager * Responsible for creating, managing, and cleaning up SMTP sessions */ import * as plugins from '../../../plugins.js'; import { SmtpState } from './interfaces.js'; import type { ISmtpSession, ISmtpEnvelope } from './interfaces.js'; import type { 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 = new Map(); /** * Map of socket to socket ID */ private socketIds: Map = new Map(); /** * SMTP server options */ private options: { socketTimeout: number; connectionTimeout: number; cleanupInterval: number; }; /** * Event listeners */ private eventListeners: { created?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; stateChanged?: Set<(session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void>; timeout?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; completed?: Set<(session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void>; error?: Set<(session: ISmtpSession, error: Error) => void>; } = {}; /** * 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: [], emailDataSize: 0, useTLS: secure || false, connectionEnded: false, remoteAddress: socketDetails.remoteAddress, remotePort: socketDetails.remotePort, createdAt: new Date(), 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(event: K, listener: ISessionEvents[K]): void { switch (event) { case 'created': if (!this.eventListeners.created) { this.eventListeners.created = new Set(); } this.eventListeners.created.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); break; case 'stateChanged': if (!this.eventListeners.stateChanged) { this.eventListeners.stateChanged = new Set(); } this.eventListeners.stateChanged.add(listener as (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void); break; case 'timeout': if (!this.eventListeners.timeout) { this.eventListeners.timeout = new Set(); } this.eventListeners.timeout.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); break; case 'completed': if (!this.eventListeners.completed) { this.eventListeners.completed = new Set(); } this.eventListeners.completed.add(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); break; case 'error': if (!this.eventListeners.error) { this.eventListeners.error = new Set(); } this.eventListeners.error.add(listener as (session: ISmtpSession, error: Error) => void); break; } } /** * Remove an event listener * @param event - Event name * @param listener - Event listener function */ public off(event: K, listener: ISessionEvents[K]): void { switch (event) { case 'created': if (this.eventListeners.created) { this.eventListeners.created.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); } break; case 'stateChanged': if (this.eventListeners.stateChanged) { this.eventListeners.stateChanged.delete(listener as (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void); } break; case 'timeout': if (this.eventListeners.timeout) { this.eventListeners.timeout.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); } break; case 'completed': if (this.eventListeners.completed) { this.eventListeners.completed.delete(listener as (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void); } break; case 'error': if (this.eventListeners.error) { this.eventListeners.error.delete(listener as (session: ISmtpSession, error: Error) => void); } break; } } /** * Emit an event to registered listeners * @param event - Event name * @param args - Event arguments */ private emitEvent(event: K, ...args: any[]): void { let listeners: Set | undefined; switch (event) { case 'created': listeners = this.eventListeners.created; break; case 'stateChanged': listeners = this.eventListeners.stateChanged; break; case 'timeout': listeners = this.eventListeners.timeout; break; case 'completed': listeners = this.eventListeners.completed; break; case 'error': listeners = this.eventListeners.error; break; } if (!listeners) { return; } for (const listener of listeners) { try { (listener as Function)(...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; } } /** * Replace socket mapping for STARTTLS upgrades * @param oldSocket - Original plain socket * @param newSocket - New TLS socket * @returns Whether the replacement was successful */ public replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean { const socketKey = this.socketIds.get(oldSocket); if (!socketKey) { SmtpLogger.warn('Cannot replace socket - original socket not found in session manager'); return false; } const session = this.sessions.get(socketKey); if (!session) { SmtpLogger.warn('Cannot replace socket - session not found for socket key'); return false; } // Remove old socket mapping this.socketIds.delete(oldSocket); // Add new socket mapping this.socketIds.set(newSocket, socketKey); // Set socket timeout for new socket newSocket.setTimeout(this.options.socketTimeout); SmtpLogger.info(`Socket replaced for session ${session.id} (STARTTLS upgrade)`, { sessionId: session.id, remoteAddress: session.remoteAddress, oldSocketType: oldSocket.constructor.name, newSocketType: newSocket.constructor.name }); return true; } /** * 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()}`; } /** * Get all active sessions */ public getAllSessions(): ISmtpSession[] { return Array.from(this.sessions.values()); } /** * Update last activity for a session by socket */ public updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.getSession(socket); if (session) { this.updateSessionActivity(session); } } /** * Check for timed out sessions */ public checkTimeouts(timeoutMs: number): ISmtpSession[] { const now = Date.now(); const timedOutSessions: ISmtpSession[] = []; for (const session of this.sessions.values()) { if (now - session.lastActivity > timeoutMs) { timedOutSessions.push(session); } } return timedOutSessions; } /** * Clean up resources */ public destroy(): void { // Clear the cleanup timer if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } // Clear all sessions this.clearAllSessions(); // Clear event listeners this.eventListeners = {}; SmtpLogger.debug('SessionManager destroyed'); } }