import * as plugins from '../../plugins.js'; import * as paths from '../../paths.js'; import { Email } from '../core/classes.email.js'; import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js'; import { logger } from '../../logger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType, IPReputationChecker, ReputationThreshold } from '../../security/index.js'; import type { ISmtpServerOptions, ISmtpSession, EmailProcessingMode } from './interfaces.js'; import { SmtpState } from './interfaces.js'; export class SMTPServer { public emailServerRef: UnifiedEmailServer; private smtpServerOptions: ISmtpServerOptions; // Making server protected so tests can access it protected server: plugins.net.Server; // Secure server for TLS connections protected secureServer?: plugins.tls.Server; private sessions: Map; private sessionTimeouts: Map; private hostname: string; private sessionIdCounter: number = 0; private connectionCount: number = 0; private maxConnections: number = 100; // Default max connections private cleanupInterval?: NodeJS.Timeout; // Reference to the cleanup interval for proper cleanup constructor(emailServerRefArg: UnifiedEmailServer, optionsArg: ISmtpServerOptions) { console.log('SMTPServer instance is being created...'); this.emailServerRef = emailServerRefArg; this.smtpServerOptions = optionsArg; this.sessions = new Map(); this.sessionTimeouts = new Map(); this.hostname = optionsArg.hostname || 'mail.lossless.one'; this.maxConnections = optionsArg.maxConnections || 100; // Use maxConnections instead of maxSize for clarity // Log enhanced server configuration const socketTimeout = optionsArg.socketTimeout || 300000; const connectionTimeout = optionsArg.connectionTimeout || 30000; const cleanupFrequency = optionsArg.cleanupInterval || 5000; logger.log('info', 'SMTP server configuration', { hostname: this.hostname, maxConnections: this.maxConnections, socketTimeout: socketTimeout, connectionTimeout: connectionTimeout, cleanupInterval: cleanupFrequency, tlsEnabled: !!(optionsArg.key && optionsArg.cert), starttlsEnabled: !!(optionsArg.key && optionsArg.cert), securePort: optionsArg.securePort }); // Start session cleanup interval - run more frequently to ensure timely timeout detection // Default to 5 seconds for production use, but can be as low as 1 second for testing logger.log('info', `Setting up session cleanup interval to run every ${cleanupFrequency}ms`); const cleanupInterval = setInterval(() => this.cleanupIdleSessions(), cleanupFrequency); // Ensure cleanup interval is cleared if server is stopped this.cleanupInterval = cleanupInterval; // Create a plain TCP server for non-TLS connections this.server = plugins.net.createServer((socket) => { // Check if we've exceeded maximum connections if (this.connectionCount >= this.maxConnections) { logger.log('warn', `Connection limit reached (${this.maxConnections}), rejecting new connection`); socket.write('421 Too many connections, try again later\r\n'); socket.destroy(); return; } this.handleNewConnection(socket); }); // Set up secure TLS server if TLS is configured if (optionsArg.key && optionsArg.cert) { logger.log('info', 'Setting up secure TLS SMTP server'); try { // Create a secure context for TLS const secureContext = plugins.tls.createSecureContext({ key: optionsArg.key, cert: optionsArg.cert, ca: optionsArg.ca }); // Create a secure TLS server this.secureServer = plugins.tls.createServer({ key: optionsArg.key, cert: optionsArg.cert, ca: optionsArg.ca, secureContext: secureContext }, (tlsSocket) => { // Check if we've exceeded maximum connections if (this.connectionCount >= this.maxConnections) { logger.log('warn', `Connection limit reached (${this.maxConnections}), rejecting new TLS connection`); tlsSocket.write('421 Too many connections, try again later\r\n'); tlsSocket.destroy(); return; } // Handle the new secure connection this.handleNewSecureConnection(tlsSocket); }); // Log errors from secure server this.secureServer.on('error', (err) => { logger.log('error', `Secure SMTP server error: ${err.message}`, { stack: err.stack }); }); } catch (error) { logger.log('error', `Failed to initialize TLS server: ${error.message}`, { stack: error.stack }); } } } /** * Start the SMTP server and listen on the specified port * @returns A promise that resolves when the server is listening */ public listen(): Promise { return new Promise((resolve, reject) => { if (!this.smtpServerOptions.port) { return reject(new Error('SMTP server port not specified')); } const port = this.smtpServerOptions.port; const securePort = this.smtpServerOptions.securePort || port; // Default to same port // Store promises for both servers const startPromises: Promise[] = []; // Start the plain TCP server const plainServerPromise = new Promise((plainResolve, plainReject) => { // Set up error handler this.server.on('error', (err) => { logger.log('error', `SMTP server error: ${err.message}`, { stack: err.stack }); console.error(`Failed to start SMTP server: ${err.message}`); plainReject(err); }); // Start listening this.server.listen(port, () => { logger.log('info', `SMTP server listening on port ${port}`); console.log(`SMTP server started on port ${port}`); plainResolve(); }); }); startPromises.push(plainServerPromise); // Start the secure TLS server if configured if (this.secureServer && this.smtpServerOptions.key && this.smtpServerOptions.cert) { const secureServerPromise = new Promise((secureResolve, secureReject) => { // Set up error handler this.secureServer!.on('error', (err) => { logger.log('error', `Secure SMTP server error: ${err.message}`, { stack: err.stack }); console.error(`Failed to start secure SMTP server: ${err.message}`); secureReject(err); }); // Decide whether to use a separate port for secure connections if (securePort !== port && securePort > 0) { // Use separate port for secure (implicit TLS) connections this.secureServer!.listen(securePort, () => { logger.log('info', `Secure SMTP server listening on port ${securePort}`); console.log(`Secure SMTP server started on port ${securePort}`); secureResolve(); }); } else { // Use the same port for both plain and secure connections // This means server needs to autodetect whether client is using TLS or not this.secureServer!.listen(port, () => { logger.log('info', `Secure SMTP server listening on same port ${port}`); console.log(`Secure SMTP server started on port ${port}`); secureResolve(); }); } }); startPromises.push(secureServerPromise); } // Wait for all servers to start Promise.all(startPromises) .then(() => resolve()) .catch(err => reject(err)); }); } /** * Stop the SMTP server * @returns A promise that resolves when the server has stopped */ public close(): Promise { return new Promise((resolve, reject) => { // Store promises for closing both servers const closePromises: Promise[] = []; let errors: Error[] = []; // Close the main server const closeMainServer = new Promise((closeResolve, closeReject) => { this.server.close((err) => { if (err) { logger.log('error', `Error closing SMTP server: ${err.message}`); errors.push(err); closeReject(err); } else { logger.log('info', 'SMTP server stopped'); closeResolve(); } }); }); closePromises.push(closeMainServer); // Close the secure server if it exists if (this.secureServer) { const closeSecureServer = new Promise((closeResolve, closeReject) => { this.secureServer!.close((err) => { if (err) { logger.log('error', `Error closing secure SMTP server: ${err.message}`); errors.push(err); closeReject(err); } else { logger.log('info', 'Secure SMTP server stopped'); closeResolve(); } }); }); closePromises.push(closeSecureServer); } // Clean up any active connections for (const [socket, session] of this.sessions.entries()) { try { // Send a notification that server is shutting down this.sendResponse(socket, '421 Server shutting down'); socket.destroy(); } catch (error) { logger.log('error', `Error closing session: ${error.message}`); } } // Clear cleanup interval if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } // Clear all sessions and timeouts this.sessions.clear(); for (const timeoutId of this.sessionTimeouts.values()) { clearTimeout(timeoutId); } this.sessionTimeouts.clear(); // Wait for all servers to close Promise.allSettled(closePromises) .then(() => { if (errors.length > 0) { reject(new Error(`Errors while closing SMTP servers: ${errors.map(e => e.message).join(', ')}`)); } else { resolve(); } }); }); } /** * Clean up idle sessions * @private */ private cleanupIdleSessions(): void { const now = Date.now(); const sessionTimeout = this.smtpServerOptions.socketTimeout || 300000; // Default 5 minutes // Log that cleanup is running logger.log('debug', `Running idle session cleanup, checking ${this.sessions.size} active sessions`); // Check all sessions for timeout for (const [socket, session] of this.sessions.entries()) { if (!session.lastActivity) { // Initialize lastActivity if not set session.lastActivity = now; continue; } const idleTime = now - session.lastActivity; logger.log('debug', `Session ${session.id} idle time: ${idleTime}ms, timeout threshold: ${sessionTimeout}ms`); if (idleTime > sessionTimeout) { logger.log('info', `Session ${session.id} timed out after ${idleTime}ms of inactivity`, { remoteAddress: socket.remoteAddress, remotePort: socket.remotePort, state: session.state }); try { // Send timeout message and end connection this.sendResponse(socket, '421 Timeout - closing connection'); // Log security event for timeout SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.CONNECTION, message: `SMTP connection terminated due to timeout`, ipAddress: socket.remoteAddress, details: { idleTime, sessionId: session.id, state: session.state } }); // Destroy socket - force timeout even if client doesn't respond socket.destroy(); } catch (error) { logger.log('error', `Error closing timed out session: ${error.message}`, { sessionId: session.id, error: error.message, stack: error instanceof Error ? error.stack : undefined }); } // Clean up session this.removeSession(socket); } } } /** * Create a new session ID * @private */ private generateSessionId(): string { return `${Date.now()}-${++this.sessionIdCounter}`; } /** * Properly remove a session and clean up resources * @private */ private removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; // Clear session timeout if exists const timeoutId = this.sessionTimeouts.get(session.id); if (timeoutId) { clearTimeout(timeoutId); this.sessionTimeouts.delete(session.id); } // Remove session from map this.sessions.delete(socket); // Decrement connection count this.connectionCount--; logger.log('debug', `Session ${session.id} removed, active connections: ${this.connectionCount}`); } /** * Handle a new secure TLS connection * @private */ private handleNewSecureConnection(tlsSocket: plugins.tls.TLSSocket): void { const clientIp = tlsSocket.remoteAddress; const clientPort = tlsSocket.remotePort; console.log(`New secure TLS connection from ${clientIp}:${clientPort}`); // Log TLS details for debugging logger.log('info', 'New secure TLS connection established', { ip: clientIp, port: clientPort, protocol: tlsSocket.getProtocol(), cipher: tlsSocket.getCipher()?.name, authorized: tlsSocket.authorized }); // Increment connection count this.connectionCount++; // Generate unique session ID const sessionId = this.generateSessionId(); // Initialize a new session with secure flag set to true this.sessions.set(tlsSocket, { id: sessionId, state: SmtpState.GREETING, clientHostname: '', mailFrom: '', rcptTo: [], emailData: '', useTLS: true, // This is a secure connection from the start connectionEnded: false, remoteAddress: tlsSocket.remoteAddress || '', secure: true, // Flag to indicate this is a secure connection authenticated: false, // Not authenticated yet lastActivity: Date.now(), envelope: { mailFrom: { address: '', args: {} }, rcptTo: [] } }); // Process IP reputation check this.checkIpReputation(tlsSocket, clientIp, clientPort) .then(shouldContinue => { if (!shouldContinue) { tlsSocket.destroy(); return; } // Send greeting this.sendResponse(tlsSocket, `220 ${this.hostname} ESMTP Service Ready`); // Set session timeout const sessionTimeout = setTimeout(() => { logger.log('info', `Initial connection timeout for secure session ${sessionId}`); this.sendResponse(tlsSocket, '421 Connection timeout'); tlsSocket.destroy(); this.removeSession(tlsSocket); }, this.smtpServerOptions.connectionTimeout || 30000); // Store timeout reference this.sessionTimeouts.set(sessionId, sessionTimeout); // Set up event handlers this.setupSocketEventHandlers(tlsSocket, sessionId); }) .catch(error => { logger.log('error', `Error during IP reputation check: ${error.message}`, { stack: error.stack, ip: clientIp }); tlsSocket.destroy(); }); } /** * Update last activity timestamp for a session * @private */ private updateSessionActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; session.lastActivity = Date.now(); } /** * Handle a new plain connection * @private */ private handleNewConnection(socket: plugins.net.Socket): void { const clientIp = socket.remoteAddress; const clientPort = socket.remotePort; console.log(`New connection from ${clientIp}:${clientPort}`); // Increment connection count this.connectionCount++; // Generate unique session ID const sessionId = this.generateSessionId(); // Initialize a new session this.sessions.set(socket, { id: sessionId, state: SmtpState.GREETING, clientHostname: '', mailFrom: '', rcptTo: [], emailData: '', useTLS: false, connectionEnded: false, remoteAddress: socket.remoteAddress || '', secure: false, authenticated: false, lastActivity: Date.now(), envelope: { mailFrom: { address: '', args: {} }, rcptTo: [] } }); // Process IP reputation check this.checkIpReputation(socket, clientIp, clientPort) .then(shouldContinue => { if (!shouldContinue) { socket.destroy(); return; } // Send greeting this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`); // Set session timeout const sessionTimeout = setTimeout(() => { logger.log('info', `Initial connection timeout for session ${sessionId}`); this.sendResponse(socket, '421 Connection timeout'); socket.destroy(); this.removeSession(socket); }, this.smtpServerOptions.connectionTimeout || 30000); // Store timeout reference this.sessionTimeouts.set(sessionId, sessionTimeout); // Set up event handlers this.setupSocketEventHandlers(socket, sessionId); }) .catch(error => { logger.log('error', `Error during IP reputation check: ${error.message}`, { stack: error.stack, ip: clientIp }); socket.destroy(); }); } /** * Check IP reputation for a new connection * @private */ private async checkIpReputation( socket: plugins.net.Socket | plugins.tls.TLSSocket, clientIp?: string, clientPort?: number ): Promise { if (!clientIp) { return true; // No IP to check } try { const reputationChecker = IPReputationChecker.getInstance(); const reputation = await reputationChecker.checkReputation(clientIp); // Log the reputation check SecurityLogger.getInstance().logEvent({ level: reputation.score < ReputationThreshold.HIGH_RISK ? SecurityLogLevel.WARN : SecurityLogLevel.INFO, type: SecurityEventType.IP_REPUTATION, message: `IP reputation checked for new SMTP connection: score=${reputation.score}`, ipAddress: clientIp, details: { clientPort, score: reputation.score, isSpam: reputation.isSpam, isProxy: reputation.isProxy, isTor: reputation.isTor, isVPN: reputation.isVPN, country: reputation.country, blacklists: reputation.blacklists, socketId: socket.remotePort?.toString() + socket.remoteFamily } }); // Handle high-risk IPs - add delay or reject based on score if (reputation.score < ReputationThreshold.HIGH_RISK) { // For high-risk connections, add an artificial delay to slow down potential spam const delayMs = Math.min(5000, Math.max(1000, (ReputationThreshold.HIGH_RISK - reputation.score) * 100)); await new Promise(resolve => setTimeout(resolve, delayMs)); if (reputation.score < 5) { // Very high risk - reject the connection for security // The email server has security settings for high-risk IPs this.sendResponse(socket, `554 Transaction failed - IP is on spam blocklist`); return false; } } // Log the connection as a security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.CONNECTION, message: `New SMTP connection established`, ipAddress: clientIp, details: { clientPort, socketId: socket.remotePort?.toString() + socket.remoteFamily, secure: socket instanceof plugins.tls.TLSSocket } }); return true; } catch (error) { logger.log('error', `Error checking IP reputation: ${error.message}`, { ip: clientIp, error: error.message, stack: error.stack }); return true; // Continue even if reputation check fails } } /** * Set up socket event handlers * @private */ private setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket, sessionId: string): void { // Set socket timeout to detect connection issues const socketTimeout = this.smtpServerOptions.socketTimeout || 300000; // Default 5 minutes socket.setTimeout(socketTimeout); // Add timeout event handler socket.on('timeout', () => { const session = this.sessions.get(socket); if (!session) return; logger.log('info', `Socket timeout event triggered for session ${session.id}`, { remoteAddress: socket.remoteAddress, remotePort: socket.remotePort }); try { // Send timeout message and end connection this.sendResponse(socket, '421 Timeout - closing connection'); // Log security event for timeout SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.CONNECTION, message: `SMTP connection timeout detected by socket`, ipAddress: socket.remoteAddress, details: { sessionId: session.id, state: session.state, lastActivity: new Date(session.lastActivity || Date.now()).toISOString() } }); // Force close the connection socket.end(); socket.destroy(); } catch (error) { logger.log('error', `Error handling socket timeout: ${error.message}`); } // Clean up session this.removeSession(socket); }); socket.on('data', (data) => { // Clear initial connection timeout on first data const timeoutId = this.sessionTimeouts.get(sessionId); if (timeoutId) { clearTimeout(timeoutId); this.sessionTimeouts.delete(sessionId); } // Update last activity timestamp this.updateSessionActivity(socket); // Process the data this.processData(socket, data); }); socket.on('end', () => { const clientIp = socket.remoteAddress; const clientPort = socket.remotePort; console.log(`Connection ended from ${clientIp}:${clientPort}`); const session = this.sessions.get(socket); if (session) { session.connectionEnded = true; // Log connection end as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.CONNECTION, message: `SMTP connection ended normally`, ipAddress: clientIp, details: { clientPort, state: SmtpState[session.state], from: session.mailFrom || 'not set', sessionId: session.id, secure: session.secure } }); } // Clean up session this.removeSession(socket); }); socket.on('error', (err) => { const clientIp = socket.remoteAddress; const clientPort = socket.remotePort; const session = this.sessions.get(socket); console.error(`Socket error for session ${session?.id}: ${err.message}`); // Log connection error as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.WARN, type: SecurityEventType.CONNECTION, message: `SMTP connection error`, ipAddress: clientIp, details: { clientPort, error: err.message, errorCode: (err as any).code, from: session?.mailFrom || 'not set', sessionId: session?.id, secure: session?.secure } }); // Clean up session resources this.removeSession(socket); socket.destroy(); }); socket.on('close', () => { const clientIp = socket.remoteAddress; const clientPort = socket.remotePort; const session = this.sessions.get(socket); console.log(`Connection closed for session ${session?.id} from ${clientIp}:${clientPort}`); // Log connection closure as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.CONNECTION, message: `SMTP connection closed`, ipAddress: clientIp, details: { clientPort, sessionId: session?.id, sessionEnded: session?.connectionEnded || false, secure: session?.secure } }); // Clean up session resources this.removeSession(socket); }); } private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void { try { socket.write(`${response}\r\n`); console.log(`→ ${response}`); } catch (error) { console.error(`Error sending response: ${error.message}`); socket.destroy(); } } private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void { const session = this.sessions.get(socket); if (!session) { console.error('No session found for socket. Closing connection.'); socket.destroy(); return; } // If we're in DATA_RECEIVING state, handle differently if (session.state === SmtpState.DATA_RECEIVING) { // Call async method but don't return the promise this.processEmailData(socket, data.toString()).catch(err => { console.error(`Error processing email data: ${err.message}`); }); return; } // Process normal SMTP commands const lines = data.toString().split('\r\n').filter(line => line.length > 0); for (const line of lines) { console.log(`← ${line}`); this.processCommand(socket, line); } } private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void { const session = this.sessions.get(socket); if (!session || session.connectionEnded) return; // Update session activity timestamp this.updateSessionActivity(socket); const [command, ...args] = commandLine.split(' '); const upperCommand = command.toUpperCase(); switch (upperCommand) { case 'EHLO': case 'HELO': this.handleEhlo(socket, args.join(' ')); break; case 'STARTTLS': this.handleStartTls(socket); break; case 'MAIL': this.handleMailFrom(socket, args.join(' ')); break; case 'RCPT': this.handleRcptTo(socket, args.join(' ')); break; case 'DATA': this.handleData(socket); break; case 'RSET': this.handleRset(socket); break; case 'QUIT': this.handleQuit(socket); break; case 'NOOP': this.sendResponse(socket, '250 OK'); break; default: this.sendResponse(socket, '502 Command not implemented'); } } /** * Handle EHLO/HELO command * @private */ private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void { const session = this.sessions.get(socket); if (!session) { logger.log('error', 'No session found when handling EHLO'); return; } // Check if hostname is provided (required by RFC 5321) if (!clientHostname) { this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } // Check for invalid characters in hostname (not a domain per RFC 5321) if (clientHostname.includes('@') || clientHostname.includes('<')) { this.sendResponse(socket, '501 Invalid domain name'); return; } // Update session with client hostname session.clientHostname = clientHostname; session.state = SmtpState.AFTER_EHLO; logger.log('debug', `EHLO received from client: ${clientHostname}`, { sessionId: session.id, remoteAddress: socket.remoteAddress }); // Format extensions according to RFC 5321 section 4.1.1.1 let extensions: string[] = []; // Add welcome message (first line) extensions.push(`250-${this.hostname} Hello ${clientHostname}`); // Add SIZE extension with max message size const maxSize = this.smtpServerOptions.size || 10485760; // 10MB default extensions.push(`250-SIZE ${maxSize}`); // Add 8BITMIME (RFC 6152) extensions.push('250-8BITMIME'); // Add STARTTLS (RFC 3207) if TLS is configured and not already in TLS mode if (!session.useTLS && this.smtpServerOptions.key && this.smtpServerOptions.cert) { extensions.push('250-STARTTLS'); } // Add any additional extensions here // Add HELP as the last extension extensions.push('250 HELP'); // Server needs to respond with exactly ONE response that has multiple lines // Each line except the last has a dash after the code (250-), // and the last line has a space (250 ) // Format response as a single multiline response properly for (let i = 0; i < extensions.length; i++) { // All lines except last should have dash if (i < extensions.length - 1) { // Ensure the line starts with "250-" const line = extensions[i]; if (!line.startsWith('250-')) { extensions[i] = '250-' + line.substring(4); } } else { // Last line should have space const line = extensions[i]; if (!line.startsWith('250 ')) { extensions[i] = '250 ' + line.substring(4); } } } // Combine all lines with CRLF and send as one response const multilineResponse = extensions.join('\r\n'); socket.write(multilineResponse + '\r\n'); console.log(`→ ${multilineResponse.replace(/\r\n/g, '\n→ ')}`); // Update activity timestamp this.updateSessionActivity(socket); } private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.AFTER_EHLO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } if (session.useTLS) { this.sendResponse(socket, '503 TLS already active'); return; } this.sendResponse(socket, '220 Ready to start TLS'); this.startTLS(socket); } private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.AFTER_EHLO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } // Extract email from MAIL FROM: const emailMatch = args.match(/FROM:<([^>]*)>/i); if (!emailMatch) { logger.log('debug', `Invalid MAIL FROM syntax: ${args}`, { sessionId: session.id }); this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } const email = emailMatch[1]; if (!this.isValidEmail(email)) { logger.log('debug', `Invalid email address in MAIL FROM: ${email}`, { sessionId: session.id }); this.sendResponse(socket, '501 Invalid email address'); return; } // Parse any ESMTP parameters (e.g., SIZE=1234) const argsObj: Record = {}; const paramRegex = /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g; let paramMatch; while ((paramMatch = paramRegex.exec(args)) !== null) { const [, name, value = ''] = paramMatch; argsObj[name.toUpperCase()] = value; // Handle SIZE parameter validation if (name.toUpperCase() === 'SIZE' && value) { const size = parseInt(value, 10); const maxSize = this.smtpServerOptions.size || 10485760; // 10MB default if (isNaN(size)) { logger.log('debug', `Invalid SIZE parameter: ${value}`, { sessionId: session.id }); this.sendResponse(socket, '501 Invalid SIZE parameter'); return; } if (size > maxSize) { logger.log('debug', `Message size too large: ${size} > ${maxSize}`, { sessionId: session.id }); this.sendResponse(socket, `552 Message size exceeds fixed maximum message size (${maxSize})`); return; } } } logger.log('info', `MAIL FROM accepted: ${email}`, { sessionId: session.id, params: argsObj, remoteAddress: socket.remoteAddress }); // Update session state session.mailFrom = email; session.state = SmtpState.MAIL_FROM; // Update envelope information with all parameters session.envelope.mailFrom = { address: email, args: argsObj }; this.sendResponse(socket, '250 OK'); } private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } // Extract email from RCPT TO: const emailMatch = args.match(/TO:<([^>]*)>/i); if (!emailMatch) { logger.log('debug', `Invalid RCPT TO syntax: ${args}`, { sessionId: session.id }); this.sendResponse(socket, '501 Syntax error in parameters or arguments'); return; } const email = emailMatch[1]; if (!this.isValidEmail(email)) { logger.log('debug', `Invalid email address in RCPT TO: ${email}`, { sessionId: session.id }); this.sendResponse(socket, '501 Invalid email address'); return; } // Parse any ESMTP parameters const argsObj: Record = {}; const paramRegex = /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g; let paramMatch; while ((paramMatch = paramRegex.exec(args)) !== null) { const [, name, value = ''] = paramMatch; argsObj[name.toUpperCase()] = value; } // Check recipient limit if configured const maxRecipients = this.smtpServerOptions.maxRecipients || 100; if (session.rcptTo.length >= maxRecipients) { logger.log('debug', `Too many recipients: ${session.rcptTo.length + 1} > ${maxRecipients}`, { sessionId: session.id, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, `452 Too many recipients, maximum allowed is ${maxRecipients}`); return; } logger.log('info', `RCPT TO accepted: ${email}`, { sessionId: session.id, rcptCount: session.rcptTo.length + 1, remoteAddress: socket.remoteAddress }); session.rcptTo.push(email); session.state = SmtpState.RCPT_TO; // Update envelope information with all parameters session.envelope.rcptTo.push({ address: email, args: argsObj }); this.sendResponse(socket, '250 OK'); } private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.RCPT_TO) { this.sendResponse(socket, '503 Bad sequence of commands'); return; } // Ensure we have at least one recipient if (session.rcptTo.length === 0) { logger.log('debug', 'DATA command received but no recipients specified', { sessionId: session.id, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, '503 Need RCPT TO before DATA'); return; } // Reset data buffers session.emailData = ''; if (session.emailDataChunks) { session.emailDataChunks = []; } // Update state and send response session.state = SmtpState.DATA_RECEIVING; // Set a timeout for the DATA command to prevent hanging connections const dataTimeout = setTimeout(() => { if (session.state === SmtpState.DATA_RECEIVING) { logger.log('warn', 'DATA command timed out', { sessionId: session.id, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, '421 Data reception timeout'); socket.destroy(); } }, this.smtpServerOptions.dataTimeout || 60000); // 1 minute default timeout for DATA // Store the timeout ID in the session for cleanup session.dataTimeoutId = dataTimeout; // Log the DATA command logger.log('info', 'DATA command accepted, expecting message content', { sessionId: session.id, remoteAddress: socket.remoteAddress, recipientCount: session.rcptTo.length }); this.sendResponse(socket, '354 End data with .'); } private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; // Reset the session data but keep connection information session.state = SmtpState.AFTER_EHLO; session.mailFrom = ''; session.rcptTo = []; session.emailData = ''; // Clear data buffers and timeouts if (session.emailDataChunks) { session.emailDataChunks = []; } // Clear any existing data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } // Update envelope session.envelope = { mailFrom: { address: '', args: {} }, rcptTo: [] }; // Log the RSET command logger.log('debug', 'RSET command executed', { sessionId: session.id, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, '250 OK'); } private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; // Clear any existing data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } this.sendResponse(socket, '221 Goodbye'); // If we have collected email data, try to parse it before closing if (session.state === SmtpState.FINISHED && session.emailData.length > 0) { this.parseEmail(socket); } // Log the QUIT command logger.log('debug', 'QUIT command executed', { sessionId: session.id, remoteAddress: socket.remoteAddress, state: session.state }); // Close the connection socket.end(); this.sessions.delete(socket); } private async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise { const session = this.sessions.get(socket); if (!session) return; if (session.state !== SmtpState.DATA_RECEIVING) { logger.log('warn', 'Received data but not in DATA_RECEIVING state', { sessionId: session.id, state: session.state, remoteAddress: socket.remoteAddress }); return; } // Initialize email data buffer if it doesn't exist if (!session.emailDataChunks) { session.emailDataChunks = []; } // Check for end of data marker if (data.endsWith('\r\n.\r\n')) { // Remove the end of data marker const emailData = data.slice(0, -5); // Add final chunk session.emailDataChunks.push(emailData); // Join chunks efficiently session.emailData = session.emailDataChunks.join(''); // Check size limits const dataSize = Buffer.byteLength(session.emailData); const maxSize = this.smtpServerOptions.size || 10485760; // 10MB default if (dataSize > maxSize) { logger.log('warn', `Message size exceeds limit: ${dataSize} > ${maxSize}`, { sessionId: session.id, size: dataSize, limit: maxSize, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, `552 Message size exceeds fixed maximum message size (${maxSize})`); // Reset data state session.emailData = ''; session.emailDataChunks = []; session.state = SmtpState.AFTER_EHLO; // Reset to after EHLO state to allow new transaction // Clear data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } return; } // Clear data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } // Free memory for chunks session.emailDataChunks = undefined; session.state = SmtpState.FINISHED; // Log successful data reception logger.log('info', 'Email data received successfully', { sessionId: session.id, size: dataSize, sender: session.mailFrom, recipients: session.rcptTo.length, remoteAddress: socket.remoteAddress }); // Save and process the email this.saveEmail(socket); this.sendResponse(socket, '250 OK: Message accepted for delivery'); } else { // Accumulate the data as chunks session.emailDataChunks.push(data); // Check for excessive data size during accumulation // This is a rough check on accumulated chunk lengths to detect huge emails early const currentSize = session.emailDataChunks.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0); const maxSize = this.smtpServerOptions.size || 10485760; // 10MB default if (currentSize > maxSize) { logger.log('warn', `Accumulated message size exceeds limit: ${currentSize} > ${maxSize}`, { sessionId: session.id, size: currentSize, limit: maxSize, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, `552 Message size exceeds fixed maximum message size (${maxSize})`); // Reset data state session.emailData = ''; session.emailDataChunks = []; session.state = SmtpState.AFTER_EHLO; // Reset to after EHLO state // Clear data timeout if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); session.dataTimeoutId = undefined; } return; } // Reset data timeout - this allows more time for large emails that come in multiple chunks if (session.dataTimeoutId) { clearTimeout(session.dataTimeoutId); } session.dataTimeoutId = setTimeout(() => { if (session.state === SmtpState.DATA_RECEIVING) { logger.log('warn', 'DATA command timed out during data reception', { sessionId: session.id, remoteAddress: socket.remoteAddress }); this.sendResponse(socket, '421 Data reception timeout'); socket.destroy(); } }, this.smtpServerOptions.dataTimeout || 60000); // 1 minute default timeout for DATA } } private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void { const session = this.sessions.get(socket); if (!session) return; try { // Ensure the directory exists plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir); // Write the email to disk plugins.smartfile.memory.toFsSync( session.emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`) ); // Parse the email this.parseEmail(socket); } catch (error) { console.error('Error saving email:', error); } } private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise { const session = this.sessions.get(socket); if (!session || !session.emailData) { console.error('No email data found for session.'); return; } let mightBeSpam = false; // Prepare headers for DKIM verification results const customHeaders: Record = {}; // Authentication results let dkimResult = { domain: '', result: false }; let spfResult = { domain: '', result: false }; // Check security configuration const securityConfig = { verifyDkim: true, verifySpf: true, verifyDmarc: true }; // Default security settings // 1. Verify DKIM signature if enabled if (securityConfig.verifyDkim) { try { // Mock DKIM verification for now - this is temporary during migration const verificationResult = { isValid: true, domain: session.mailFrom.split('@')[1] || '', selector: 'default', status: 'pass', errorMessage: '' }; dkimResult.result = verificationResult.isValid; dkimResult.domain = verificationResult.domain || ''; if (!verificationResult.isValid) { logger.log('warn', `DKIM verification failed for incoming email: ${verificationResult.errorMessage || 'Unknown error'}`); // Enhanced security logging SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.WARN, type: SecurityEventType.DKIM, message: `DKIM verification failed for incoming email`, domain: verificationResult.domain || session.mailFrom.split('@')[1], details: { error: verificationResult.errorMessage || 'Unknown error', status: verificationResult.status, selector: verificationResult.selector, senderIP: socket.remoteAddress }, ipAddress: socket.remoteAddress, success: false }); } else { logger.log('info', `DKIM verification passed for incoming email from domain ${verificationResult.domain}`); // Enhanced security logging SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.DKIM, message: `DKIM verification passed for incoming email`, domain: verificationResult.domain, details: { selector: verificationResult.selector, status: verificationResult.status, senderIP: socket.remoteAddress }, ipAddress: socket.remoteAddress, success: true }); } // Store verification results in headers if (verificationResult.domain) { customHeaders['X-DKIM-Domain'] = verificationResult.domain; } customHeaders['X-DKIM-Status'] = verificationResult.status || 'unknown'; customHeaders['X-DKIM-Result'] = verificationResult.isValid ? 'pass' : 'fail'; } catch (error) { logger.log('error', `Failed to verify DKIM signature: ${error.message}`); customHeaders['X-DKIM-Status'] = 'error'; customHeaders['X-DKIM-Result'] = 'error'; } } // 2. Verify SPF if enabled if (securityConfig.verifySpf) { try { // Get the client IP and hostname const clientIp = socket.remoteAddress || '127.0.0.1'; const clientHostname = session.clientHostname || 'localhost'; // Parse the email to get envelope from const parsedEmail = await plugins.mailparser.simpleParser(session.emailData); // Create a temporary Email object for SPF verification const tempEmail = new Email({ from: parsedEmail.from?.value[0].address || session.mailFrom, to: session.rcptTo[0], subject: "Temporary Email for SPF Verification", text: "This is a temporary email for SPF verification" }); // Set envelope from for SPF verification tempEmail.setEnvelopeFrom(session.mailFrom); // Verify SPF using the email server's verifier const spfVerified = true; // Assume SPF verification is handled by the email server // In a real implementation, this would call: // const spfVerified = await this.emailServerRef.spfVerifier.verify(tempEmail, clientIp, clientHostname); // Update SPF result spfResult.result = spfVerified; spfResult.domain = session.mailFrom.split('@')[1] || ''; // Copy SPF headers from the temp email if (tempEmail.headers['Received-SPF']) { customHeaders['Received-SPF'] = tempEmail.headers['Received-SPF']; } // Set spam flag if SPF fails badly if (tempEmail.mightBeSpam) { mightBeSpam = true; } } catch (error) { logger.log('error', `Failed to verify SPF: ${error.message}`); customHeaders['Received-SPF'] = `error (${error.message})`; } } // 3. Verify DMARC if enabled if (securityConfig.verifyDmarc) { try { // Parse the email again const parsedEmail = await plugins.mailparser.simpleParser(session.emailData); // Create a temporary Email object for DMARC verification const tempEmail = new Email({ from: parsedEmail.from?.value[0].address || session.mailFrom, to: session.rcptTo[0], subject: "Temporary Email for DMARC Verification", text: "This is a temporary email for DMARC verification" }); // Verify DMARC - handled by email server in real implementation const dmarcResult = {}; // Apply DMARC policy - assuming we would pass if either SPF or DKIM passes const dmarcPassed = spfResult.result || dkimResult.result; // Add DMARC result to headers if (tempEmail.headers['X-DMARC-Result']) { customHeaders['X-DMARC-Result'] = tempEmail.headers['X-DMARC-Result']; } // Add Authentication-Results header combining all authentication results customHeaders['Authentication-Results'] = `${this.hostname}; ` + `spf=${spfResult.result ? 'pass' : 'fail'} smtp.mailfrom=${session.mailFrom}; ` + `dkim=${dkimResult.result ? 'pass' : 'fail'} header.d=${dkimResult.domain || 'unknown'}; ` + `dmarc=${dmarcPassed ? 'pass' : 'fail'} header.from=${tempEmail.getFromDomain()}`; // Set spam flag if DMARC fails if (tempEmail.mightBeSpam) { mightBeSpam = true; } } catch (error) { logger.log('error', `Failed to verify DMARC: ${error.message}`); customHeaders['X-DMARC-Result'] = `error (${error.message})`; } } try { const parsedEmail = await plugins.mailparser.simpleParser(session.emailData); const email = new Email({ from: parsedEmail.from?.value[0].address || session.mailFrom, to: session.rcptTo[0], // Use the first recipient headers: customHeaders, // Add our custom headers with DKIM verification results subject: parsedEmail.subject || '', text: parsedEmail.html || parsedEmail.text || '', attachments: parsedEmail.attachments?.map((attachment) => ({ filename: attachment.filename || '', content: attachment.content, contentType: attachment.contentType, })) || [], mightBeSpam: mightBeSpam, }); console.log('Email received and parsed:', { from: email.from, to: email.to, subject: email.subject, attachments: email.attachments.length, mightBeSpam: email.mightBeSpam }); // Enhanced security logging for received email SecurityLogger.getInstance().logEvent({ level: mightBeSpam ? SecurityLogLevel.WARN : SecurityLogLevel.INFO, type: mightBeSpam ? SecurityEventType.SPAM : SecurityEventType.EMAIL_VALIDATION, message: `Email received and ${mightBeSpam ? 'flagged as potential spam' : 'validated successfully'}`, domain: email.from.split('@')[1], ipAddress: socket.remoteAddress, details: { from: email.from, subject: email.subject, recipientCount: email.getAllRecipients().length, attachmentCount: email.attachments.length, hasAttachments: email.hasAttachments(), dkimStatus: customHeaders['X-DKIM-Result'] || 'unknown' }, success: !mightBeSpam }); // Process or forward the email via unified email server try { await this.emailServerRef.processEmailByMode(email, { id: session.id, state: session.state, mailFrom: session.mailFrom, rcptTo: session.rcptTo, emailData: session.emailData, useTLS: session.useTLS, connectionEnded: session.connectionEnded, remoteAddress: session.remoteAddress, clientHostname: session.clientHostname, secure: session.useTLS, authenticated: session.authenticated, envelope: session.envelope, processingMode: session.processingMode }, session.processingMode || 'process'); } catch (err) { console.error('Error in email server processing of incoming email:', err); // Log processing errors SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.EMAIL_VALIDATION, message: `Error processing incoming email`, domain: email.from.split('@')[1], ipAddress: socket.remoteAddress, details: { error: err.message, from: email.from, stack: err.stack }, success: false }); } } catch (error) { console.error('Error parsing email:', error); // Log parsing errors SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.EMAIL_VALIDATION, message: `Error parsing incoming email`, ipAddress: socket.remoteAddress, details: { error: error.message, sender: session.mailFrom, stack: error.stack }, success: false }); } } /** * Upgrade a plain socket to TLS using STARTTLS * @private */ private startTLS(socket: plugins.net.Socket): void { if (!this.smtpServerOptions.key || !this.smtpServerOptions.cert) { logger.log('error', 'Cannot upgrade to TLS: No key or certificate provided'); this.sendResponse(socket, '454 TLS not available due to temporary reason'); return; } try { logger.log('info', 'Starting TLS negotiation', { remoteAddress: socket.remoteAddress, remotePort: socket.remotePort }); // Create a secure context for TLS const secureContext = plugins.tls.createSecureContext({ key: this.smtpServerOptions.key, cert: this.smtpServerOptions.cert, ca: this.smtpServerOptions.ca, requestCert: false, rejectUnauthorized: false // Don't require client cert }); // Get the original session before upgrading const originalSession = this.sessions.get(socket); if (!originalSession) { logger.log('error', 'No session found when upgrading to TLS'); this.sendResponse(socket, '454 TLS not available: Internal error'); return; } // Log the TLS upgrade attempt SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.TLS_NEGOTIATION, message: `STARTTLS negotiation initiated`, ipAddress: socket.remoteAddress, details: { sessionId: originalSession.id, clientHostname: originalSession.clientHostname } }); // Remove existing listeners before upgrade to avoid data corruption socket.removeAllListeners('data'); socket.removeAllListeners('end'); socket.removeAllListeners('close'); socket.removeAllListeners('error'); // Store the session ID before deleting from map const sessionId = originalSession.id; // Remove the old session this.sessions.delete(socket); // Prepare options for TLS Socket const options: plugins.tls.TLSSocketOptions = { secureContext: secureContext, isServer: true, server: this.server, requestCert: false, rejectUnauthorized: false }; // Create a new TLS socket from the plain socket const tlsSocket = new plugins.tls.TLSSocket(socket, options); // Wait for secure event before sending/receiving data tlsSocket.once('secure', () => { // Create a new session for the TLS socket this.sessions.set(tlsSocket, { ...originalSession, id: sessionId, // Keep same ID to maintain timeouts useTLS: true, secure: true, state: SmtpState.GREETING, // Reset state to require a new EHLO lastActivity: Date.now() // Reset activity timer }); // Set up all event handlers for the TLS socket this.setupSocketEventHandlers(tlsSocket, sessionId); console.log(`TLS negotiation successful: ${tlsSocket.getProtocol()} with cipher ${tlsSocket.getCipher()?.name}`); // Log successful TLS upgrade as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.INFO, type: SecurityEventType.TLS_NEGOTIATION, message: `STARTTLS negotiation successful`, ipAddress: tlsSocket.remoteAddress, details: { sessionId: sessionId, protocol: tlsSocket.getProtocol(), cipher: tlsSocket.getCipher()?.name, authorized: tlsSocket.authorized } }); // Enhanced logging logger.log('info', 'TLS connection established', { protocol: tlsSocket.getProtocol(), cipher: tlsSocket.getCipher()?.name, remoteAddress: tlsSocket.remoteAddress, clientHostname: originalSession.clientHostname }); }); // Handle error during TLS negotiation tlsSocket.once('error', (error) => { console.error('Error during TLS negotiation:', error); logger.log('error', `Error during TLS negotiation: ${error.message}`, { remoteAddress: socket.remoteAddress, stack: error.stack }); // Log TLS failure as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.TLS_NEGOTIATION, message: `STARTTLS negotiation failed`, ipAddress: socket.remoteAddress, details: { error: error.message, stack: error.stack } }); try { this.sendResponse(socket, '454 TLS negotiation failed'); } catch (err) { // Socket may be closed already } // Clean up tlsSocket.destroy(); }); } catch (error) { console.error('Error upgrading connection to TLS:', error); logger.log('error', `Error upgrading connection to TLS: ${error.message}`, { remoteAddress: socket.remoteAddress, stack: error.stack }); // Log TLS failure as security event SecurityLogger.getInstance().logEvent({ level: SecurityLogLevel.ERROR, type: SecurityEventType.TLS_NEGOTIATION, message: `STARTTLS negotiation failed`, ipAddress: socket.remoteAddress, details: { error: error.message, stack: error.stack } }); // Send error response to client this.sendResponse(socket, '454 TLS negotiation failed'); socket.destroy(); } } private isValidEmail(email: string): boolean { // Basic email validation - more comprehensive validation could be implemented const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } }