This commit is contained in:
2025-05-21 13:42:12 +00:00
parent 3f220996ee
commit 38811dbf23
17 changed files with 1116 additions and 1759 deletions

View File

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

View File

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

View File

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