557 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			557 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * SMTP Session Manager
 | |
|  * Responsible for creating, managing, and cleaning up SMTP sessions
 | |
|  */
 | |
| 
 | |
| import * as plugins from '../../../plugins.ts';
 | |
| import { SmtpState } from './interfaces.ts';
 | |
| import type { ISmtpSession, ISmtpEnvelope } from './interfaces.ts';
 | |
| import type { ISessionManager, ISessionEvents } from './interfaces.ts';
 | |
| import { SMTP_DEFAULTS } from './constants.ts';
 | |
| import { generateSessionId, getSocketDetails } from './utils/helpers.ts';
 | |
| import { SmtpLogger } from './utils/logging.ts';
 | |
| 
 | |
| /**
 | |
|  * 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: {
 | |
|     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<K extends keyof ISessionEvents>(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<K extends keyof ISessionEvents>(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<K extends keyof ISessionEvents>(event: K, ...args: any[]): void {
 | |
|     let listeners: Set<any> | 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');
 | |
|   }
 | |
| } |