This commit is contained in:
2025-05-21 12:52:24 +00:00
parent b0a0078ad0
commit 3f220996ee
14 changed files with 4773 additions and 197 deletions

File diff suppressed because it is too large Load Diff

View File

@ -140,6 +140,11 @@ export interface ISmtpSession {
* Timestamp of last activity for session timeout tracking
*/
lastActivity?: number;
/**
* Timeout ID for DATA command timeout
*/
dataTimeoutId?: NodeJS.Timeout;
}
/**
@ -186,11 +191,31 @@ export interface ISmtpServerOptions {
*/
hostname?: string;
/**
* Host address to bind to (defaults to all interfaces)
*/
host?: string;
/**
* Secure port for dedicated TLS connections
*/
securePort?: number;
/**
* CA certificates for TLS (PEM format)
*/
ca?: string;
/**
* Maximum size of messages in bytes
*/
maxSize?: number;
/**
* Maximum number of concurrent connections
*/
maxConnections?: number;
/**
* Authentication options
*/
@ -207,14 +232,37 @@ export interface ISmtpServerOptions {
};
/**
* Socket timeout in milliseconds (default: 5 minutes)
* Socket timeout in milliseconds (default: 5 minutes / 300000ms)
*/
socketTimeout?: number;
/**
* Initial connection timeout in milliseconds (default: 30 seconds)
* Initial connection timeout in milliseconds (default: 30 seconds / 30000ms)
*/
connectionTimeout?: number;
/**
* Interval for checking idle sessions in milliseconds (default: 5 seconds / 5000ms)
* For testing, can be set lower (e.g. 1000ms) to detect timeouts more quickly
*/
cleanupInterval?: number;
/**
* Maximum number of recipients allowed per message (default: 100)
*/
maxRecipients?: number;
/**
* Maximum message size in bytes (default: 10MB / 10485760 bytes)
* This is advertised in the EHLO SIZE extension
*/
size?: number;
/**
* Timeout for the DATA command in milliseconds (default: 60000ms / 1 minute)
* This controls how long to wait for the complete email data
*/
dataTimeout?: number;
}
/**

View File

@ -0,0 +1,632 @@
/**
* SMTP Command Handler
* Responsible for parsing and handling SMTP commands
*/
import * as plugins from '../../../plugins.js';
import { SmtpState, ISmtpSession, IEnvelopeRecipient } from '../interfaces.js';
import { ICommandHandler, ISessionManager, IDataHandler, ITlsHandler, ISecurityHandler } from './interfaces.js';
import { SmtpCommand, SmtpResponseCode, SMTP_DEFAULTS, SMTP_EXTENSIONS } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { extractCommandName, extractCommandArgs, formatMultilineResponse } from './utils/helpers.js';
import { validateEhlo, validateMailFrom, validateRcptTo, isValidCommandSequence } from './utils/validation.js';
/**
* Handles SMTP commands and responses
*/
export class CommandHandler implements ICommandHandler {
/**
* Session manager instance
*/
private sessionManager: ISessionManager;
/**
* Data handler instance (optional, injected when processing DATA command)
*/
private dataHandler?: IDataHandler;
/**
* TLS handler instance (optional, injected when processing STARTTLS command)
*/
private tlsHandler?: ITlsHandler;
/**
* Security handler instance (optional, used for IP reputation and authentication)
*/
private securityHandler?: ISecurityHandler;
/**
* SMTP server options
*/
private options: {
hostname: string;
size?: number;
maxRecipients: number;
auth?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
};
};
/**
* Creates a new command handler
* @param sessionManager - Session manager instance
* @param options - Command handler options
* @param dataHandler - Optional data handler instance
* @param tlsHandler - Optional TLS handler instance
* @param securityHandler - Optional security handler instance
*/
constructor(
sessionManager: ISessionManager,
options: {
hostname?: string;
size?: number;
maxRecipients?: number;
auth?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
};
} = {},
dataHandler?: IDataHandler,
tlsHandler?: ITlsHandler,
securityHandler?: ISecurityHandler
) {
this.sessionManager = sessionManager;
this.dataHandler = dataHandler;
this.tlsHandler = tlsHandler;
this.securityHandler = securityHandler;
this.options = {
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS,
auth: options.auth
};
}
/**
* Process a command from the client
* @param socket - Client socket
* @param commandLine - Command line from client
*/
public processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
SmtpLogger.warn(`No session found for socket from ${socket.remoteAddress}`);
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
socket.end();
return;
}
// Handle data state differently - pass to data handler
if (session.state === SmtpState.DATA_RECEIVING) {
if (this.dataHandler) {
// Let the data handler process the line
this.dataHandler.processEmailData(socket, commandLine)
.catch(error => {
SmtpLogger.error(`Error processing email data: ${error.message}`, {
sessionId: session.id,
error
});
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email data: ${error.message}`);
this.resetSession(session);
});
} else {
// No data handler available
SmtpLogger.error('Data handler not available', { sessionId: session.id });
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - data handler not available`);
this.resetSession(session);
}
return;
}
// Log received command
SmtpLogger.logCommand(commandLine, socket, session);
// Extract command and arguments
const command = extractCommandName(commandLine);
const args = extractCommandArgs(commandLine);
// Validate command sequence
if (!this.validateCommandSequence(command, session)) {
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} Bad sequence of commands`);
return;
}
// Process the command
switch (command) {
case SmtpCommand.EHLO:
case SmtpCommand.HELO:
this.handleEhlo(socket, args);
break;
case SmtpCommand.MAIL_FROM:
this.handleMailFrom(socket, args);
break;
case SmtpCommand.RCPT_TO:
this.handleRcptTo(socket, args);
break;
case SmtpCommand.DATA:
this.handleData(socket);
break;
case SmtpCommand.RSET:
this.handleRset(socket);
break;
case SmtpCommand.NOOP:
this.handleNoop(socket);
break;
case SmtpCommand.QUIT:
this.handleQuit(socket);
break;
case SmtpCommand.STARTTLS:
if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) {
this.tlsHandler.handleStartTls(socket);
} else {
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} STARTTLS not available`);
}
break;
case SmtpCommand.AUTH:
this.handleAuth(socket, args);
break;
case SmtpCommand.HELP:
this.handleHelp(socket, args);
break;
default:
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Command not implemented`);
break;
}
}
/**
* Send a response to the client
* @param socket - Client socket
* @param response - Response to send
*/
public sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
try {
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
SmtpLogger.logResponse(response, socket);
} catch (error) {
// Log error and destroy socket
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
response,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
socket.destroy();
}
}
/**
* Handle EHLO command
* @param socket - Client socket
* @param clientHostname - Client hostname from EHLO command
*/
public handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Validate EHLO hostname
const validation = validateEhlo(clientHostname);
if (!validation.isValid) {
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
return;
}
// Update session state and client hostname
session.clientHostname = validation.hostname || clientHostname;
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
// Set up EHLO response lines
const responseLines = [
`${this.options.hostname} greets ${session.clientHostname}`,
SMTP_EXTENSIONS.PIPELINING,
SMTP_EXTENSIONS.formatExtension(SMTP_EXTENSIONS.SIZE, this.options.size),
SMTP_EXTENSIONS.EIGHTBITMIME,
SMTP_EXTENSIONS.ENHANCEDSTATUSCODES
];
// Add TLS extension if available and not already using TLS
if (this.tlsHandler && this.tlsHandler.isTlsEnabled() && !session.useTLS) {
responseLines.push(SMTP_EXTENSIONS.STARTTLS);
}
// Add AUTH extension if configured
if (this.options.auth && this.options.auth.methods && this.options.auth.methods.length > 0) {
responseLines.push(`${SMTP_EXTENSIONS.AUTH} ${this.options.auth.methods.join(' ')}`);
}
// Send multiline response
this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.OK, responseLines));
}
/**
* Handle MAIL FROM command
* @param socket - Client socket
* @param args - Command arguments
*/
public handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Check if authentication is required but not provided
if (this.options.auth && this.options.auth.required && !session.authenticated) {
this.sendResponse(socket, `${SmtpResponseCode.AUTH_REQUIRED} Authentication required`);
return;
}
// Validate MAIL FROM syntax
const validation = validateMailFrom(args);
if (!validation.isValid) {
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
return;
}
// Check size parameter if provided
if (validation.params && validation.params.SIZE) {
const size = parseInt(validation.params.SIZE, 10);
if (isNaN(size)) {
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} Invalid SIZE parameter`);
return;
}
if (size > this.options.size!) {
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message size exceeds limit`);
return;
}
}
// Reset email data and recipients for new transaction
session.mailFrom = validation.address || '';
session.rcptTo = [];
session.emailData = '';
session.emailDataChunks = [];
// Update envelope information
session.envelope = {
mailFrom: {
address: validation.address || '',
args: validation.params || {}
},
rcptTo: []
};
// Update session state
this.sessionManager.updateSessionState(session, SmtpState.MAIL_FROM);
// Send success response
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
}
/**
* Handle RCPT TO command
* @param socket - Client socket
* @param args - Command arguments
*/
public handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Validate RCPT TO syntax
const validation = validateRcptTo(args);
if (!validation.isValid) {
this.sendResponse(socket, `${SmtpResponseCode.SYNTAX_ERROR_PARAMETERS} ${validation.errorMessage}`);
return;
}
// Check if we've reached maximum recipients
if (session.rcptTo.length >= this.options.maxRecipients) {
this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Too many recipients`);
return;
}
// Create recipient object
const recipient: IEnvelopeRecipient = {
address: validation.address || '',
args: validation.params || {}
};
// Add to session data
session.rcptTo.push(validation.address || '');
session.envelope.rcptTo.push(recipient);
// Update session state
this.sessionManager.updateSessionState(session, SmtpState.RCPT_TO);
// Send success response
this.sendResponse(socket, `${SmtpResponseCode.OK} Recipient ok`);
}
/**
* Handle DATA command
* @param socket - Client socket
*/
public handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Check if we have recipients
if (!session.rcptTo.length) {
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} No recipients specified`);
return;
}
// Update session state
this.sessionManager.updateSessionState(session, SmtpState.DATA_RECEIVING);
// Reset email data storage
session.emailData = '';
session.emailDataChunks = [];
// Set up timeout for DATA command
const dataTimeout = SMTP_DEFAULTS.DATA_TIMEOUT;
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
}
session.dataTimeoutId = setTimeout(() => {
if (session.state === SmtpState.DATA_RECEIVING) {
SmtpLogger.warn(`DATA command timeout for session ${session.id}`, {
sessionId: session.id,
timeout: dataTimeout
});
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`);
this.resetSession(session);
}
}, dataTimeout);
// Send intermediate response to signal start of data
this.sendResponse(socket, `${SmtpResponseCode.START_MAIL_INPUT} Start mail input; end with <CRLF>.<CRLF>`);
}
/**
* Handle RSET command
* @param socket - Client socket
*/
public handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Reset the transaction state
this.resetSession(session);
// Send success response
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
}
/**
* Handle NOOP command
* @param socket - Client socket
*/
public handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Update session activity timestamp
this.sessionManager.updateSessionActivity(session);
// Send success response
this.sendResponse(socket, `${SmtpResponseCode.OK} OK`);
}
/**
* Handle QUIT command
* @param socket - Client socket
*/
public handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
// Send goodbye message
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`);
// End the connection
socket.end();
// Clean up session if we have one
if (session) {
this.sessionManager.removeSession(socket);
}
}
/**
* Handle AUTH command
* @param socket - Client socket
* @param args - Command arguments
*/
private handleAuth(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Check if we have auth config
if (!this.options.auth || !this.options.auth.methods || !this.options.auth.methods.length) {
this.sendResponse(socket, `${SmtpResponseCode.COMMAND_NOT_IMPLEMENTED} Authentication not supported`);
return;
}
// Check if TLS is required for authentication
if (!session.useTLS) {
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication requires TLS`);
return;
}
// Simple response for now - authentication would be implemented in the security handler
this.sendResponse(socket, `${SmtpResponseCode.AUTH_FAILED} Authentication not implemented yet`);
}
/**
* Handle HELP command
* @param socket - Client socket
* @param args - Command arguments
*/
private handleHelp(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Update session activity timestamp
this.sessionManager.updateSessionActivity(session);
// Provide help information based on arguments
const helpCommand = args.trim().toUpperCase();
if (!helpCommand) {
// General help
const helpLines = [
'Supported commands:',
'EHLO/HELO domain - Identify yourself to the server',
'MAIL FROM:<address> - Start a new mail transaction',
'RCPT TO:<address> - Specify recipients for the message',
'DATA - Start message data input',
'RSET - Reset the transaction',
'NOOP - No operation',
'QUIT - Close the connection',
'HELP [command] - Show help'
];
// Add conditional commands
if (this.tlsHandler && this.tlsHandler.isTlsEnabled()) {
helpLines.push('STARTTLS - Start TLS negotiation');
}
if (this.options.auth && this.options.auth.methods.length) {
helpLines.push('AUTH mechanism - Authenticate with the server');
}
this.sendResponse(socket, formatMultilineResponse(SmtpResponseCode.HELP_MESSAGE, helpLines));
return;
}
// Command-specific help
let helpText: string;
switch (helpCommand) {
case 'EHLO':
case 'HELO':
helpText = 'EHLO/HELO domain - Identify yourself to the server';
break;
case 'MAIL':
helpText = 'MAIL FROM:<address> [SIZE=size] - Start a new mail transaction';
break;
case 'RCPT':
helpText = 'RCPT TO:<address> - Specify a recipient for the message';
break;
case 'DATA':
helpText = 'DATA - Start message data input, end with <CRLF>.<CRLF>';
break;
case 'RSET':
helpText = 'RSET - Reset the transaction';
break;
case 'NOOP':
helpText = 'NOOP - No operation';
break;
case 'QUIT':
helpText = 'QUIT - Close the connection';
break;
case 'STARTTLS':
helpText = 'STARTTLS - Start TLS negotiation';
break;
case 'AUTH':
helpText = `AUTH mechanism - Authenticate with the server. Supported methods: ${this.options.auth?.methods.join(', ')}`;
break;
default:
helpText = `Unknown command: ${helpCommand}`;
break;
}
this.sendResponse(socket, `${SmtpResponseCode.HELP_MESSAGE} ${helpText}`);
}
/**
* Reset session to after-EHLO state
* @param session - SMTP session to reset
*/
private resetSession(session: ISmtpSession): void {
// Clear any data timeout
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
session.dataTimeoutId = undefined;
}
// Reset data fields but keep authentication state
session.mailFrom = '';
session.rcptTo = [];
session.emailData = '';
session.emailDataChunks = [];
session.envelope = {
mailFrom: { address: '', args: {} },
rcptTo: []
};
// Reset state to after EHLO
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
}
/**
* Validate command sequence based on current state
* @param command - Command to validate
* @param session - Current session
* @returns Whether the command is valid in the current state
*/
private validateCommandSequence(command: string, session: ISmtpSession): boolean {
return isValidCommandSequence(command, session.state);
}
}

View File

@ -0,0 +1,363 @@
/**
* SMTP Connection Manager
* Responsible for managing socket connections to the SMTP server
*/
import * as plugins from '../../../plugins.js';
import { IConnectionManager } from './interfaces.js';
import { ISessionManager } from './interfaces.js';
import { SmtpResponseCode, SMTP_DEFAULTS } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { getSocketDetails, formatMultilineResponse } from './utils/helpers.js';
/**
* Manager for SMTP connections
* Handles connection setup, event listeners, and lifecycle management
*/
export class ConnectionManager implements IConnectionManager {
/**
* Set of active socket connections
*/
private activeConnections: Set<plugins.net.Socket | plugins.tls.TLSSocket> = new Set();
/**
* Reference to the session manager
*/
private sessionManager: ISessionManager;
/**
* SMTP server options
*/
private options: {
hostname: string;
maxConnections: number;
socketTimeout: number;
};
/**
* Command handler function
*/
private commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void;
/**
* Creates a new connection manager
* @param sessionManager - Session manager instance
* @param commandHandler - Command handler function
* @param options - Connection manager options
*/
constructor(
sessionManager: ISessionManager,
commandHandler: (socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string) => void,
options: {
hostname?: string;
maxConnections?: number;
socketTimeout?: number;
} = {}
) {
this.sessionManager = sessionManager;
this.commandHandler = commandHandler;
this.options = {
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT
};
}
/**
* Handle a new connection
* @param socket - Client socket
*/
public handleNewConnection(socket: plugins.net.Socket): void {
// Check if maximum connections reached
if (this.hasReachedMaxConnections()) {
this.rejectConnection(socket, 'Too many connections');
return;
}
// Add socket to active connections
this.activeConnections.add(socket);
// Set up socket options
socket.setKeepAlive(true);
socket.setTimeout(this.options.socketTimeout);
// Set up event handlers
this.setupSocketEventHandlers(socket);
// Create a session for this connection
this.sessionManager.createSession(socket, false);
// Log the new connection
const socketDetails = getSocketDetails(socket);
SmtpLogger.logConnection(socket, 'connect');
// Send greeting
this.sendGreeting(socket);
}
/**
* Handle a new secure TLS connection
* @param socket - Client TLS socket
*/
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
// Check if maximum connections reached
if (this.hasReachedMaxConnections()) {
this.rejectConnection(socket, 'Too many connections');
return;
}
// Add socket to active connections
this.activeConnections.add(socket);
// Set up socket options
socket.setKeepAlive(true);
socket.setTimeout(this.options.socketTimeout);
// Set up event handlers
this.setupSocketEventHandlers(socket);
// Create a session for this connection
this.sessionManager.createSession(socket, true);
// Log the new secure connection
SmtpLogger.logConnection(socket, 'connect');
// Send greeting
this.sendGreeting(socket);
}
/**
* Set up event handlers for a socket
* @param socket - Client socket
*/
public setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Store existing socket event handlers before adding new ones
const existingDataHandler = socket.listeners('data')[0];
const existingCloseHandler = socket.listeners('close')[0];
const existingErrorHandler = socket.listeners('error')[0];
const existingTimeoutHandler = socket.listeners('timeout')[0];
// Remove existing event handlers if they exist
if (existingDataHandler) socket.removeListener('data', existingDataHandler);
if (existingCloseHandler) socket.removeListener('close', existingCloseHandler);
if (existingErrorHandler) socket.removeListener('error', existingErrorHandler);
if (existingTimeoutHandler) socket.removeListener('timeout', existingTimeoutHandler);
// Data event - process incoming data from the client
let buffer = '';
socket.on('data', (data) => {
// Get current session and update activity timestamp
const session = this.sessionManager.getSession(socket);
if (session) {
this.sessionManager.updateSessionActivity(session);
}
// Buffer incoming data
buffer += data.toString();
// Process complete lines
let lineEndPos;
while ((lineEndPos = buffer.indexOf(SMTP_DEFAULTS.CRLF)) !== -1) {
// Extract a complete line
const line = buffer.substring(0, lineEndPos);
buffer = buffer.substring(lineEndPos + 2); // +2 to skip CRLF
// Process non-empty lines
if (line.length > 0) {
// In DATA state, the command handler will process the data differently
this.commandHandler(socket, line);
}
}
});
// Close event - clean up when connection is closed
socket.on('close', (hadError) => {
this.handleSocketClose(socket, hadError);
});
// Error event - handle socket errors
socket.on('error', (err) => {
this.handleSocketError(socket, err);
});
// Timeout event - handle socket timeouts
socket.on('timeout', () => {
this.handleSocketTimeout(socket);
});
}
/**
* Get the current connection count
* @returns Number of active connections
*/
public getConnectionCount(): number {
return this.activeConnections.size;
}
/**
* Check if the server has reached the maximum number of connections
* @returns True if max connections reached
*/
public hasReachedMaxConnections(): boolean {
return this.activeConnections.size >= this.options.maxConnections;
}
/**
* Close all active connections
*/
public closeAllConnections(): void {
const connectionCount = this.activeConnections.size;
if (connectionCount === 0) {
return;
}
SmtpLogger.info(`Closing all connections (count: ${connectionCount})`);
for (const socket of this.activeConnections) {
try {
// Send service closing notification
this.sendServiceClosing(socket);
// End the socket
socket.end();
} catch (error) {
SmtpLogger.error(`Error closing connection: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Clear active connections
this.activeConnections.clear();
}
/**
* Handle socket close event
* @param socket - Client socket
* @param hadError - Whether the socket was closed due to error
*/
private handleSocketClose(socket: plugins.net.Socket | plugins.tls.TLSSocket, hadError: boolean): void {
// Remove from active connections
this.activeConnections.delete(socket);
// Get the session before removing it
const session = this.sessionManager.getSession(socket);
// Remove from session manager
this.sessionManager.removeSession(socket);
// Log connection close
SmtpLogger.logConnection(socket, 'close', session);
}
/**
* Handle socket error event
* @param socket - Client socket
* @param error - Error object
*/
private handleSocketError(socket: plugins.net.Socket | plugins.tls.TLSSocket, error: Error): void {
// Get the session
const session = this.sessionManager.getSession(socket);
// Log the error
SmtpLogger.logConnection(socket, 'error', session, error);
// Close the socket if not already closed
if (!socket.destroyed) {
socket.destroy();
}
}
/**
* Handle socket timeout event
* @param socket - Client socket
*/
private handleSocketTimeout(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session
const session = this.sessionManager.getSession(socket);
if (session) {
// Log the timeout
SmtpLogger.warn(`Socket timeout from ${session.remoteAddress}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
state: session.state,
timeout: this.options.socketTimeout
});
// Send timeout notification
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} Connection timeout - closing connection`);
} else {
// Log timeout without session context
const socketDetails = getSocketDetails(socket);
SmtpLogger.warn(`Socket timeout without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
}
// Close the socket
try {
socket.end();
} catch (error) {
SmtpLogger.error(`Error ending timed out socket: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Reject a connection
* @param socket - Client socket
* @param reason - Reason for rejection
*/
private rejectConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, reason: string): void {
// Log the rejection
const socketDetails = getSocketDetails(socket);
SmtpLogger.warn(`Connection rejected from ${socketDetails.remoteAddress}:${socketDetails.remotePort}: ${reason}`);
// Send rejection message
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_NOT_AVAILABLE} ${this.options.hostname} Service temporarily unavailable - ${reason}`);
// Close the socket
try {
socket.end();
} catch (error) {
SmtpLogger.error(`Error ending rejected socket: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Send greeting message
* @param socket - Client socket
*/
private sendGreeting(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const greeting = `${SmtpResponseCode.SERVICE_READY} ${this.options.hostname} ESMTP service ready`;
this.sendResponse(socket, greeting);
}
/**
* Send service closing notification
* @param socket - Client socket
*/
private sendServiceClosing(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const message = `${SmtpResponseCode.SERVICE_CLOSING} ${this.options.hostname} Service closing transmission channel`;
this.sendResponse(socket, message);
}
/**
* Send response to client
* @param socket - Client socket
* @param response - Response to send
*/
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
try {
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
SmtpLogger.logResponse(response, socket);
} catch (error) {
// Log error and destroy socket
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
response,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
socket.destroy();
}
}
}

View File

@ -0,0 +1,176 @@
/**
* SMTP Server Constants
* This file contains all constants and enums used by the SMTP server
*/
import { SmtpState } from '../interfaces.js';
// Re-export SmtpState enum from the main interfaces file
export { SmtpState };
/**
* SMTP Response Codes
* Based on RFC 5321 and common SMTP practice
*/
export enum SmtpResponseCode {
// Success codes (2xx)
SUCCESS = 250, // Requested mail action okay, completed
SYSTEM_STATUS = 211, // System status, or system help reply
HELP_MESSAGE = 214, // Help message
SERVICE_READY = 220, // <domain> Service ready
SERVICE_CLOSING = 221, // <domain> Service closing transmission channel
AUTHENTICATION_SUCCESSFUL = 235, // Authentication successful
OK = 250, // Requested mail action okay, completed
FORWARD = 251, // User not local; will forward to <forward-path>
CANNOT_VRFY = 252, // Cannot VRFY user, but will accept message and attempt delivery
// Intermediate codes (3xx)
MORE_INFO_NEEDED = 334, // Server challenge for authentication
START_MAIL_INPUT = 354, // Start mail input; end with <CRLF>.<CRLF>
// Temporary error codes (4xx)
SERVICE_NOT_AVAILABLE = 421, // <domain> Service not available, closing transmission channel
MAILBOX_TEMPORARILY_UNAVAILABLE = 450, // Requested mail action not taken: mailbox unavailable
LOCAL_ERROR = 451, // Requested action aborted: local error in processing
INSUFFICIENT_STORAGE = 452, // Requested action not taken: insufficient system storage
TLS_UNAVAILABLE_TEMP = 454, // TLS not available due to temporary reason
// Permanent error codes (5xx)
SYNTAX_ERROR = 500, // Syntax error, command unrecognized
SYNTAX_ERROR_PARAMETERS = 501, // Syntax error in parameters or arguments
COMMAND_NOT_IMPLEMENTED = 502, // Command not implemented
BAD_SEQUENCE = 503, // Bad sequence of commands
COMMAND_PARAMETER_NOT_IMPLEMENTED = 504, // Command parameter not implemented
AUTH_REQUIRED = 530, // Authentication required
AUTH_FAILED = 535, // Authentication credentials invalid
MAILBOX_UNAVAILABLE = 550, // Requested action not taken: mailbox unavailable
USER_NOT_LOCAL = 551, // User not local; please try <forward-path>
EXCEEDED_STORAGE = 552, // Requested mail action aborted: exceeded storage allocation
MAILBOX_NAME_INVALID = 553, // Requested action not taken: mailbox name not allowed
TRANSACTION_FAILED = 554, // Transaction failed
MAIL_RCPT_PARAMETERS_INVALID = 555, // MAIL FROM/RCPT TO parameters not recognized or not implemented
}
/**
* SMTP Command Types
*/
export enum SmtpCommand {
HELO = 'HELO',
EHLO = 'EHLO',
MAIL_FROM = 'MAIL',
RCPT_TO = 'RCPT',
DATA = 'DATA',
RSET = 'RSET',
NOOP = 'NOOP',
QUIT = 'QUIT',
STARTTLS = 'STARTTLS',
AUTH = 'AUTH',
HELP = 'HELP',
VRFY = 'VRFY',
EXPN = 'EXPN',
}
/**
* Security log event types
*/
export enum SecurityEventType {
CONNECTION = 'connection',
AUTHENTICATION = 'authentication',
COMMAND = 'command',
DATA = 'data',
IP_REPUTATION = 'ip_reputation',
TLS_NEGOTIATION = 'tls_negotiation',
DKIM = 'dkim',
SPF = 'spf',
DMARC = 'dmarc',
EMAIL_VALIDATION = 'email_validation',
SPAM = 'spam',
ACCESS_CONTROL = 'access_control',
}
/**
* Security log levels
*/
export enum SecurityLogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error',
}
/**
* SMTP Server Defaults
*/
export const SMTP_DEFAULTS = {
// Default timeouts in milliseconds
CONNECTION_TIMEOUT: 30000, // 30 seconds
SOCKET_TIMEOUT: 300000, // 5 minutes
DATA_TIMEOUT: 60000, // 1 minute
CLEANUP_INTERVAL: 5000, // 5 seconds
// Default limits
MAX_CONNECTIONS: 100,
MAX_RECIPIENTS: 100,
MAX_MESSAGE_SIZE: 10485760, // 10MB
// Default ports
SMTP_PORT: 25,
SUBMISSION_PORT: 587,
SECURE_PORT: 465,
// Default hostname
HOSTNAME: 'mail.lossless.one',
// CRLF line ending required by SMTP protocol
CRLF: '\r\n',
};
/**
* SMTP Command Patterns
* Regular expressions for parsing SMTP commands
*/
export const SMTP_PATTERNS = {
// Match EHLO/HELO command: "EHLO example.com"
EHLO: /^(?:EHLO|HELO)\s+(.+)$/i,
// Match MAIL FROM command: "MAIL FROM:<user@example.com> [PARAM=VALUE]"
MAIL_FROM: /^MAIL\s+FROM:<([^>]*)>((?:\s+\w+(?:=\w+)?)*)$/i,
// Match RCPT TO command: "RCPT TO:<user@example.com> [PARAM=VALUE]"
RCPT_TO: /^RCPT\s+TO:<([^>]*)>((?:\s+\w+(?:=\w+)?)*)$/i,
// Match parameter format: "PARAM=VALUE"
PARAM: /\s+([A-Za-z0-9][A-Za-z0-9\-]*)(?:=([^\s]+))?/g,
// Match email address format
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
// Match end of DATA marker: \r\n.\r\n
END_DATA: /\r\n\.\r\n$/,
};
/**
* SMTP Extension List
* These extensions are advertised in the EHLO response
*/
export const SMTP_EXTENSIONS = {
// Basic extensions (RFC 1869)
PIPELINING: 'PIPELINING',
SIZE: 'SIZE',
EIGHTBITMIME: '8BITMIME',
// Security extensions
STARTTLS: 'STARTTLS',
AUTH: 'AUTH',
// Additional extensions
ENHANCEDSTATUSCODES: 'ENHANCEDSTATUSCODES',
HELP: 'HELP',
CHUNKING: 'CHUNKING',
DSN: 'DSN',
// Format an extension with a parameter
formatExtension(name: string, parameter?: string | number): string {
return parameter !== undefined ? `${name} ${parameter}` : name;
}
};

View File

@ -0,0 +1,386 @@
/**
* SMTP Data Handler
* Responsible for processing email data during and after DATA command
*/
import * as plugins from '../../../plugins.js';
import * as fs from 'fs';
import * as path from 'path';
import { SmtpState, ISmtpSession, ISmtpTransactionResult } from '../interfaces.js';
import { IDataHandler, ISessionManager } from './interfaces.js';
import { SmtpResponseCode, SMTP_PATTERNS, SMTP_DEFAULTS } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { Email } from '../../core/classes.email.js';
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
/**
* Handles SMTP DATA command and email data processing
*/
export class DataHandler implements IDataHandler {
/**
* Session manager instance
*/
private sessionManager: ISessionManager;
/**
* Email server reference
*/
private emailServer: UnifiedEmailServer;
/**
* SMTP server options
*/
private options: {
size: number;
tempDir?: string;
};
/**
* Creates a new data handler
* @param sessionManager - Session manager instance
* @param emailServer - Email server reference
* @param options - Data handler options
*/
constructor(
sessionManager: ISessionManager,
emailServer: UnifiedEmailServer,
options: {
size?: number;
tempDir?: string;
} = {}
) {
this.sessionManager = sessionManager;
this.emailServer = emailServer;
this.options = {
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
tempDir: options.tempDir
};
// Create temp directory if specified and doesn't exist
if (this.options.tempDir) {
try {
if (!fs.existsSync(this.options.tempDir)) {
fs.mkdirSync(this.options.tempDir, { recursive: true });
}
} catch (error) {
SmtpLogger.error(`Failed to create temp directory: ${error instanceof Error ? error.message : String(error)}`, {
error: error instanceof Error ? error : new Error(String(error)),
tempDir: this.options.tempDir
});
this.options.tempDir = undefined;
}
}
}
/**
* Process incoming email data
* @param socket - Client socket
* @param data - Data chunk
* @returns Promise that resolves when the data is processed
*/
public async processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void> {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Clear any existing timeout and set a new one
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
}
session.dataTimeoutId = setTimeout(() => {
if (session.state === SmtpState.DATA_RECEIVING) {
SmtpLogger.warn(`DATA timeout for session ${session.id}`, { sessionId: session.id });
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Data timeout`);
this.resetSession(session);
}
}, SMTP_DEFAULTS.DATA_TIMEOUT);
// Update activity timestamp
this.sessionManager.updateSessionActivity(session);
// Store data in chunks for better memory efficiency
if (!session.emailDataChunks) {
session.emailDataChunks = [];
}
session.emailDataChunks.push(data);
// Check if we've reached the max size
let totalSize = 0;
for (const chunk of session.emailDataChunks) {
totalSize += chunk.length;
}
if (totalSize > this.options.size) {
SmtpLogger.warn(`Message size exceeds limit for session ${session.id}`, {
sessionId: session.id,
size: totalSize,
limit: this.options.size
});
this.sendResponse(socket, `${SmtpResponseCode.EXCEEDED_STORAGE} Message too big, size limit is ${this.options.size} bytes`);
this.resetSession(session);
return;
}
// Check for end of data marker
const lastChunk = session.emailDataChunks[session.emailDataChunks.length - 1] || '';
if (SMTP_PATTERNS.END_DATA.test(lastChunk)) {
// End of data marker found
await this.handleEndOfData(socket, session);
}
}
/**
* Process a complete email
* @param session - SMTP session
* @returns Promise that resolves with the result of the transaction
*/
public async processEmail(session: ISmtpSession): Promise<ISmtpTransactionResult> {
// Combine all chunks and remove end of data marker
session.emailData = (session.emailDataChunks || []).join('');
// Remove trailing end-of-data marker: \r\n.\r\n
session.emailData = session.emailData.replace(/\r\n\.\r\n$/, '');
// Remove dot-stuffing (RFC 5321, section 4.5.2)
session.emailData = session.emailData.replace(/\r\n\.\./g, '\r\n.');
try {
// Parse email into Email object
const email = await this.parseEmail(session);
// Process the email based on the processing mode
const processingMode = session.processingMode || 'mta';
let result: ISmtpTransactionResult = {
success: false,
error: 'Email processing failed'
};
switch (processingMode) {
case 'mta':
// Process through the MTA system
try {
SmtpLogger.debug(`Processing email in MTA mode for session ${session.id}`, {
sessionId: session.id,
messageId: email.getMessageId()
});
// Queue the email for further processing by the email server
const messageId = await this.emailServer.queueEmail(email);
result = {
success: true,
messageId,
email
};
} catch (error) {
SmtpLogger.error(`Failed to queue email: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
error: error instanceof Error ? error : new Error(String(error))
});
result = {
success: false,
error: `Failed to queue email: ${error instanceof Error ? error.message : String(error)}`
};
}
break;
case 'forward':
// Forward email to another server
SmtpLogger.debug(`Processing email in FORWARD mode for session ${session.id}`, {
sessionId: session.id,
messageId: email.getMessageId()
});
// Forward logic would be implemented here
result = {
success: true,
messageId: email.getMessageId(),
email
};
break;
case 'process':
// Process the email immediately
SmtpLogger.debug(`Processing email in PROCESS mode for session ${session.id}`, {
sessionId: session.id,
messageId: email.getMessageId()
});
// Direct processing logic would be implemented here
result = {
success: true,
messageId: email.getMessageId(),
email
};
break;
default:
SmtpLogger.warn(`Unknown processing mode: ${processingMode}`, { sessionId: session.id });
result = {
success: false,
error: `Unknown processing mode: ${processingMode}`
};
}
return result;
} catch (error) {
SmtpLogger.error(`Failed to parse email: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
error: error instanceof Error ? error : new Error(String(error))
});
return {
success: false,
error: `Failed to parse email: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Save an email to disk
* @param session - SMTP session
*/
public saveEmail(session: ISmtpSession): void {
if (!this.options.tempDir) {
return;
}
try {
const timestamp = Date.now();
const filename = `${session.id}-${timestamp}.eml`;
const filePath = path.join(this.options.tempDir, filename);
fs.writeFileSync(filePath, session.emailData);
SmtpLogger.debug(`Saved email to disk: ${filePath}`, {
sessionId: session.id,
filePath
});
} catch (error) {
SmtpLogger.error(`Failed to save email to disk: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
error: error instanceof Error ? error : new Error(String(error))
});
}
}
/**
* Parse an email into an Email object
* @param session - SMTP session
* @returns Promise that resolves with the parsed Email object
*/
public async parseEmail(session: ISmtpSession): Promise<Email> {
// Create a new Email object
const email = new Email();
// Set envelope information from SMTP session
email.setFrom(session.envelope.mailFrom.address);
for (const recipient of session.envelope.rcptTo) {
email.addTo(recipient.address);
}
// Parse the raw email data
await email.parseFromRaw(session.emailData);
return email;
}
/**
* Handle end of data marker received
* @param socket - Client socket
* @param session - SMTP session
*/
private async handleEndOfData(socket: plugins.net.Socket | plugins.tls.TLSSocket, session: ISmtpSession): Promise<void> {
// Clear the data timeout
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
session.dataTimeoutId = undefined;
}
try {
// Update session state
this.sessionManager.updateSessionState(session, SmtpState.FINISHED);
// Optionally save email to disk
this.saveEmail(session);
// Process the email
const result = await this.processEmail(session);
if (result.success) {
// Send success response
this.sendResponse(socket, `${SmtpResponseCode.OK} OK message queued as ${result.messageId}`);
} else {
// Send error response
this.sendResponse(socket, `${SmtpResponseCode.TRANSACTION_FAILED} Failed to process email: ${result.error}`);
}
// Reset session for new transaction
this.resetSession(session);
} catch (error) {
SmtpLogger.error(`Error processing email: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
error: error instanceof Error ? error : new Error(String(error))
});
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Error processing email: ${error instanceof Error ? error.message : String(error)}`);
this.resetSession(session);
}
}
/**
* Reset session after email processing
* @param session - SMTP session
*/
private resetSession(session: ISmtpSession): void {
// Clear any data timeout
if (session.dataTimeoutId) {
clearTimeout(session.dataTimeoutId);
session.dataTimeoutId = undefined;
}
// Reset data fields but keep authentication state
session.mailFrom = '';
session.rcptTo = [];
session.emailData = '';
session.emailDataChunks = [];
session.envelope = {
mailFrom: { address: '', args: {} },
rcptTo: []
};
// Reset state to after EHLO
this.sessionManager.updateSessionState(session, SmtpState.AFTER_EHLO);
}
/**
* Send a response to the client
* @param socket - Client socket
* @param response - Response message
*/
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
try {
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
SmtpLogger.logResponse(response, socket);
} catch (error) {
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
response,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
socket.destroy();
}
}
}

View File

@ -0,0 +1,348 @@
/**
* SMTP Server Module Interfaces
* This file contains all interfaces for the refactored SMTP server implementation
*/
import * as plugins from '../../../plugins.js';
import type { Email } from '../../core/classes.email.js';
import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
import { SmtpState, EmailProcessingMode, IEnvelopeRecipient, ISmtpEnvelope, ISmtpSession, ISmtpAuth, ISmtpServerOptions, ISmtpTransactionResult } from '../interfaces.js';
// Re-export the basic interfaces from the main interfaces file
export {
SmtpState,
EmailProcessingMode,
IEnvelopeRecipient,
ISmtpEnvelope,
ISmtpSession,
ISmtpAuth,
ISmtpServerOptions,
ISmtpTransactionResult
};
/**
* Interface for SMTP session events
* These events are emitted by the session manager
*/
export interface ISessionEvents {
created: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
stateChanged: (session: ISmtpSession, previousState: SmtpState, newState: SmtpState) => void;
timeout: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
completed: (session: ISmtpSession, socket: plugins.net.Socket | plugins.tls.TLSSocket) => void;
error: (session: ISmtpSession, error: Error) => void;
}
/**
* Interface for the session manager component
*/
export interface ISessionManager {
/**
* Creates a new session for a socket connection
*/
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): ISmtpSession;
/**
* Updates the session state
*/
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
/**
* Updates the session's last activity timestamp
*/
updateSessionActivity(session: ISmtpSession): void;
/**
* Removes a session
*/
removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Gets a session for a socket
*/
getSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession | undefined;
/**
* Cleans up idle sessions
*/
cleanupIdleSessions(): void;
/**
* Gets the current number of active sessions
*/
getSessionCount(): number;
/**
* Clears all sessions (used when shutting down)
*/
clearAllSessions(): void;
/**
* Register an event listener
*/
on<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
/**
* Remove an event listener
*/
off<K extends keyof ISessionEvents>(event: K, listener: ISessionEvents[K]): void;
}
/**
* Interface for the connection manager component
*/
export interface IConnectionManager {
/**
* Handle a new connection
*/
handleNewConnection(socket: plugins.net.Socket): void;
/**
* Handle a new secure TLS connection
*/
handleNewSecureConnection(socket: plugins.tls.TLSSocket): void;
/**
* Set up event handlers for a socket
*/
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Get the current connection count
*/
getConnectionCount(): number;
/**
* Check if the server has reached the maximum number of connections
*/
hasReachedMaxConnections(): boolean;
/**
* Close all active connections
*/
closeAllConnections(): void;
}
/**
* Interface for the command handler component
*/
export interface ICommandHandler {
/**
* Process a command from the client
*/
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void;
/**
* Send a response to the client
*/
sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void;
/**
* Handle EHLO command
*/
handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void;
/**
* Handle MAIL FROM command
*/
handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
/**
* Handle RCPT TO command
*/
handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void;
/**
* Handle DATA command
*/
handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Handle RSET command
*/
handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Handle NOOP command
*/
handleNoop(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Handle QUIT command
*/
handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
}
/**
* Interface for the data handler component
*/
export interface IDataHandler {
/**
* Process incoming email data
*/
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
/**
* Process a complete email
*/
processEmail(session: ISmtpSession): Promise<ISmtpTransactionResult>;
/**
* Save an email to disk
*/
saveEmail(session: ISmtpSession): void;
/**
* Parse an email into an Email object
*/
parseEmail(session: ISmtpSession): Promise<Email>;
}
/**
* Interface for the TLS handler component
*/
export interface ITlsHandler {
/**
* Handle STARTTLS command
*/
handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
/**
* Upgrade a connection to TLS
*/
startTLS(socket: plugins.net.Socket): void;
/**
* Create a secure server
*/
createSecureServer(): plugins.tls.Server | undefined;
/**
* Check if TLS is enabled
*/
isTlsEnabled(): boolean;
}
/**
* Interface for the security handler component
*/
export interface ISecurityHandler {
/**
* Check IP reputation for a connection
*/
checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean>;
/**
* Validate an email address
*/
isValidEmail(email: string): boolean;
/**
* Validate authentication credentials
*/
authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean>;
/**
* Log a security event
*/
logSecurityEvent(event: string, level: string, details: Record<string, any>): void;
}
/**
* Interface for the SMTP server component
*/
export interface ISmtpServer {
/**
* Start the SMTP server
*/
listen(): Promise<void>;
/**
* Stop the SMTP server
*/
close(): Promise<void>;
/**
* Get the session manager
*/
getSessionManager(): ISessionManager;
/**
* Get the connection manager
*/
getConnectionManager(): IConnectionManager;
/**
* Get the command handler
*/
getCommandHandler(): ICommandHandler;
/**
* Get the data handler
*/
getDataHandler(): IDataHandler;
/**
* Get the TLS handler
*/
getTlsHandler(): ITlsHandler;
/**
* Get the security handler
*/
getSecurityHandler(): ISecurityHandler;
/**
* Get the server options
*/
getOptions(): ISmtpServerOptions;
/**
* Get the email server reference
*/
getEmailServer(): UnifiedEmailServer;
}
/**
* Configuration for creating an SMTP server
*/
export interface ISmtpServerConfig {
/**
* Email server reference
*/
emailServer: UnifiedEmailServer;
/**
* SMTP server options
*/
options: ISmtpServerOptions;
/**
* Optional session manager
*/
sessionManager?: ISessionManager;
/**
* Optional connection manager
*/
connectionManager?: IConnectionManager;
/**
* Optional command handler
*/
commandHandler?: ICommandHandler;
/**
* Optional data handler
*/
dataHandler?: IDataHandler;
/**
* Optional TLS handler
*/
tlsHandler?: ITlsHandler;
/**
* Optional security handler
*/
securityHandler?: ISecurityHandler;
}

View File

@ -0,0 +1,342 @@
/**
* SMTP Security Handler
* Responsible for security aspects including IP reputation checking,
* email validation, and authentication
*/
import * as plugins from '../../../plugins.js';
import { ISmtpSession, ISmtpAuth } from '../interfaces.js';
import { ISecurityHandler } from './interfaces.js';
import { SmtpLogger } from './utils/logging.js';
import { SecurityEventType, SecurityLogLevel } from './constants.js';
import { isValidEmail } from './utils/validation.js';
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
import { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
/**
* Interface for IP denylist entry
*/
interface IIpDenylistEntry {
ip: string;
reason: string;
expiresAt?: number;
}
/**
* Handles security aspects for SMTP server
*/
export class SecurityHandler implements ISecurityHandler {
/**
* Email server reference
*/
private emailServer: UnifiedEmailServer;
/**
* IP reputation service
*/
private ipReputationService?: any;
/**
* Authentication options
*/
private authOptions?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
validateUser?: (username: string, password: string) => Promise<boolean>;
};
/**
* Simple in-memory IP denylist
*/
private ipDenylist: IIpDenylistEntry[] = [];
/**
* Creates a new security handler
* @param emailServer - Email server reference
* @param ipReputationService - Optional IP reputation service
* @param authOptions - Authentication options
*/
constructor(
emailServer: UnifiedEmailServer,
ipReputationService?: any,
authOptions?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
validateUser?: (username: string, password: string) => Promise<boolean>;
}
) {
this.emailServer = emailServer;
this.ipReputationService = ipReputationService;
this.authOptions = authOptions;
// Clean expired denylist entries periodically
setInterval(() => this.cleanExpiredDenylistEntries(), 60000); // Every minute
}
/**
* Check IP reputation for a connection
* @param socket - Client socket
* @returns Promise that resolves to true if IP is allowed, false if blocked
*/
public async checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean> {
const socketDetails = getSocketDetails(socket);
const ip = socketDetails.remoteAddress;
// Check local denylist first
if (this.isIpDenylisted(ip)) {
// Log the blocked connection
this.logSecurityEvent(
SecurityEventType.IP_REPUTATION,
SecurityLogLevel.WARN,
`Connection blocked from denylisted IP: ${ip}`,
{ reason: this.getDenylistReason(ip) }
);
return false;
}
// If no reputation service, allow by default
if (!this.ipReputationService) {
return true;
}
try {
// Check with IP reputation service
const reputationResult = await this.ipReputationService.checkIp(ip);
if (!reputationResult.allowed) {
// Add to local denylist temporarily
this.addToDenylist(ip, reputationResult.reason, 3600000); // 1 hour
// Log the blocked connection
this.logSecurityEvent(
SecurityEventType.IP_REPUTATION,
SecurityLogLevel.WARN,
`Connection blocked by reputation service: ${ip}`,
{
reason: reputationResult.reason,
score: reputationResult.score,
categories: reputationResult.categories
}
);
return false;
}
// Log the allowed connection
this.logSecurityEvent(
SecurityEventType.IP_REPUTATION,
SecurityLogLevel.INFO,
`IP reputation check passed: ${ip}`,
{
score: reputationResult.score,
categories: reputationResult.categories
}
);
return true;
} catch (error) {
// Log the error
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
ip,
error: error instanceof Error ? error : new Error(String(error))
});
// Allow the connection on error (fail open)
return true;
}
}
/**
* Validate an email address
* @param email - Email address to validate
* @returns Whether the email address is valid
*/
public isValidEmail(email: string): boolean {
return isValidEmail(email);
}
/**
* Validate authentication credentials
* @param session - SMTP session
* @param username - Username
* @param password - Password
* @param method - Authentication method
* @returns Promise that resolves to true if authenticated
*/
public async authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean> {
// Check if authentication is enabled
if (!this.authOptions) {
this.logSecurityEvent(
SecurityEventType.AUTHENTICATION,
SecurityLogLevel.WARN,
'Authentication attempt when auth is disabled',
{ username, method, sessionId: session.id, ip: session.remoteAddress }
);
return false;
}
// Check if method is supported
if (!this.authOptions.methods.includes(method as any)) {
this.logSecurityEvent(
SecurityEventType.AUTHENTICATION,
SecurityLogLevel.WARN,
`Unsupported authentication method: ${method}`,
{ username, method, sessionId: session.id, ip: session.remoteAddress }
);
return false;
}
// Check if TLS is active (should be required for auth)
if (!session.useTLS) {
this.logSecurityEvent(
SecurityEventType.AUTHENTICATION,
SecurityLogLevel.WARN,
'Authentication attempt without TLS',
{ username, method, sessionId: session.id, ip: session.remoteAddress }
);
return false;
}
try {
let authenticated = false;
// Use custom validation function if provided
if (this.authOptions.validateUser) {
authenticated = await this.authOptions.validateUser(username, password);
} else {
// Default behavior - no authentication
authenticated = false;
}
// Log the authentication result
this.logSecurityEvent(
SecurityEventType.AUTHENTICATION,
authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
authenticated ? 'Authentication successful' : 'Authentication failed',
{ username, method, sessionId: session.id, ip: session.remoteAddress }
);
return authenticated;
} catch (error) {
// Log authentication error
this.logSecurityEvent(
SecurityEventType.AUTHENTICATION,
SecurityLogLevel.ERROR,
`Authentication error: ${error instanceof Error ? error.message : String(error)}`,
{ username, method, sessionId: session.id, ip: session.remoteAddress, error: error instanceof Error ? error.message : String(error) }
);
return false;
}
}
/**
* Log a security event
* @param event - Event type
* @param level - Log level
* @param details - Event details
*/
public logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void {
SmtpLogger.logSecurityEvent(
level as SecurityLogLevel,
event as SecurityEventType,
message,
details,
details.ip,
details.domain,
details.success
);
}
/**
* Add an IP to the denylist
* @param ip - IP address
* @param reason - Reason for denylisting
* @param duration - Duration in milliseconds (optional, indefinite if not specified)
*/
private addToDenylist(ip: string, reason: string, duration?: number): void {
// Remove existing entry if present
this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip);
// Create new entry
const entry: IIpDenylistEntry = {
ip,
reason,
expiresAt: duration ? Date.now() + duration : undefined
};
// Add to denylist
this.ipDenylist.push(entry);
// Log the action
this.logSecurityEvent(
SecurityEventType.ACCESS_CONTROL,
SecurityLogLevel.INFO,
`Added IP to denylist: ${ip}`,
{
ip,
reason,
duration: duration ? `${duration / 1000} seconds` : 'indefinite'
}
);
}
/**
* Check if an IP is denylisted
* @param ip - IP address
* @returns Whether the IP is denylisted
*/
private isIpDenylisted(ip: string): boolean {
const entry = this.ipDenylist.find(e => e.ip === ip);
if (!entry) {
return false;
}
// Check if entry has expired
if (entry.expiresAt && entry.expiresAt < Date.now()) {
// Remove expired entry
this.ipDenylist = this.ipDenylist.filter(e => e !== entry);
return false;
}
return true;
}
/**
* Get the reason an IP was denylisted
* @param ip - IP address
* @returns Reason for denylisting or undefined if not denylisted
*/
private getDenylistReason(ip: string): string | undefined {
const entry = this.ipDenylist.find(e => e.ip === ip);
return entry?.reason;
}
/**
* Clean expired denylist entries
*/
private cleanExpiredDenylistEntries(): void {
const now = Date.now();
const initialCount = this.ipDenylist.length;
this.ipDenylist = this.ipDenylist.filter(entry => {
return !entry.expiresAt || entry.expiresAt > now;
});
const removedCount = initialCount - this.ipDenylist.length;
if (removedCount > 0) {
this.logSecurityEvent(
SecurityEventType.ACCESS_CONTROL,
SecurityLogLevel.INFO,
`Cleaned up ${removedCount} expired denylist entries`,
{ remainingCount: this.ipDenylist.length }
);
}
}
}

View File

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

View File

@ -0,0 +1,284 @@
/**
* SMTP TLS Handler
* Responsible for handling TLS-related SMTP functionality
*/
import * as plugins from '../../../plugins.js';
import { ITlsHandler, ISessionManager } from './interfaces.js';
import { SmtpResponseCode, SecurityEventType, SecurityLogLevel } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
/**
* Handles TLS functionality for SMTP server
*/
export class TlsHandler implements ITlsHandler {
/**
* Session manager instance
*/
private sessionManager: ISessionManager;
/**
* TLS options
*/
private options: {
key: string;
cert: string;
ca?: string;
rejectUnauthorized?: boolean;
};
/**
* Creates a new TLS handler
* @param sessionManager - Session manager instance
* @param options - TLS options
*/
constructor(
sessionManager: ISessionManager,
options: {
key: string;
cert: string;
ca?: string;
rejectUnauthorized?: boolean;
}
) {
this.sessionManager = sessionManager;
this.options = options;
}
/**
* Handle STARTTLS command
* @param socket - Client socket
*/
public handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
// Check if already using TLS
if (session.useTLS) {
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} TLS already active`);
return;
}
// Check if we have the necessary TLS certificates
if (!this.isTlsEnabled()) {
this.sendResponse(socket, `${SmtpResponseCode.TLS_UNAVAILABLE_TEMP} TLS not available`);
return;
}
// Send ready for TLS response
this.sendResponse(socket, `${SmtpResponseCode.SERVICE_READY} Ready to start TLS`);
// Upgrade the connection to TLS
try {
this.startTLS(socket);
} catch (error) {
SmtpLogger.error(`STARTTLS negotiation failed: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress,
error: error instanceof Error ? error : new Error(String(error))
});
// Log security event
SmtpLogger.logSecurityEvent(
SecurityLogLevel.ERROR,
SecurityEventType.TLS_NEGOTIATION,
'STARTTLS negotiation failed',
{ error: error instanceof Error ? error.message : String(error) },
session.remoteAddress
);
}
}
/**
* Upgrade a connection to TLS
* @param socket - Client socket
*/
public startTLS(socket: plugins.net.Socket): void {
// Get the session for this socket
const session = this.sessionManager.getSession(socket);
// Create TLS context
const context = {
key: this.options.key,
cert: this.options.cert,
ca: this.options.ca,
isServer: true,
rejectUnauthorized: this.options.rejectUnauthorized || false
};
try {
// Upgrade the connection
const secureSocket = new plugins.tls.TLSSocket(socket, context);
// Store reference to the original socket to facilitate cleanup
(secureSocket as any).originalSocket = socket;
// Log the successful upgrade
if (session) {
SmtpLogger.info(`Upgraded connection to TLS for session ${session.id}`, {
sessionId: session.id,
remoteAddress: session.remoteAddress
});
// Log security event
SmtpLogger.logSecurityEvent(
SecurityLogLevel.INFO,
SecurityEventType.TLS_NEGOTIATION,
'STARTTLS negotiation successful',
{},
session.remoteAddress,
undefined,
true
);
// Update session properties
session.useTLS = true;
session.secure = true;
// Reset session state (per RFC 3207)
// After STARTTLS, client must issue a new EHLO
if (this.sessionManager.updateSessionState) {
this.sessionManager.updateSessionState(session, SmtpState.GREETING);
}
} else {
const socketDetails = getSocketDetails(socket);
SmtpLogger.info(`Upgraded connection to TLS without session from ${socketDetails.remoteAddress}:${socketDetails.remotePort}`);
}
// Securely handle TLS errors
secureSocket.on('error', (err) => {
SmtpLogger.error(`TLS error: ${err.message}`, {
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: err
});
// Log security event
SmtpLogger.logSecurityEvent(
SecurityLogLevel.ERROR,
SecurityEventType.TLS_NEGOTIATION,
'TLS error after successful negotiation',
{ error: err.message },
socket.remoteAddress
);
socket.destroy();
});
// Log TLS connection details on secure
secureSocket.on('secure', () => {
const tlsDetails = getTlsDetails(secureSocket);
if (tlsDetails) {
SmtpLogger.info('TLS connection established', {
remoteAddress: secureSocket.remoteAddress,
remotePort: secureSocket.remotePort,
protocol: tlsDetails.protocol,
cipher: tlsDetails.cipher,
authorized: tlsDetails.authorized
});
// Log security event with TLS details
SmtpLogger.logSecurityEvent(
SecurityLogLevel.INFO,
SecurityEventType.TLS_NEGOTIATION,
'TLS connection details',
{
protocol: tlsDetails.protocol,
cipher: tlsDetails.cipher,
authorized: tlsDetails.authorized
},
secureSocket.remoteAddress,
undefined,
true
);
}
});
} catch (error) {
SmtpLogger.error(`Failed to upgrade connection to TLS: ${error instanceof Error ? error.message : String(error)}`, {
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
// Log security event
SmtpLogger.logSecurityEvent(
SecurityLogLevel.ERROR,
SecurityEventType.TLS_NEGOTIATION,
'Failed to upgrade connection to TLS',
{ error: error instanceof Error ? error.message : String(error) },
socket.remoteAddress,
undefined,
false
);
socket.destroy();
}
}
/**
* Create a secure server
* @returns TLS server instance or undefined if TLS is not enabled
*/
public createSecureServer(): plugins.tls.Server | undefined {
if (!this.isTlsEnabled()) {
return undefined;
}
try {
// Create TLS context
const context = {
key: this.options.key,
cert: this.options.cert,
ca: this.options.ca,
rejectUnauthorized: this.options.rejectUnauthorized || false
};
// Create secure server
return new plugins.tls.Server(context);
} catch (error) {
SmtpLogger.error(`Failed to create secure server: ${error instanceof Error ? error.message : String(error)}`, {
error: error instanceof Error ? error : new Error(String(error))
});
return undefined;
}
}
/**
* Check if TLS is enabled
* @returns Whether TLS is enabled
*/
public isTlsEnabled(): boolean {
return !!(this.options.key && this.options.cert);
}
/**
* Send a response to the client
* @param socket - Client socket
* @param response - Response message
*/
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
try {
socket.write(`${response}\r\n`);
SmtpLogger.logResponse(response, socket);
} catch (error) {
SmtpLogger.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`, {
response,
remoteAddress: socket.remoteAddress,
remotePort: socket.remotePort,
error: error instanceof Error ? error : new Error(String(error))
});
socket.destroy();
}
}
}
// Import SmtpState only for type reference, not available at runtime
import { SmtpState } from '../interfaces.js';

View File

@ -0,0 +1,201 @@
/**
* SMTP Helper Functions
* Provides utility functions for SMTP server implementation
*/
import * as plugins from '../../../../plugins.js';
import { SMTP_DEFAULTS } from '../constants.js';
import type { ISmtpSession, ISmtpServerOptions } from '../../interfaces.js';
/**
* Formats a multi-line SMTP response according to RFC 5321
* @param code - Response code
* @param lines - Response lines
* @returns Formatted SMTP response
*/
export function formatMultilineResponse(code: number, lines: string[]): string {
if (!lines || lines.length === 0) {
return `${code} `;
}
if (lines.length === 1) {
return `${code} ${lines[0]}`;
}
let response = '';
for (let i = 0; i < lines.length - 1; i++) {
response += `${code}-${lines[i]}${SMTP_DEFAULTS.CRLF}`;
}
response += `${code} ${lines[lines.length - 1]}`;
return response;
}
/**
* Generates a unique session ID
* @returns Unique session ID
*/
export function generateSessionId(): string {
return `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
}
/**
* Safely parses an integer from string with a default value
* @param value - String value to parse
* @param defaultValue - Default value if parsing fails
* @returns Parsed integer or default value
*/
export function safeParseInt(value: string | undefined, defaultValue: number): number {
if (!value) {
return defaultValue;
}
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Safely gets the socket details
* @param socket - Socket to get details from
* @returns Socket details object
*/
export function getSocketDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
remoteAddress: string;
remotePort: number;
remoteFamily: string;
localAddress: string;
localPort: number;
encrypted: boolean;
} {
return {
remoteAddress: socket.remoteAddress || 'unknown',
remotePort: socket.remotePort || 0,
remoteFamily: socket.remoteFamily || 'unknown',
localAddress: socket.localAddress || 'unknown',
localPort: socket.localPort || 0,
encrypted: socket instanceof plugins.tls.TLSSocket
};
}
/**
* Gets TLS details if socket is TLS
* @param socket - Socket to get TLS details from
* @returns TLS details or undefined if not TLS
*/
export function getTlsDetails(socket: plugins.net.Socket | plugins.tls.TLSSocket): {
protocol?: string;
cipher?: string;
authorized?: boolean;
} | undefined {
if (!(socket instanceof plugins.tls.TLSSocket)) {
return undefined;
}
return {
protocol: socket.getProtocol(),
cipher: socket.getCipher()?.name,
authorized: socket.authorized
};
}
/**
* Merges default options with provided options
* @param options - User provided options
* @returns Merged options with defaults
*/
export function mergeWithDefaults(options: Partial<ISmtpServerOptions>): ISmtpServerOptions {
return {
port: options.port || SMTP_DEFAULTS.SMTP_PORT,
key: options.key || '',
cert: options.cert || '',
hostname: options.hostname || SMTP_DEFAULTS.HOSTNAME,
host: options.host,
securePort: options.securePort,
ca: options.ca,
maxSize: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
maxConnections: options.maxConnections || SMTP_DEFAULTS.MAX_CONNECTIONS,
socketTimeout: options.socketTimeout || SMTP_DEFAULTS.SOCKET_TIMEOUT,
connectionTimeout: options.connectionTimeout || SMTP_DEFAULTS.CONNECTION_TIMEOUT,
cleanupInterval: options.cleanupInterval || SMTP_DEFAULTS.CLEANUP_INTERVAL,
maxRecipients: options.maxRecipients || SMTP_DEFAULTS.MAX_RECIPIENTS,
size: options.size || SMTP_DEFAULTS.MAX_MESSAGE_SIZE,
dataTimeout: options.dataTimeout || SMTP_DEFAULTS.DATA_TIMEOUT,
auth: options.auth,
};
}
/**
* Creates a text response formatter for the SMTP server
* @param socket - Socket to send responses to
* @returns Function to send formatted response
*/
export function createResponseFormatter(socket: plugins.net.Socket | plugins.tls.TLSSocket): (response: string) => void {
return (response: string): void => {
try {
socket.write(`${response}${SMTP_DEFAULTS.CRLF}`);
console.log(`${response}`);
} catch (error) {
console.error(`Error sending response: ${error instanceof Error ? error.message : String(error)}`);
socket.destroy();
}
};
}
/**
* Extracts SMTP command name from a command line
* @param commandLine - Full command line
* @returns Command name in uppercase
*/
export function extractCommandName(commandLine: string): string {
const parts = commandLine.trim().split(' ');
return parts[0].toUpperCase();
}
/**
* Extracts SMTP command arguments from a command line
* @param commandLine - Full command line
* @returns Arguments string
*/
export function extractCommandArgs(commandLine: string): string {
const firstSpace = commandLine.indexOf(' ');
if (firstSpace === -1) {
return '';
}
return commandLine.substring(firstSpace + 1).trim();
}
/**
* Sanitizes data for logging (hides sensitive info)
* @param data - Data to sanitize
* @returns Sanitized data
*/
export function sanitizeForLogging(data: any): any {
if (!data) {
return data;
}
if (typeof data !== 'object') {
return data;
}
const result: any = Array.isArray(data) ? [] : {};
for (const key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
// Sanitize sensitive fields
if (key.toLowerCase().includes('password') ||
key.toLowerCase().includes('token') ||
key.toLowerCase().includes('secret') ||
key.toLowerCase().includes('credential')) {
result[key] = '********';
} else if (typeof data[key] === 'object' && data[key] !== null) {
result[key] = sanitizeForLogging(data[key]);
} else {
result[key] = data[key];
}
}
}
return result;
}

View File

@ -0,0 +1,246 @@
/**
* SMTP Logging Utilities
* Provides structured logging for SMTP server components
*/
import * as plugins from '../../../../plugins.js';
import { logger } from '../../../../logger.js';
import { SecurityLogLevel, SecurityEventType } from '../constants.js';
import type { ISmtpSession } from '../../interfaces.js';
/**
* SMTP connection metadata to include in logs
*/
export interface IConnectionMetadata {
remoteAddress?: string;
remotePort?: number;
socketId?: string;
secure?: boolean;
sessionId?: string;
}
/**
* Log levels for SMTP server
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
/**
* Options for SMTP log
*/
export interface ISmtpLogOptions {
level?: LogLevel;
sessionId?: string;
sessionState?: string;
remoteAddress?: string;
remotePort?: number;
command?: string;
error?: Error;
[key: string]: any;
}
/**
* SMTP logger - provides structured logging for SMTP server
*/
export class SmtpLogger {
/**
* Log a message with context
* @param level - Log level
* @param message - Log message
* @param options - Additional log options
*/
public static log(level: LogLevel, message: string, options: ISmtpLogOptions = {}): void {
// Extract error information if provided
const errorInfo = options.error ? {
errorMessage: options.error.message,
errorStack: options.error.stack,
errorName: options.error.name
} : {};
// Structure log data
const logData = {
component: 'smtp-server',
...options,
...errorInfo
};
// Remove error from log data to avoid duplication
if (logData.error) {
delete logData.error;
}
// Log through the main logger
logger.log(level, message, logData);
// Also console log for immediate visibility during development
if (level === 'error' || level === 'warn') {
console[level](`[SMTP] ${message}`, logData);
}
}
/**
* Log debug level message
* @param message - Log message
* @param options - Additional log options
*/
public static debug(message: string, options: ISmtpLogOptions = {}): void {
this.log('debug', message, options);
}
/**
* Log info level message
* @param message - Log message
* @param options - Additional log options
*/
public static info(message: string, options: ISmtpLogOptions = {}): void {
this.log('info', message, options);
}
/**
* Log warning level message
* @param message - Log message
* @param options - Additional log options
*/
public static warn(message: string, options: ISmtpLogOptions = {}): void {
this.log('warn', message, options);
}
/**
* Log error level message
* @param message - Log message
* @param options - Additional log options
*/
public static error(message: string, options: ISmtpLogOptions = {}): void {
this.log('error', message, options);
}
/**
* Log command received from client
* @param command - The command string
* @param socket - The client socket
* @param session - The SMTP session
*/
public static 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
};
this.info(`Command received: ${command}`, {
...clientInfo,
command: command.split(' ')[0]?.toUpperCase()
});
// Also log to console for easy debugging
console.log(`${command}`);
}
/**
* Log response sent to client
* @param response - The response string
* @param socket - The client socket
*/
public static 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
};
// Get the response code from the beginning of the response
const responseCode = response.substring(0, 3);
// Log different levels based on response code
if (responseCode.startsWith('2') || responseCode.startsWith('3')) {
this.debug(`Response sent: ${response}`, clientInfo);
} else if (responseCode.startsWith('4')) {
this.warn(`Temporary error response: ${response}`, clientInfo);
} else if (responseCode.startsWith('5')) {
this.error(`Permanent error response: ${response}`, clientInfo);
}
// Also log to console for easy debugging
console.log(`${response}`);
}
/**
* Log client connection event
* @param socket - The client socket
* @param eventType - Type of connection event (connect, close, error)
* @param session - The SMTP session
* @param error - Optional error object for error events
*/
public static 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
};
switch (eventType) {
case 'connect':
this.info(`New ${clientInfo.secure ? 'secure ' : ''}connection from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
break;
case 'close':
this.info(`Connection closed from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, clientInfo);
break;
case 'error':
this.error(`Connection error from ${clientInfo.remoteAddress}:${clientInfo.remotePort}`, {
...clientInfo,
error
});
break;
}
}
/**
* Log security event
* @param level - Security log level
* @param type - Security event type
* @param message - Log message
* @param details - Event details
* @param ipAddress - Client IP address
* @param domain - Optional domain involved
* @param success - Whether the security check was successful
*/
public static logSecurityEvent(
level: SecurityLogLevel,
type: SecurityEventType,
message: string,
details: Record<string, any>,
ipAddress?: string,
domain?: string,
success?: boolean
): void {
// Map security log level to system log level
const logLevel: LogLevel = level === SecurityLogLevel.DEBUG ? 'debug' :
level === SecurityLogLevel.INFO ? 'info' :
level === SecurityLogLevel.WARN ? 'warn' : 'error';
// Log the security event
this.log(logLevel, message, {
component: 'smtp-security',
eventType: type,
success,
ipAddress,
domain,
...details
});
}
}
/**
* Default instance for backward compatibility
*/
export const smtpLogger = SmtpLogger;

View File

@ -0,0 +1,194 @@
/**
* SMTP Validation Utilities
* Provides validation functions for SMTP server
*/
import { SmtpState } from '../../interfaces.js';
import { SMTP_PATTERNS } from '../constants.js';
/**
* Validates an email address
* @param email - Email address to validate
* @returns Whether the email address is valid
*/
export function isValidEmail(email: string): boolean {
if (!email || typeof email !== 'string') {
return false;
}
return SMTP_PATTERNS.EMAIL.test(email);
}
/**
* Validates the MAIL FROM command syntax
* @param args - Arguments string from the MAIL FROM command
* @returns Object with validation result and extracted data
*/
export function validateMailFrom(args: string): {
isValid: boolean;
address?: string;
params?: Record<string, string>;
errorMessage?: string;
} {
if (!args) {
return { isValid: false, errorMessage: 'Missing arguments' };
}
const match = args.match(SMTP_PATTERNS.MAIL_FROM);
if (!match) {
return { isValid: false, errorMessage: 'Invalid syntax' };
}
const [, address, paramsString] = match;
if (!isValidEmail(address)) {
return { isValid: false, errorMessage: 'Invalid email address' };
}
// Parse parameters if they exist
const params: Record<string, string> = {};
if (paramsString) {
let paramMatch;
const paramRegex = SMTP_PATTERNS.PARAM;
paramRegex.lastIndex = 0; // Reset the regex
while ((paramMatch = paramRegex.exec(paramsString)) !== null) {
const [, name, value = ''] = paramMatch;
params[name.toUpperCase()] = value;
}
}
return { isValid: true, address, params };
}
/**
* Validates the RCPT TO command syntax
* @param args - Arguments string from the RCPT TO command
* @returns Object with validation result and extracted data
*/
export function validateRcptTo(args: string): {
isValid: boolean;
address?: string;
params?: Record<string, string>;
errorMessage?: string;
} {
if (!args) {
return { isValid: false, errorMessage: 'Missing arguments' };
}
const match = args.match(SMTP_PATTERNS.RCPT_TO);
if (!match) {
return { isValid: false, errorMessage: 'Invalid syntax' };
}
const [, address, paramsString] = match;
if (!isValidEmail(address)) {
return { isValid: false, errorMessage: 'Invalid email address' };
}
// Parse parameters if they exist
const params: Record<string, string> = {};
if (paramsString) {
let paramMatch;
const paramRegex = SMTP_PATTERNS.PARAM;
paramRegex.lastIndex = 0; // Reset the regex
while ((paramMatch = paramRegex.exec(paramsString)) !== null) {
const [, name, value = ''] = paramMatch;
params[name.toUpperCase()] = value;
}
}
return { isValid: true, address, params };
}
/**
* Validates the EHLO command syntax
* @param args - Arguments string from the EHLO command
* @returns Object with validation result and extracted data
*/
export function validateEhlo(args: string): {
isValid: boolean;
hostname?: string;
errorMessage?: string;
} {
if (!args) {
return { isValid: false, errorMessage: 'Missing domain name' };
}
const match = args.match(SMTP_PATTERNS.EHLO);
if (!match) {
return { isValid: false, errorMessage: 'Invalid syntax' };
}
const hostname = match[1];
// Check for invalid characters in hostname
if (hostname.includes('@') || hostname.includes('<')) {
return { isValid: false, errorMessage: 'Invalid domain name format' };
}
return { isValid: true, hostname };
}
/**
* Validates command in the current SMTP state
* @param command - SMTP command
* @param currentState - Current SMTP state
* @returns Whether the command is valid in the current state
*/
export function isValidCommandSequence(command: string, currentState: SmtpState): boolean {
const upperCommand = command.toUpperCase();
// Some commands are valid in any state
if (upperCommand === 'QUIT' || upperCommand === 'RSET' || upperCommand === 'NOOP' || upperCommand === 'HELP') {
return true;
}
// State-specific validation
switch (currentState) {
case SmtpState.GREETING:
return upperCommand === 'EHLO' || upperCommand === 'HELO';
case SmtpState.AFTER_EHLO:
return upperCommand === 'MAIL' || upperCommand === 'STARTTLS' || upperCommand === 'AUTH';
case SmtpState.MAIL_FROM:
case SmtpState.RCPT_TO:
if (upperCommand === 'RCPT') {
return true;
}
return currentState === SmtpState.RCPT_TO && upperCommand === 'DATA';
case SmtpState.DATA:
// In DATA state, only the data content is accepted, not commands
return false;
case SmtpState.DATA_RECEIVING:
// In DATA_RECEIVING state, only the data content is accepted, not commands
return false;
case SmtpState.FINISHED:
// After data is received, only new transactions or session end
return upperCommand === 'MAIL' || upperCommand === 'QUIT' || upperCommand === 'RSET';
default:
return false;
}
}
/**
* Validates if a hostname is valid according to RFC 5321
* @param hostname - Hostname to validate
* @returns Whether the hostname is valid
*/
export function isValidHostname(hostname: string): boolean {
if (!hostname || typeof hostname !== 'string') {
return false;
}
// Basic hostname validation
// This is a simplified check, full RFC compliance would be more complex
return /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/.test(hostname);
}