514 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			514 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * Adaptive SMTP Logging System | ||
|  |  * Automatically switches between logging modes based on server load (active connections) | ||
|  |  * to maintain performance during high-concurrency scenarios | ||
|  |  */ | ||
|  | 
 | ||
|  | import * as plugins from '../../../../plugins.ts'; | ||
|  | import { logger } from '../../../../logger.ts'; | ||
|  | import { SecurityLogLevel, SecurityEventType } from '../constants.ts'; | ||
|  | import type { ISmtpSession } from '../interfaces.ts'; | ||
|  | import type { LogLevel, ISmtpLogOptions } from './logging.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * Log modes based on server load | ||
|  |  */ | ||
|  | export enum LogMode { | ||
|  |   VERBOSE = 'VERBOSE',     // < 20 connections: Full detailed logging
 | ||
|  |   REDUCED = 'REDUCED',     // 20-40 connections: Limited command/response logging, full error logging
 | ||
|  |   MINIMAL = 'MINIMAL'      // 40+ connections: Aggregated logging only, critical errors only
 | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Configuration for adaptive logging thresholds | ||
|  |  */ | ||
|  | export interface IAdaptiveLogConfig { | ||
|  |   verboseThreshold: number;     // Switch to REDUCED mode above this connection count
 | ||
|  |   reducedThreshold: number;     // Switch to MINIMAL mode above this connection count
 | ||
|  |   aggregationInterval: number;  // How often to flush aggregated logs (ms)
 | ||
|  |   maxAggregatedEntries: number; // Max entries to hold before forced flush
 | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Aggregated log entry for batching similar events | ||
|  |  */ | ||
|  | interface IAggregatedLogEntry { | ||
|  |   type: 'connection' | 'command' | 'response' | 'error'; | ||
|  |   count: number; | ||
|  |   firstSeen: number; | ||
|  |   lastSeen: number; | ||
|  |   sample: { | ||
|  |     message: string; | ||
|  |     level: LogLevel; | ||
|  |     options?: ISmtpLogOptions; | ||
|  |   }; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Connection metadata for aggregation tracking | ||
|  |  */ | ||
|  | interface IConnectionTracker { | ||
|  |   activeConnections: number; | ||
|  |   peakConnections: number; | ||
|  |   totalConnections: number; | ||
|  |   connectionsPerSecond: number; | ||
|  |   lastConnectionTime: number; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Adaptive SMTP Logger that scales logging based on server load | ||
|  |  */ | ||
|  | export class AdaptiveSmtpLogger { | ||
|  |   private static instance: AdaptiveSmtpLogger; | ||
|  |   private currentMode: LogMode = LogMode.VERBOSE; | ||
|  |   private config: IAdaptiveLogConfig; | ||
|  |   private aggregatedEntries: Map<string, IAggregatedLogEntry> = new Map(); | ||
|  |   private aggregationTimer: NodeJS.Timeout | null = null; | ||
|  |   private connectionTracker: IConnectionTracker = { | ||
|  |     activeConnections: 0, | ||
|  |     peakConnections: 0, | ||
|  |     totalConnections: 0, | ||
|  |     connectionsPerSecond: 0, | ||
|  |     lastConnectionTime: Date.now() | ||
|  |   }; | ||
|  | 
 | ||
|  |   private constructor(config?: Partial<IAdaptiveLogConfig>) { | ||
|  |     this.config = { | ||
|  |       verboseThreshold: 20, | ||
|  |       reducedThreshold: 40, | ||
|  |       aggregationInterval: 30000, // 30 seconds
 | ||
|  |       maxAggregatedEntries: 100, | ||
|  |       ...config | ||
|  |     }; | ||
|  | 
 | ||
|  |     this.startAggregationTimer(); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Get singleton instance | ||
|  |    */ | ||
|  |   public static getInstance(config?: Partial<IAdaptiveLogConfig>): AdaptiveSmtpLogger { | ||
|  |     if (!AdaptiveSmtpLogger.instance) { | ||
|  |       AdaptiveSmtpLogger.instance = new AdaptiveSmtpLogger(config); | ||
|  |     } | ||
|  |     return AdaptiveSmtpLogger.instance; | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Update active connection count and adjust log mode if needed | ||
|  |    */ | ||
|  |   public updateConnectionCount(activeConnections: number): void { | ||
|  |     this.connectionTracker.activeConnections = activeConnections; | ||
|  |     this.connectionTracker.peakConnections = Math.max( | ||
|  |       this.connectionTracker.peakConnections, | ||
|  |       activeConnections | ||
|  |     ); | ||
|  | 
 | ||
|  |     const newMode = this.determineLogMode(activeConnections); | ||
|  |     if (newMode !== this.currentMode) { | ||
|  |       this.switchLogMode(newMode); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Track new connection for rate calculation | ||
|  |    */ | ||
|  |   public trackConnection(): void { | ||
|  |     this.connectionTracker.totalConnections++; | ||
|  |     const now = Date.now(); | ||
|  |     const timeDiff = (now - this.connectionTracker.lastConnectionTime) / 1000; | ||
|  |     if (timeDiff > 0) { | ||
|  |       this.connectionTracker.connectionsPerSecond = 1 / timeDiff; | ||
|  |     } | ||
|  |     this.connectionTracker.lastConnectionTime = now; | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Get current logging mode | ||
|  |    */ | ||
|  |   public getCurrentMode(): LogMode { | ||
|  |     return this.currentMode; | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Get connection statistics | ||
|  |    */ | ||
|  |   public getConnectionStats(): IConnectionTracker { | ||
|  |     return { ...this.connectionTracker }; | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Log a message with adaptive behavior | ||
|  |    */ | ||
|  |   public log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void { | ||
|  |     // Always log structured data
 | ||
|  |     const errorInfo = options.error ? { | ||
|  |       errorMessage: options.error.message, | ||
|  |       errorStack: options.error.stack, | ||
|  |       errorName: options.error.name | ||
|  |     } : {}; | ||
|  |      | ||
|  |     const logData = { | ||
|  |       component: 'smtp-server', | ||
|  |       logMode: this.currentMode, | ||
|  |       activeConnections: this.connectionTracker.activeConnections, | ||
|  |       ...options, | ||
|  |       ...errorInfo | ||
|  |     }; | ||
|  |      | ||
|  |     if (logData.error) { | ||
|  |       delete logData.error; | ||
|  |     } | ||
|  |      | ||
|  |     logger.log(level, message, logData); | ||
|  | 
 | ||
|  |     // Adaptive console logging based on mode
 | ||
|  |     switch (this.currentMode) { | ||
|  |       case LogMode.VERBOSE: | ||
|  |         // Full console logging
 | ||
|  |         if (level === 'error' || level === 'warn') { | ||
|  |           console[level](`[SMTP] ${message}`, logData); | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.REDUCED: | ||
|  |         // Only errors and warnings to console
 | ||
|  |         if (level === 'error' || level === 'warn') { | ||
|  |           console[level](`[SMTP] ${message}`, logData); | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.MINIMAL: | ||
|  |         // Only critical errors to console
 | ||
|  |         if (level === 'error' && (message.includes('critical') || message.includes('security') || message.includes('crash'))) { | ||
|  |           console[level](`[SMTP] ${message}`, logData); | ||
|  |         } | ||
|  |         break; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Log command with adaptive behavior | ||
|  |    */ | ||
|  |   public logCommand(command: string, socket: plugins.net.Socket | plugins.tls.TLSSocket, session?: ISmtpSession): void { | ||
|  |     const clientInfo = { | ||
|  |       remoteAddress: socket.remoteAddress, | ||
|  |       remotePort: socket.remotePort, | ||
|  |       secure: socket instanceof plugins.tls.TLSSocket, | ||
|  |       sessionId: session?.id, | ||
|  |       sessionState: session?.state | ||
|  |     }; | ||
|  | 
 | ||
|  |     switch (this.currentMode) { | ||
|  |       case LogMode.VERBOSE: | ||
|  |         this.log('info', `Command received: ${command}`, { | ||
|  |           ...clientInfo, | ||
|  |           command: command.split(' ')[0]?.toUpperCase() | ||
|  |         }); | ||
|  |         console.log(`← ${command}`); | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.REDUCED: | ||
|  |         // Aggregate commands instead of logging each one
 | ||
|  |         this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo); | ||
|  |         // Only show error commands
 | ||
|  |         if (command.toUpperCase().startsWith('QUIT') || command.includes('error')) { | ||
|  |           console.log(`← ${command}`); | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.MINIMAL: | ||
|  |         // Only aggregate, no console output unless it's an error command
 | ||
|  |         this.aggregateEntry('command', 'info', `Command: ${command.split(' ')[0]?.toUpperCase()}`, clientInfo); | ||
|  |         break; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Log response with adaptive behavior | ||
|  |    */ | ||
|  |   public logResponse(response: string, socket: plugins.net.Socket | plugins.tls.TLSSocket): void { | ||
|  |     const clientInfo = { | ||
|  |       remoteAddress: socket.remoteAddress, | ||
|  |       remotePort: socket.remotePort, | ||
|  |       secure: socket instanceof plugins.tls.TLSSocket | ||
|  |     }; | ||
|  | 
 | ||
|  |     const responseCode = response.substring(0, 3); | ||
|  |     const isError = responseCode.startsWith('4') || responseCode.startsWith('5'); | ||
|  | 
 | ||
|  |     switch (this.currentMode) { | ||
|  |       case LogMode.VERBOSE: | ||
|  |         if (responseCode.startsWith('2') || responseCode.startsWith('3')) { | ||
|  |           this.log('debug', `Response sent: ${response}`, clientInfo); | ||
|  |         } else if (responseCode.startsWith('4')) { | ||
|  |           this.log('warn', `Temporary error response: ${response}`, clientInfo); | ||
|  |         } else if (responseCode.startsWith('5')) { | ||
|  |           this.log('error', `Permanent error response: ${response}`, clientInfo); | ||
|  |         } | ||
|  |         console.log(`→ ${response}`); | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.REDUCED: | ||
|  |         // Log errors normally, aggregate success responses
 | ||
|  |         if (isError) { | ||
|  |           if (responseCode.startsWith('4')) { | ||
|  |             this.log('warn', `Temporary error response: ${response}`, clientInfo); | ||
|  |           } else { | ||
|  |             this.log('error', `Permanent error response: ${response}`, clientInfo); | ||
|  |           } | ||
|  |           console.log(`→ ${response}`); | ||
|  |         } else { | ||
|  |           this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo); | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.MINIMAL: | ||
|  |         // Only log critical errors
 | ||
|  |         if (responseCode.startsWith('5')) { | ||
|  |           this.log('error', `Permanent error response: ${response}`, clientInfo); | ||
|  |           console.log(`→ ${response}`); | ||
|  |         } else { | ||
|  |           this.aggregateEntry('response', 'debug', `Response: ${responseCode}xx`, clientInfo); | ||
|  |         } | ||
|  |         break; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Log connection event with adaptive behavior | ||
|  |    */ | ||
|  |   public logConnection( | ||
|  |     socket: plugins.net.Socket | plugins.tls.TLSSocket, | ||
|  |     eventType: 'connect' | 'close' | 'error', | ||
|  |     session?: ISmtpSession, | ||
|  |     error?: Error | ||
|  |   ): void { | ||
|  |     const clientInfo = { | ||
|  |       remoteAddress: socket.remoteAddress, | ||
|  |       remotePort: socket.remotePort, | ||
|  |       secure: socket instanceof plugins.tls.TLSSocket, | ||
|  |       sessionId: session?.id, | ||
|  |       sessionState: session?.state | ||
|  |     }; | ||
|  | 
 | ||
|  |     if (eventType === 'connect') { | ||
|  |       this.trackConnection(); | ||
|  |     } | ||
|  | 
 | ||
|  |     switch (this.currentMode) { | ||
|  |       case LogMode.VERBOSE: | ||
|  |         // Full connection logging
 | ||
|  |         switch (eventType) { | ||
|  |           case 'connect': | ||
|  |             this.log('info', `New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); | ||
|  |             break; | ||
|  |           case 'close': | ||
|  |             this.log('info', `Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo); | ||
|  |             break; | ||
|  |           case 'error': | ||
|  |             this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { | ||
|  |               ...clientInfo, | ||
|  |               error | ||
|  |             }); | ||
|  |             break; | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.REDUCED: | ||
|  |         // Aggregate normal connections, log errors
 | ||
|  |         if (eventType === 'error') { | ||
|  |           this.log('error', `Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { | ||
|  |             ...clientInfo, | ||
|  |             error | ||
|  |           }); | ||
|  |         } else { | ||
|  |           this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo); | ||
|  |         } | ||
|  |         break; | ||
|  | 
 | ||
|  |       case LogMode.MINIMAL: | ||
|  |         // Only aggregate, except for critical errors
 | ||
|  |         if (eventType === 'error' && error && (error.message.includes('security') || error.message.includes('critical'))) { | ||
|  |           this.log('error', `Critical connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, { | ||
|  |             ...clientInfo, | ||
|  |             error | ||
|  |           }); | ||
|  |         } else { | ||
|  |           this.aggregateEntry('connection', 'info', `Connection ${eventType}`, clientInfo); | ||
|  |         } | ||
|  |         break; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Log security event (always logged regardless of mode) | ||
|  |    */ | ||
|  |   public logSecurityEvent( | ||
|  |     level: SecurityLogLevel, | ||
|  |     type: SecurityEventType, | ||
|  |     message: string, | ||
|  |     details: Record<string, any>, | ||
|  |     ipAddress?: string, | ||
|  |     domain?: string, | ||
|  |     success?: boolean | ||
|  |   ): void { | ||
|  |     const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' : | ||
|  |                                level === SecurityLogLevel.INFO ? 'info' : | ||
|  |                                level === SecurityLogLevel.WARN ? 'warn' : 'error'; | ||
|  |      | ||
|  |     // Security events are always logged in full detail
 | ||
|  |     this.log(logLevel, message, { | ||
|  |       component: 'smtp-security', | ||
|  |       eventType: type, | ||
|  |       success, | ||
|  |       ipAddress, | ||
|  |       domain, | ||
|  |       ...details | ||
|  |     }); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Determine appropriate log mode based on connection count | ||
|  |    */ | ||
|  |   private determineLogMode(activeConnections: number): LogMode { | ||
|  |     if (activeConnections >= this.config.reducedThreshold) { | ||
|  |       return LogMode.MINIMAL; | ||
|  |     } else if (activeConnections >= this.config.verboseThreshold) { | ||
|  |       return LogMode.REDUCED; | ||
|  |     } else { | ||
|  |       return LogMode.VERBOSE; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Switch to a new log mode | ||
|  |    */ | ||
|  |   private switchLogMode(newMode: LogMode): void { | ||
|  |     const oldMode = this.currentMode; | ||
|  |     this.currentMode = newMode; | ||
|  | 
 | ||
|  |     // Log the mode switch
 | ||
|  |     console.log(`[SMTP] Adaptive logging switched from ${oldMode} to ${newMode} (${this.connectionTracker.activeConnections} active connections)`); | ||
|  |      | ||
|  |     this.log('info', `Adaptive logging mode changed to ${newMode}`, { | ||
|  |       oldMode, | ||
|  |       newMode, | ||
|  |       activeConnections: this.connectionTracker.activeConnections, | ||
|  |       peakConnections: this.connectionTracker.peakConnections, | ||
|  |       totalConnections: this.connectionTracker.totalConnections | ||
|  |     }); | ||
|  | 
 | ||
|  |     // If switching to more verbose mode, flush aggregated entries
 | ||
|  |     if ((oldMode === LogMode.MINIMAL && newMode !== LogMode.MINIMAL) || | ||
|  |         (oldMode === LogMode.REDUCED && newMode === LogMode.VERBOSE)) { | ||
|  |       this.flushAggregatedEntries(); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Add entry to aggregation buffer | ||
|  |    */ | ||
|  |   private aggregateEntry( | ||
|  |     type: 'connection' | 'command' | 'response' | 'error', | ||
|  |     level: LogLevel, | ||
|  |     message: string, | ||
|  |     options?: ISmtpLogOptions | ||
|  |   ): void { | ||
|  |     const key = `${type}:${message}`; | ||
|  |     const now = Date.now(); | ||
|  | 
 | ||
|  |     if (this.aggregatedEntries.has(key)) { | ||
|  |       const entry = this.aggregatedEntries.get(key)!; | ||
|  |       entry.count++; | ||
|  |       entry.lastSeen = now; | ||
|  |     } else { | ||
|  |       this.aggregatedEntries.set(key, { | ||
|  |         type, | ||
|  |         count: 1, | ||
|  |         firstSeen: now, | ||
|  |         lastSeen: now, | ||
|  |         sample: { message, level, options } | ||
|  |       }); | ||
|  |     } | ||
|  | 
 | ||
|  |     // Force flush if we have too many entries
 | ||
|  |     if (this.aggregatedEntries.size >= this.config.maxAggregatedEntries) { | ||
|  |       this.flushAggregatedEntries(); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Start the aggregation timer | ||
|  |    */ | ||
|  |   private startAggregationTimer(): void { | ||
|  |     if (this.aggregationTimer) { | ||
|  |       clearInterval(this.aggregationTimer); | ||
|  |     } | ||
|  | 
 | ||
|  |     this.aggregationTimer = setInterval(() => { | ||
|  |       this.flushAggregatedEntries(); | ||
|  |     }, this.config.aggregationInterval); | ||
|  |      | ||
|  |     // Unref the timer so it doesn't keep the process alive
 | ||
|  |     if (this.aggregationTimer && typeof this.aggregationTimer.unref === 'function') { | ||
|  |       this.aggregationTimer.unref(); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Flush aggregated entries to logs | ||
|  |    */ | ||
|  |   private flushAggregatedEntries(): void { | ||
|  |     if (this.aggregatedEntries.size === 0) { | ||
|  |       return; | ||
|  |     } | ||
|  | 
 | ||
|  |     const summary: Record<string, number> = {}; | ||
|  |     let totalAggregated = 0; | ||
|  | 
 | ||
|  |     for (const [key, entry] of this.aggregatedEntries.entries()) { | ||
|  |       summary[entry.type] = (summary[entry.type] || 0) + entry.count; | ||
|  |       totalAggregated += entry.count; | ||
|  | 
 | ||
|  |       // Log a sample of high-frequency entries
 | ||
|  |       if (entry.count >= 10) { | ||
|  |         this.log(entry.sample.level, `${entry.sample.message} (aggregated: ${entry.count} occurrences)`, { | ||
|  |           ...entry.sample.options, | ||
|  |           aggregated: true, | ||
|  |           occurrences: entry.count, | ||
|  |           timeSpan: entry.lastSeen - entry.firstSeen | ||
|  |         }); | ||
|  |       } | ||
|  |     } | ||
|  | 
 | ||
|  |     // Log aggregation summary
 | ||
|  |     console.log(`[SMTP] Aggregated ${totalAggregated} log entries: ${JSON.stringify(summary)}`); | ||
|  |      | ||
|  |     this.log('info', 'Aggregated log summary', { | ||
|  |       totalEntries: totalAggregated, | ||
|  |       breakdown: summary, | ||
|  |       logMode: this.currentMode, | ||
|  |       activeConnections: this.connectionTracker.activeConnections | ||
|  |     }); | ||
|  | 
 | ||
|  |     // Clear aggregated entries
 | ||
|  |     this.aggregatedEntries.clear(); | ||
|  |   } | ||
|  | 
 | ||
|  |   /** | ||
|  |    * Cleanup resources | ||
|  |    */ | ||
|  |   public destroy(): void { | ||
|  |     if (this.aggregationTimer) { | ||
|  |       clearInterval(this.aggregationTimer); | ||
|  |       this.aggregationTimer = null; | ||
|  |     } | ||
|  |     this.flushAggregatedEntries(); | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Default instance for easy access | ||
|  |  */ | ||
|  | export const adaptiveLogger = AdaptiveSmtpLogger.getInstance(); |