update
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
632
ts/mail/delivery/smtp/command-handler.ts
Normal file
632
ts/mail/delivery/smtp/command-handler.ts
Normal 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);
|
||||
}
|
||||
}
|
363
ts/mail/delivery/smtp/connection-manager.ts
Normal file
363
ts/mail/delivery/smtp/connection-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
176
ts/mail/delivery/smtp/constants.ts
Normal file
176
ts/mail/delivery/smtp/constants.ts
Normal 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;
|
||||
}
|
||||
};
|
386
ts/mail/delivery/smtp/data-handler.ts
Normal file
386
ts/mail/delivery/smtp/data-handler.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
348
ts/mail/delivery/smtp/interfaces.ts
Normal file
348
ts/mail/delivery/smtp/interfaces.ts
Normal 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;
|
||||
}
|
342
ts/mail/delivery/smtp/security-handler.ts
Normal file
342
ts/mail/delivery/smtp/security-handler.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
392
ts/mail/delivery/smtp/session-manager.ts
Normal file
392
ts/mail/delivery/smtp/session-manager.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
284
ts/mail/delivery/smtp/tls-handler.ts
Normal file
284
ts/mail/delivery/smtp/tls-handler.ts
Normal 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';
|
201
ts/mail/delivery/smtp/utils/helpers.ts
Normal file
201
ts/mail/delivery/smtp/utils/helpers.ts
Normal 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;
|
||||
}
|
246
ts/mail/delivery/smtp/utils/logging.ts
Normal file
246
ts/mail/delivery/smtp/utils/logging.ts
Normal 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;
|
194
ts/mail/delivery/smtp/utils/validation.ts
Normal file
194
ts/mail/delivery/smtp/utils/validation.ts
Normal 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);
|
||||
}
|
Reference in New Issue
Block a user