This commit is contained in:
Philipp Kunz 2025-05-23 00:06:07 +00:00
parent f058b2d1e7
commit 4905595cbb
7 changed files with 351 additions and 99 deletions

View File

@ -35,7 +35,7 @@ export class CommandHandler implements ICommandHandler {
* @param socket - Client socket
* @param commandLine - Command line from client
*/
public processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
public async processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): Promise<void> {
// Get the session for this socket
const session = this.smtpServer.getSessionManager().getSession(socket);
if (!session) {
@ -216,7 +216,7 @@ export class CommandHandler implements ICommandHandler {
case SmtpCommand.STARTTLS:
const tlsHandler = this.smtpServer.getTlsHandler();
if (tlsHandler && tlsHandler.isTlsEnabled()) {
tlsHandler.handleStartTls(socket);
await tlsHandler.handleStartTls(socket, session);
} else {
SmtpLogger.warn('STARTTLS requested but TLS is not enabled', {
remoteAddress: socket.remoteAddress,
@ -1018,6 +1018,48 @@ export class CommandHandler implements ICommandHandler {
return isValidCommandSequence(command, session.state);
}
/**
* Handle an SMTP command (interface requirement)
*/
public async handleCommand(
socket: plugins.net.Socket | plugins.tls.TLSSocket,
command: SmtpCommand,
args: string,
session: ISmtpSession
): Promise<void> {
// Delegate to processCommand for now
this.processCommand(socket, `${command} ${args}`.trim());
}
/**
* Get supported commands for current session state (interface requirement)
*/
public getSupportedCommands(session: ISmtpSession): SmtpCommand[] {
const commands: SmtpCommand[] = [SmtpCommand.NOOP, SmtpCommand.QUIT, SmtpCommand.RSET];
switch (session.state) {
case SmtpState.GREETING:
commands.push(SmtpCommand.EHLO, SmtpCommand.HELO);
break;
case SmtpState.AFTER_EHLO:
commands.push(SmtpCommand.MAIL_FROM, SmtpCommand.STARTTLS);
if (!session.authenticated) {
commands.push(SmtpCommand.AUTH);
}
break;
case SmtpState.MAIL_FROM:
commands.push(SmtpCommand.RCPT_TO);
break;
case SmtpState.RCPT_TO:
commands.push(SmtpCommand.RCPT_TO, SmtpCommand.DATA);
break;
default:
break;
}
return commands;
}
/**
* Clean up resources
*/

View File

@ -281,7 +281,7 @@ export class ConnectionManager implements IConnectionManager {
* Handle a new connection with resource management
* @param socket - Client socket
*/
public handleNewConnection(socket: plugins.net.Socket): void {
public async handleNewConnection(socket: plugins.net.Socket): Promise<void> {
// Update connection stats
this.connectionStats.totalConnections++;
this.connectionStats.activeConnections = this.activeConnections.size + 1;
@ -437,7 +437,7 @@ export class ConnectionManager implements IConnectionManager {
* Handle a new secure TLS connection with resource management
* @param socket - Client TLS socket
*/
public handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
public async handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void> {
// Update connection stats
this.connectionStats.totalConnections++;
this.connectionStats.activeConnections = this.activeConnections.size + 1;
@ -961,6 +961,24 @@ export class ConnectionManager implements IConnectionManager {
}
}
/**
* Handle a new connection (interface requirement)
*/
public async handleConnection(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure: boolean): Promise<void> {
if (secure) {
this.handleNewSecureConnection(socket as plugins.tls.TLSSocket);
} else {
this.handleNewConnection(socket as plugins.net.Socket);
}
}
/**
* Check if accepting new connections (interface requirement)
*/
public canAcceptConnection(): boolean {
return !this.hasReachedMaxConnections();
}
/**
* Clean up resources
*/
@ -976,8 +994,18 @@ export class ConnectionManager implements IConnectionManager {
// Clear maps
this.activeConnections.clear();
this.connectionTimestamps.clear();
this.ipConnectionCounts.clear();
this.ipConnections.clear();
// Reset connection stats
this.connectionStats = {
totalConnections: 0,
activeConnections: 0,
peakConnections: 0,
rejectedConnections: 0,
closedConnections: 0,
erroredConnections: 0,
timedOutConnections: 0
};
SmtpLogger.debug('ConnectionManager destroyed');
}

View File

@ -189,46 +189,79 @@ export class DataHandler implements IDataHandler {
/**
* Process a complete email
* @param rawData - Raw email data
* @param session - SMTP session
* @returns Promise that resolves with the Email object
*/
public async processEmail(rawData: string, session: ISmtpSession): Promise<Email> {
// Clean up the raw email data
let cleanedData = rawData;
// Remove trailing end-of-data marker: various formats
cleanedData = cleanedData
.replace(/\r\n\.\r\n$/, '')
.replace(/\n\.\r\n$/, '')
.replace(/\r\n\.\n$/, '')
.replace(/\n\.\n$/, '')
.replace(/^\.$/, ''); // Handle ONLY a lone dot as the entire content (not trailing dots)
// Remove dot-stuffing (RFC 5321, section 4.5.2)
cleanedData = cleanedData.replace(/\r\n\.\./g, '\r\n.');
try {
// Parse email into Email object using cleaned data
const email = await this.parseEmailFromData(cleanedData, session);
// Return the parsed email
return email;
} 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))
});
// Create a minimal email object on error
const fallbackEmail = new Email();
fallbackEmail.setFromRawData(cleanedData);
return fallbackEmail;
}
}
/**
* Parse email from raw data
* @param rawData - Raw email data
* @param session - SMTP session
* @returns Email object
*/
private async parseEmailFromData(rawData: string, session: ISmtpSession): Promise<Email> {
const email = new Email();
// Set raw data
email.setFromRawData(rawData);
// Set envelope information from session
if (session.mailFrom) {
email.setFrom(session.mailFrom);
}
if (session.rcptTo && session.rcptTo.length > 0) {
for (const recipient of session.rcptTo) {
email.addTo(recipient);
}
}
return email;
}
/**
* Process a complete email (legacy method)
* @param session - SMTP session
* @returns Promise that resolves with the result of the transaction
*/
public async processEmail(session: ISmtpSession): Promise<ISmtpTransactionResult> {
const isLargeMessage = (session.emailDataSize || 0) > 100 * 1024; // 100KB threshold
// For large messages, process chunks efficiently to avoid memory issues
if (isLargeMessage) {
session.emailData = this.processEmailDataStreaming(session.emailDataChunks || []);
// Clear chunks immediately after processing to free memory
session.emailDataChunks = [];
session.emailDataSize = 0;
// Force garbage collection for large messages
if (global.gc) {
global.gc();
}
} else {
// For smaller messages, use the simpler approach
session.emailData = (session.emailDataChunks || []).join('');
// Remove trailing end-of-data marker: various formats
session.emailData = session.emailData
.replace(/\r\n\.\r\n$/, '')
.replace(/\n\.\r\n$/, '')
.replace(/\r\n\.\n$/, '')
.replace(/\n\.\n$/, '')
.replace(/^\.$/, ''); // Handle ONLY a lone dot as the entire content (not trailing dots)
// Remove dot-stuffing (RFC 5321, section 4.5.2)
session.emailData = session.emailData.replace(/\r\n\.\./g, '\r\n.');
// Clear chunks after processing
session.emailDataChunks = [];
}
public async processEmailLegacy(session: ISmtpSession): Promise<ISmtpTransactionResult> {
try {
// Parse email into Email object
const email = await this.parseEmail(session);
// Use the email data from session
const email = await this.parseEmailFromData(session.emailData || '', session);
// Process the email based on the processing mode
const processingMode = session.processingMode || 'mta';
@ -1195,6 +1228,18 @@ SmtpLogger.debug(`Parsed email subject: ${subject}`, { subject });
}, 100); // Short delay before retry
}
/**
* Handle email data (interface requirement)
*/
public async handleData(
socket: plugins.net.Socket | plugins.tls.TLSSocket,
data: string,
session: ISmtpSession
): Promise<void> {
// Delegate to existing method
await this.handleDataReceived(socket, data);
}
/**
* Clean up resources
*/

View File

@ -7,9 +7,11 @@ import * as plugins from '../../../plugins.js';
import type { Email } from '../../core/classes.email.js';
import type { UnifiedEmailServer } from '../../routing/classes.unified.email.server.js';
// Re-export types from other modules
export { SmtpState } from '../interfaces.js';
export type { SmtpCommand } from './constants.js';
// Re-export types from other modules
import { SmtpState } from '../interfaces.js';
import { SmtpCommand } from './constants.js';
export { SmtpState, SmtpCommand };
export type { IEnvelopeRecipient } from '../interfaces.js';
/**
* Interface for components that need cleanup
@ -104,7 +106,7 @@ export interface ISmtpSession {
/**
* Last activity timestamp
*/
lastActivity: Date;
lastActivity: number;
/**
* Client's IP address
@ -165,6 +167,51 @@ export interface ISmtpSession {
* TLS options for this session
*/
tlsOptions?: any;
/**
* Whether TLS is being used
*/
useTLS?: boolean;
/**
* Mail from address for this transaction
*/
mailFrom?: string;
/**
* Recipients for this transaction
*/
rcptTo?: string[];
/**
* Email data being received
*/
emailData?: string;
/**
* Chunks of email data
*/
emailDataChunks?: string[];
/**
* Timeout ID for data reception
*/
dataTimeoutId?: NodeJS.Timeout;
/**
* Whether connection has ended
*/
connectionEnded?: boolean;
/**
* Size of email data being received
*/
emailDataSize?: number;
/**
* Processing mode for this session
*/
processingMode?: string;
}
/**
@ -174,7 +221,7 @@ export interface ISessionManager extends IDestroyable {
/**
* Create a new session for a socket
*/
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): ISmtpSession;
createSession(socket: plugins.net.Socket | plugins.tls.TLSSocket, secure?: boolean): ISmtpSession;
/**
* Get session by socket
@ -184,7 +231,7 @@ export interface ISessionManager extends IDestroyable {
/**
* Update session state
*/
updateSessionState(socket: plugins.net.Socket | plugins.tls.TLSSocket, newState: SmtpState): void;
updateSessionState(session: ISmtpSession, newState: SmtpState): void;
/**
* Remove a session
@ -215,6 +262,16 @@ export interface ISessionManager extends IDestroyable {
* Check for timed out sessions
*/
checkTimeouts(timeoutMs: number): ISmtpSession[];
/**
* Update session activity timestamp
*/
updateSessionActivity(session: ISmtpSession): void;
/**
* Replace socket in session (for TLS upgrade)
*/
replaceSocket(oldSocket: plugins.net.Socket | plugins.tls.TLSSocket, newSocket: plugins.net.Socket | plugins.tls.TLSSocket): boolean;
}
/**
@ -240,6 +297,21 @@ export interface IConnectionManager extends IDestroyable {
* Check if accepting new connections
*/
canAcceptConnection(): boolean;
/**
* Handle new connection (legacy method name)
*/
handleNewConnection(socket: plugins.net.Socket): Promise<void>;
/**
* Handle new secure connection (legacy method name)
*/
handleNewSecureConnection(socket: plugins.tls.TLSSocket): Promise<void>;
/**
* Setup socket event handlers
*/
setupSocketEventHandlers(socket: plugins.net.Socket | plugins.tls.TLSSocket): void;
}
/**
@ -260,6 +332,11 @@ export interface ICommandHandler extends IDestroyable {
* Get supported commands for current session state
*/
getSupportedCommands(session: ISmtpSession): SmtpCommand[];
/**
* Process command (legacy method name)
*/
processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, command: string): Promise<void>;
}
/**
@ -282,6 +359,16 @@ export interface IDataHandler extends IDestroyable {
rawData: string,
session: ISmtpSession
): Promise<Email>;
/**
* Handle data received (legacy method name)
*/
handleDataReceived(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
/**
* Process email data (legacy method name)
*/
processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): Promise<void>;
}
/**
@ -305,6 +392,11 @@ export interface ITlsHandler extends IDestroyable {
* Get TLS options
*/
getTlsOptions(): plugins.tls.TlsOptions;
/**
* Check if TLS is enabled
*/
isTlsEnabled(): boolean;
}
/**
@ -341,6 +433,16 @@ export interface ISmtpServerOptions {
*/
hostname: string;
/**
* Host to bind to (optional, defaults to 0.0.0.0)
*/
host?: string;
/**
* Secure port for TLS connections
*/
securePort?: number;
/**
* TLS/SSL private key (PEM format)
*/

View File

@ -159,13 +159,11 @@ export class SecurityHandler implements ISecurityHandler {
/**
* Validate authentication credentials
* @param session - SMTP session
* @param username - Username
* @param password - Password
* @param method - Authentication method
* @param auth - Authentication credentials
* @returns Promise that resolves to true if authenticated
*/
public async authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean> {
public async authenticate(auth: ISmtpAuth): Promise<boolean> {
const { username, password } = auth;
// Get auth options from server
const options = this.smtpServer.getOptions();
const authOptions = options.auth;
@ -176,35 +174,14 @@ export class SecurityHandler implements ISecurityHandler {
SecurityEventType.AUTHENTICATION,
SecurityLogLevel.WARN,
'Authentication attempt when auth is disabled',
{ username, method, sessionId: session.id, ip: session.remoteAddress }
{ username }
);
return false;
}
// Check if method is supported
if (!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;
}
// Note: Method validation and TLS requirement checks would need to be done
// at the caller level since the interface doesn't include session/method info
try {
let authenticated = false;
@ -222,7 +199,7 @@ export class SecurityHandler implements ISecurityHandler {
SecurityEventType.AUTHENTICATION,
authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
authenticated ? 'Authentication successful' : 'Authentication failed',
{ username, method, sessionId: session.id, ip: session.remoteAddress }
{ username }
);
return authenticated;
@ -232,7 +209,7 @@ export class SecurityHandler implements ISecurityHandler {
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) }
{ username, error: error instanceof Error ? error.message : String(error) }
);
return false;

View File

@ -93,6 +93,8 @@ export class SessionManager implements ISessionManager {
useTLS: secure || false,
connectionEnded: false,
remoteAddress: socketDetails.remoteAddress,
remotePort: socketDetails.remotePort,
createdAt: new Date(),
secure: secure || false,
authenticated: false,
envelope: {
@ -501,6 +503,39 @@ export class SessionManager implements ISessionManager {
return `${details.remoteAddress}:${details.remotePort}-${Date.now()}`;
}
/**
* Get all active sessions
*/
public getAllSessions(): ISmtpSession[] {
return Array.from(this.sessions.values());
}
/**
* Update last activity for a session by socket
*/
public updateLastActivity(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
const session = this.getSession(socket);
if (session) {
this.updateSessionActivity(session);
}
}
/**
* Check for timed out sessions
*/
public checkTimeouts(timeoutMs: number): ISmtpSession[] {
const now = Date.now();
const timedOutSessions: ISmtpSession[] = [];
for (const session of this.sessions.values()) {
if (now - session.lastActivity > timeoutMs) {
timedOutSessions.push(session);
}
}
return timedOutSessions;
}
/**
* Clean up resources
*/

View File

@ -4,7 +4,7 @@
*/
import * as plugins from '../../../plugins.js';
import type { ITlsHandler, ISmtpServer } from './interfaces.js';
import type { ITlsHandler, ISmtpServer, ISmtpSession } from './interfaces.js';
import { SmtpResponseCode, SecurityEventType, SecurityLogLevel } from './constants.js';
import { SmtpLogger } from './utils/logging.js';
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
@ -30,6 +30,11 @@ export class TlsHandler implements ITlsHandler {
*/
private certificates: ICertificateData;
/**
* TLS options
*/
private options: plugins.tls.TlsOptions;
/**
* Creates a new TLS handler
* @param smtpServer - SMTP server instance
@ -38,13 +43,13 @@ export class TlsHandler implements ITlsHandler {
this.smtpServer = smtpServer;
// Initialize certificates
const options = this.smtpServer.getOptions();
const serverOptions = this.smtpServer.getOptions();
try {
// Try to load certificates from provided options
this.certificates = loadCertificatesFromString({
key: options.key,
cert: options.cert,
ca: options.ca
key: serverOptions.key,
cert: serverOptions.cert,
ca: serverOptions.ca
});
SmtpLogger.info('Successfully loaded TLS certificates');
@ -54,30 +59,27 @@ export class TlsHandler implements ITlsHandler {
// Fall back to self-signed certificates for testing
this.certificates = generateSelfSignedCertificates();
}
// Initialize TLS options
this.options = createTlsOptions(this.certificates);
}
/**
* 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.smtpServer.getSessionManager().getSession(socket);
if (!session) {
this.sendResponse(socket, `${SmtpResponseCode.LOCAL_ERROR} Internal server error - session not found`);
return;
}
public async handleStartTls(socket: plugins.net.Socket, session: ISmtpSession): Promise<plugins.tls.TLSSocket | null> {
// Check if already using TLS
if (session.useTLS) {
this.sendResponse(socket, `${SmtpResponseCode.BAD_SEQUENCE} TLS already active`);
return;
return null;
}
// Check if we have the necessary TLS certificates
if (!this.isTlsEnabled()) {
this.sendResponse(socket, `${SmtpResponseCode.TLS_UNAVAILABLE_TEMP} TLS not available`);
return;
return null;
}
// Send ready for TLS response
@ -85,7 +87,8 @@ export class TlsHandler implements ITlsHandler {
// Upgrade the connection to TLS
try {
this.startTLS(socket);
const tlsSocket = await this.startTLS(socket);
return tlsSocket;
} catch (error) {
SmtpLogger.error(`STARTTLS negotiation failed: ${error instanceof Error ? error.message : String(error)}`, {
sessionId: session.id,
@ -101,6 +104,8 @@ export class TlsHandler implements ITlsHandler {
{ error: error instanceof Error ? error.message : String(error) },
session.remoteAddress
);
return null;
}
}
@ -108,7 +113,7 @@ export class TlsHandler implements ITlsHandler {
* Upgrade a connection to TLS
* @param socket - Client socket
*/
public async startTLS(socket: plugins.net.Socket): Promise<void> {
public async startTLS(socket: plugins.net.Socket): Promise<plugins.tls.TLSSocket> {
// Get the session for this socket
const session = this.smtpServer.getSessionManager().getSession(socket);
@ -120,11 +125,11 @@ export class TlsHandler implements ITlsHandler {
SmtpLogger.info('Using enhanced STARTTLS implementation');
// Use the enhanced STARTTLS handler with better error handling and socket management
const options = this.smtpServer.getOptions();
const serverOptions = this.smtpServer.getOptions();
const tlsSocket = await performStartTLS(socket, {
key: options.key,
cert: options.cert,
ca: options.ca,
key: serverOptions.key,
cert: serverOptions.cert,
ca: serverOptions.ca,
session: session,
sessionManager: this.smtpServer.getSessionManager(),
connectionManager: this.smtpServer.getConnectionManager(),
@ -180,7 +185,10 @@ export class TlsHandler implements ITlsHandler {
sessionId: session?.id,
remoteAddress: socket.remoteAddress
});
throw new Error('Failed to create TLS socket');
}
return tlsSocket;
} catch (error) {
// Log STARTTLS failure
SmtpLogger.error(`Failed to upgrade connection to TLS: ${error instanceof Error ? error.message : String(error)}`, {
@ -206,6 +214,7 @@ export class TlsHandler implements ITlsHandler {
// Destroy the socket on error
socket.destroy();
throw error;
}
}
@ -312,6 +321,20 @@ export class TlsHandler implements ITlsHandler {
}
}
/**
* Check if TLS is available (interface requirement)
*/
public isTlsAvailable(): boolean {
return this.isTlsEnabled();
}
/**
* Get TLS options (interface requirement)
*/
public getTlsOptions(): plugins.tls.TlsOptions {
return this.options;
}
/**
* Clean up resources
*/