update
This commit is contained in:
1422
ts/mail/delivery/classes.smtp.client.legacy.ts
Normal file
1422
ts/mail/delivery/classes.smtp.client.legacy.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,304 +0,0 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as paths from '../../paths.js';
|
||||
import { Email } from '../core/classes.email.js';
|
||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType,
|
||||
IPReputationChecker,
|
||||
ReputationThreshold
|
||||
} from '../../security/index.js';
|
||||
|
||||
import type {
|
||||
ISmtpServerOptions,
|
||||
ISmtpSession,
|
||||
EmailProcessingMode
|
||||
} from './interfaces.js';
|
||||
import { SmtpState } from './interfaces.js';
|
||||
|
||||
// Import refactored SMTP server components
|
||||
import {
|
||||
SmtpServer,
|
||||
createSmtpServer,
|
||||
type ISmtpServer
|
||||
} from './smtpserver/index.js';
|
||||
|
||||
/**
|
||||
* Legacy SMTP Server implementation that uses the refactored modular version
|
||||
* Maintains the original API for backward compatibility
|
||||
*/
|
||||
export class SMTPServer {
|
||||
// Public properties used by existing code
|
||||
public emailServerRef: UnifiedEmailServer;
|
||||
|
||||
// Protected properties for test access
|
||||
protected server: plugins.net.Server;
|
||||
protected secureServer?: plugins.tls.Server;
|
||||
|
||||
// Original properties maintained for compatibility
|
||||
private smtpServerOptions: ISmtpServerOptions;
|
||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, ISmtpSession>;
|
||||
private sessionTimeouts: Map<string, NodeJS.Timeout>;
|
||||
private hostname: string;
|
||||
private sessionIdCounter: number = 0;
|
||||
private connectionCount: number = 0;
|
||||
private maxConnections: number = 100;
|
||||
private cleanupInterval?: NodeJS.Timeout;
|
||||
|
||||
// New refactored server implementation
|
||||
private smtpServerImpl: ISmtpServer;
|
||||
|
||||
constructor(emailServerRefArg: UnifiedEmailServer, optionsArg: ISmtpServerOptions) {
|
||||
console.log('SMTPServer instance is being created (using refactored implementation)...');
|
||||
|
||||
// Store original arguments and properties for backward compatibility
|
||||
this.emailServerRef = emailServerRefArg;
|
||||
this.smtpServerOptions = optionsArg;
|
||||
this.sessions = new Map();
|
||||
this.sessionTimeouts = new Map();
|
||||
this.hostname = optionsArg.hostname || 'mail.lossless.one';
|
||||
this.maxConnections = optionsArg.maxConnections || 100;
|
||||
|
||||
// Log enhanced server configuration
|
||||
const socketTimeout = optionsArg.socketTimeout || 300000;
|
||||
const connectionTimeout = optionsArg.connectionTimeout || 30000;
|
||||
const cleanupFrequency = optionsArg.cleanupInterval || 5000;
|
||||
|
||||
logger.log('info', 'SMTP server configuration', {
|
||||
hostname: this.hostname,
|
||||
maxConnections: this.maxConnections,
|
||||
socketTimeout: socketTimeout,
|
||||
connectionTimeout: connectionTimeout,
|
||||
cleanupInterval: cleanupFrequency,
|
||||
tlsEnabled: !!(optionsArg.key && optionsArg.cert),
|
||||
starttlsEnabled: !!(optionsArg.key && optionsArg.cert),
|
||||
securePort: optionsArg.securePort
|
||||
});
|
||||
|
||||
// Create the refactored SMTP server implementation
|
||||
this.smtpServerImpl = createSmtpServer(emailServerRefArg, optionsArg);
|
||||
|
||||
// Initialize server properties to support existing test code
|
||||
// These will be properly set during the listen() call
|
||||
this.server = new plugins.net.Server();
|
||||
|
||||
if (optionsArg.key && optionsArg.cert) {
|
||||
try {
|
||||
// Convert certificates to Buffer format for Node.js TLS
|
||||
// This helps prevent ASN.1 encoding issues when Node parses the certificates
|
||||
// Use explicit 'utf8' encoding to handle PEM certificates properly
|
||||
const key = Buffer.from(optionsArg.key, 'utf8');
|
||||
const cert = Buffer.from(optionsArg.cert, 'utf8');
|
||||
const ca = optionsArg.ca ? Buffer.from(optionsArg.ca, 'utf8') : undefined;
|
||||
|
||||
logger.log('warn', 'SMTP SERVER: Creating TLS server with certificates', {
|
||||
keyBufferLength: key.length,
|
||||
certBufferLength: cert.length,
|
||||
caBufferLength: ca ? ca.length : 0,
|
||||
keyPreview: key.toString('utf8').substring(0, 50),
|
||||
certPreview: cert.toString('utf8').substring(0, 50)
|
||||
});
|
||||
|
||||
// TLS configuration for secure connections with broader compatibility
|
||||
const tlsOptions: plugins.tls.TlsOptions = {
|
||||
key: key,
|
||||
cert: cert,
|
||||
ca: ca,
|
||||
// Support a wider range of TLS versions for better compatibility
|
||||
// Note: this is a key fix for the "wrong version number" error
|
||||
minVersion: 'TLSv1', // Support older TLS versions (minimum TLS 1.0)
|
||||
maxVersion: 'TLSv1.3', // Support latest TLS version (1.3)
|
||||
// Let the client choose the cipher for better compatibility
|
||||
honorCipherOrder: false,
|
||||
// Allow self-signed certificates for test environments
|
||||
rejectUnauthorized: false,
|
||||
// Enable session reuse for better performance
|
||||
sessionTimeout: 600,
|
||||
// Use a broader set of ciphers for maximum compatibility
|
||||
ciphers: 'HIGH:MEDIUM:!aNULL:!eNULL:!NULL:!ADH:!RC4',
|
||||
// TLS renegotiation option (removed - not supported in newer Node.js)
|
||||
// Longer handshake timeout for reliability
|
||||
handshakeTimeout: 30000,
|
||||
// Disable secure options to allow more flexibility
|
||||
secureOptions: 0,
|
||||
// For debugging
|
||||
enableTrace: true
|
||||
};
|
||||
|
||||
this.secureServer = plugins.tls.createServer(tlsOptions);
|
||||
|
||||
logger.log('info', 'TLS server created successfully');
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to create secure server: ${error instanceof Error ? error.message : String(error)}`, {
|
||||
error: error instanceof Error ? error.stack : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set up session events to maintain legacy behavior
|
||||
const sessionManager = this.smtpServerImpl.getSessionManager();
|
||||
|
||||
// Track sessions for backward compatibility
|
||||
sessionManager.on('created', (session, socket) => {
|
||||
this.sessions.set(socket, session);
|
||||
this.connectionCount++;
|
||||
});
|
||||
|
||||
sessionManager.on('completed', (session, socket) => {
|
||||
this.sessions.delete(socket);
|
||||
this.connectionCount--;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the SMTP server and listen on the specified port
|
||||
* @returns A promise that resolves when the server is listening
|
||||
*/
|
||||
public listen(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.smtpServerImpl.listen()
|
||||
.then(() => {
|
||||
// Get created servers for test compatibility
|
||||
// Get the actual server instances for backward compatibility
|
||||
const netServer = (this.smtpServerImpl as any).server;
|
||||
if (netServer) {
|
||||
this.server = netServer;
|
||||
}
|
||||
|
||||
const tlsServer = (this.smtpServerImpl as any).secureServer;
|
||||
if (tlsServer) {
|
||||
this.secureServer = tlsServer;
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch(err => {
|
||||
logger.log('error', `Failed to start SMTP server: ${err.message}`, {
|
||||
stack: err.stack
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the SMTP server
|
||||
* @returns A promise that resolves when the server has stopped
|
||||
*/
|
||||
public close(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.smtpServerImpl.close()
|
||||
.then(() => {
|
||||
// Clean up legacy resources
|
||||
this.sessions.clear();
|
||||
for (const timeoutId of this.sessionTimeouts.values()) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
this.sessionTimeouts.clear();
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = undefined;
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch(err => {
|
||||
logger.log('error', `Failed to stop SMTP server: ${err.message}`, {
|
||||
stack: err.stack
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private handleNewConnection(socket: plugins.net.Socket): void {
|
||||
logger.log('warn', 'Using deprecated handleNewConnection method');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private handleNewSecureConnection(socket: plugins.tls.TLSSocket): void {
|
||||
logger.log('warn', 'Using deprecated handleNewSecureConnection method');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private cleanupIdleSessions(): void {
|
||||
// This is now handled by the session manager in the refactored implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return `${Date.now()}-${++this.sessionIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private removeSession(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
||||
// This is now handled by the session manager in the refactored implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, line: string): void {
|
||||
// This is now handled by the command handler in the refactored implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private handleDataChunk(socket: plugins.net.Socket | plugins.tls.TLSSocket, chunk: string): void {
|
||||
// This is now handled by the data handler in the refactored implementation
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the refactored implementation directly
|
||||
* Maintained for backward compatibility
|
||||
*/
|
||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
||||
try {
|
||||
socket.write(`${response}\r\n`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Error sending response: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
response,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
remotePort: socket.remotePort
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current active connection count
|
||||
* @returns Number of active connections
|
||||
*/
|
||||
public getConnectionCount(): number {
|
||||
return this.connectionCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the refactored SMTP server implementation
|
||||
* This provides access to the new implementation for future use
|
||||
*/
|
||||
public getSmtpServerImpl(): ISmtpServer {
|
||||
return this.smtpServerImpl;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// Email delivery components
|
||||
export * from './classes.smtpserver.js';
|
||||
export * from './smtpserver/index.js';
|
||||
export * from './classes.emailsignjob.js';
|
||||
export * from './classes.delivery.queue.js';
|
||||
export * from './classes.delivery.system.js';
|
||||
|
232
ts/mail/delivery/smtpclient/auth-handler.ts
Normal file
232
ts/mail/delivery/smtpclient/auth-handler.ts
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* SMTP Client Authentication Handler
|
||||
* Authentication mechanisms implementation
|
||||
*/
|
||||
|
||||
import { AUTH_METHODS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpAuthOptions,
|
||||
ISmtpClientOptions,
|
||||
ISmtpResponse,
|
||||
IOAuth2Options
|
||||
} from './interfaces.js';
|
||||
import {
|
||||
encodeAuthPlain,
|
||||
encodeAuthLogin,
|
||||
generateOAuth2String,
|
||||
isSuccessCode
|
||||
} from './utils/helpers.js';
|
||||
import { logAuthentication, logDebug } from './utils/logging.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
|
||||
export class AuthHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
private commandHandler: CommandHandler;
|
||||
|
||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
||||
this.options = options;
|
||||
this.commandHandler = commandHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using the configured method
|
||||
*/
|
||||
public async authenticate(connection: ISmtpConnection): Promise<void> {
|
||||
if (!this.options.auth) {
|
||||
logDebug('No authentication configured', this.options);
|
||||
return;
|
||||
}
|
||||
|
||||
const authOptions = this.options.auth;
|
||||
const capabilities = connection.capabilities;
|
||||
|
||||
if (!capabilities || capabilities.authMethods.size === 0) {
|
||||
throw new Error('Server does not support authentication');
|
||||
}
|
||||
|
||||
// Determine authentication method
|
||||
const method = this.selectAuthMethod(authOptions, capabilities.authMethods);
|
||||
|
||||
logAuthentication('start', method, this.options);
|
||||
|
||||
try {
|
||||
switch (method) {
|
||||
case AUTH_METHODS.PLAIN:
|
||||
await this.authenticatePlain(connection, authOptions);
|
||||
break;
|
||||
case AUTH_METHODS.LOGIN:
|
||||
await this.authenticateLogin(connection, authOptions);
|
||||
break;
|
||||
case AUTH_METHODS.OAUTH2:
|
||||
await this.authenticateOAuth2(connection, authOptions);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported authentication method: ${method}`);
|
||||
}
|
||||
|
||||
logAuthentication('success', method, this.options);
|
||||
} catch (error) {
|
||||
logAuthentication('failure', method, this.options, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using AUTH PLAIN
|
||||
*/
|
||||
private async authenticatePlain(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.user || !auth.pass) {
|
||||
throw new Error('Username and password required for PLAIN authentication');
|
||||
}
|
||||
|
||||
const credentials = encodeAuthPlain(auth.user, auth.pass);
|
||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.PLAIN, credentials);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`PLAIN authentication failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using AUTH LOGIN
|
||||
*/
|
||||
private async authenticateLogin(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.user || !auth.pass) {
|
||||
throw new Error('Username and password required for LOGIN authentication');
|
||||
}
|
||||
|
||||
// Step 1: Send AUTH LOGIN
|
||||
let response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.LOGIN);
|
||||
|
||||
if (response.code !== 334) {
|
||||
throw new Error(`LOGIN authentication initiation failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Step 2: Send username
|
||||
const encodedUser = encodeAuthLogin(auth.user);
|
||||
response = await this.commandHandler.sendCommand(connection, encodedUser);
|
||||
|
||||
if (response.code !== 334) {
|
||||
throw new Error(`LOGIN username failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Send password
|
||||
const encodedPass = encodeAuthLogin(auth.pass);
|
||||
response = await this.commandHandler.sendCommand(connection, encodedPass);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`LOGIN password failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using OAuth2
|
||||
*/
|
||||
private async authenticateOAuth2(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise<void> {
|
||||
if (!auth.oauth2) {
|
||||
throw new Error('OAuth2 configuration required for OAUTH2 authentication');
|
||||
}
|
||||
|
||||
let accessToken = auth.oauth2.accessToken;
|
||||
|
||||
// Refresh token if needed
|
||||
if (!accessToken || this.isTokenExpired(auth.oauth2)) {
|
||||
accessToken = await this.refreshOAuth2Token(auth.oauth2);
|
||||
}
|
||||
|
||||
const authString = generateOAuth2String(auth.oauth2.user, accessToken);
|
||||
const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.OAUTH2, authString);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`OAUTH2 authentication failed: ${response.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select appropriate authentication method
|
||||
*/
|
||||
private selectAuthMethod(auth: ISmtpAuthOptions, serverMethods: Set<string>): string {
|
||||
// If method is explicitly specified, use it
|
||||
if (auth.method && auth.method !== 'AUTO') {
|
||||
const method = auth.method === 'OAUTH2' ? AUTH_METHODS.OAUTH2 : auth.method;
|
||||
if (serverMethods.has(method)) {
|
||||
return method;
|
||||
}
|
||||
throw new Error(`Requested authentication method ${auth.method} not supported by server`);
|
||||
}
|
||||
|
||||
// Auto-select based on available credentials and server support
|
||||
if (auth.oauth2 && serverMethods.has(AUTH_METHODS.OAUTH2)) {
|
||||
return AUTH_METHODS.OAUTH2;
|
||||
}
|
||||
|
||||
if (auth.user && auth.pass) {
|
||||
// Prefer PLAIN over LOGIN for simplicity
|
||||
if (serverMethods.has(AUTH_METHODS.PLAIN)) {
|
||||
return AUTH_METHODS.PLAIN;
|
||||
}
|
||||
if (serverMethods.has(AUTH_METHODS.LOGIN)) {
|
||||
return AUTH_METHODS.LOGIN;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No compatible authentication method found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth2 token is expired
|
||||
*/
|
||||
private isTokenExpired(oauth2: IOAuth2Options): boolean {
|
||||
if (!oauth2.expires) {
|
||||
return false; // No expiry information, assume valid
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const buffer = 300000; // 5 minutes buffer
|
||||
|
||||
return oauth2.expires < (now + buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth2 access token
|
||||
*/
|
||||
private async refreshOAuth2Token(oauth2: IOAuth2Options): Promise<string> {
|
||||
// This is a simplified implementation
|
||||
// In a real implementation, you would make an HTTP request to the OAuth2 provider
|
||||
logDebug('OAuth2 token refresh required', this.options);
|
||||
|
||||
if (!oauth2.refreshToken) {
|
||||
throw new Error('Refresh token required for OAuth2 token refresh');
|
||||
}
|
||||
|
||||
// TODO: Implement actual OAuth2 token refresh
|
||||
// For now, throw an error to indicate this needs to be implemented
|
||||
throw new Error('OAuth2 token refresh not implemented. Please provide a valid access token.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authentication configuration
|
||||
*/
|
||||
public validateAuthConfig(auth: ISmtpAuthOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (auth.method === 'OAUTH2' || auth.oauth2) {
|
||||
if (!auth.oauth2) {
|
||||
errors.push('OAuth2 configuration required when using OAUTH2 method');
|
||||
} else {
|
||||
if (!auth.oauth2.user) errors.push('OAuth2 user required');
|
||||
if (!auth.oauth2.clientId) errors.push('OAuth2 clientId required');
|
||||
if (!auth.oauth2.clientSecret) errors.push('OAuth2 clientSecret required');
|
||||
if (!auth.oauth2.refreshToken && !auth.oauth2.accessToken) {
|
||||
errors.push('OAuth2 refreshToken or accessToken required');
|
||||
}
|
||||
}
|
||||
} else if (auth.method === 'PLAIN' || auth.method === 'LOGIN' || (!auth.method && (auth.user || auth.pass))) {
|
||||
if (!auth.user) errors.push('Username required for basic authentication');
|
||||
if (!auth.pass) errors.push('Password required for basic authentication');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
336
ts/mail/delivery/smtpclient/command-handler.ts
Normal file
336
ts/mail/delivery/smtpclient/command-handler.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* SMTP Client Command Handler
|
||||
* SMTP command sending and response parsing
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { SMTP_COMMANDS, SMTP_CODES, LINE_ENDINGS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpResponse,
|
||||
ISmtpClientOptions,
|
||||
ISmtpCapabilities
|
||||
} from './interfaces.js';
|
||||
import {
|
||||
parseSmtpResponse,
|
||||
parseEhloResponse,
|
||||
formatCommand,
|
||||
isSuccessCode
|
||||
} from './utils/helpers.js';
|
||||
import { logCommand, logDebug } from './utils/logging.js';
|
||||
|
||||
export class CommandHandler extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private responseBuffer: string = '';
|
||||
private pendingCommand: { resolve: Function; reject: Function; command: string } | null = null;
|
||||
private commandTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send EHLO command and parse capabilities
|
||||
*/
|
||||
public async sendEhlo(connection: ISmtpConnection, domain?: string): Promise<ISmtpCapabilities> {
|
||||
const hostname = domain || this.options.domain || 'localhost';
|
||||
const command = `${SMTP_COMMANDS.EHLO} ${hostname}`;
|
||||
|
||||
const response = await this.sendCommand(connection, command);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`EHLO failed: ${response.message}`);
|
||||
}
|
||||
|
||||
const capabilities = parseEhloResponse(response.raw);
|
||||
connection.capabilities = capabilities;
|
||||
|
||||
logDebug('EHLO capabilities parsed', this.options, { capabilities });
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MAIL FROM command
|
||||
*/
|
||||
public async sendMailFrom(connection: ISmtpConnection, fromAddress: string): Promise<ISmtpResponse> {
|
||||
const command = `${SMTP_COMMANDS.MAIL_FROM}:<${fromAddress}>`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send RCPT TO command
|
||||
*/
|
||||
public async sendRcptTo(connection: ISmtpConnection, toAddress: string): Promise<ISmtpResponse> {
|
||||
const command = `${SMTP_COMMANDS.RCPT_TO}:<${toAddress}>`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send DATA command
|
||||
*/
|
||||
public async sendData(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email data content
|
||||
*/
|
||||
public async sendDataContent(connection: ISmtpConnection, emailData: string): Promise<ISmtpResponse> {
|
||||
// Ensure email data ends with CRLF.CRLF
|
||||
let data = emailData;
|
||||
if (!data.endsWith(LINE_ENDINGS.CRLF)) {
|
||||
data += LINE_ENDINGS.CRLF;
|
||||
}
|
||||
data += '.' + LINE_ENDINGS.CRLF;
|
||||
|
||||
// Perform dot stuffing (escape lines starting with a dot)
|
||||
data = data.replace(/\n\./g, '\n..');
|
||||
|
||||
return this.sendRawData(connection, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send RSET command
|
||||
*/
|
||||
public async sendRset(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.RSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send NOOP command
|
||||
*/
|
||||
public async sendNoop(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.NOOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send QUIT command
|
||||
*/
|
||||
public async sendQuit(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.QUIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send STARTTLS command
|
||||
*/
|
||||
public async sendStartTls(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return this.sendCommand(connection, SMTP_COMMANDS.STARTTLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send AUTH command
|
||||
*/
|
||||
public async sendAuth(connection: ISmtpConnection, method: string, credentials?: string): Promise<ISmtpResponse> {
|
||||
const command = credentials ?
|
||||
`${SMTP_COMMANDS.AUTH} ${method} ${credentials}` :
|
||||
`${SMTP_COMMANDS.AUTH} ${method}`;
|
||||
return this.sendCommand(connection, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic SMTP command
|
||||
*/
|
||||
public async sendCommand(connection: ISmtpConnection, command: string): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.pendingCommand) {
|
||||
reject(new Error('Another command is already pending'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCommand = { resolve, reject, command };
|
||||
|
||||
// Set command timeout
|
||||
const timeout = 30000; // 30 seconds
|
||||
this.commandTimeout = setTimeout(() => {
|
||||
this.pendingCommand = null;
|
||||
this.commandTimeout = null;
|
||||
reject(new Error(`Command timeout: ${command}`));
|
||||
}, timeout);
|
||||
|
||||
// Set up data handler
|
||||
const dataHandler = (data: Buffer) => {
|
||||
this.handleIncomingData(data.toString());
|
||||
};
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = () => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
if (this.commandTimeout) {
|
||||
clearTimeout(this.commandTimeout);
|
||||
this.commandTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Send command
|
||||
const formattedCommand = command.endsWith(LINE_ENDINGS.CRLF) ? command : formatCommand(command);
|
||||
|
||||
logCommand(command, undefined, this.options);
|
||||
logDebug(`Sending command: ${command}`, this.options);
|
||||
|
||||
connection.socket.write(formattedCommand, (error) => {
|
||||
if (error) {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Override resolve/reject to include cleanup
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
logCommand(command, response, this.options);
|
||||
originalResolve(response);
|
||||
};
|
||||
|
||||
this.pendingCommand.reject = (error: Error) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalReject(error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send raw data without command formatting
|
||||
*/
|
||||
public async sendRawData(connection: ISmtpConnection, data: string): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.pendingCommand) {
|
||||
reject(new Error('Another command is already pending'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingCommand = { resolve, reject, command: 'DATA_CONTENT' };
|
||||
|
||||
// Set data timeout
|
||||
const timeout = 60000; // 60 seconds for data
|
||||
this.commandTimeout = setTimeout(() => {
|
||||
this.pendingCommand = null;
|
||||
this.commandTimeout = null;
|
||||
reject(new Error('Data transmission timeout'));
|
||||
}, timeout);
|
||||
|
||||
// Set up data handler
|
||||
const dataHandler = (chunk: Buffer) => {
|
||||
this.handleIncomingData(chunk.toString());
|
||||
};
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
|
||||
// Clean up function
|
||||
const cleanup = () => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
if (this.commandTimeout) {
|
||||
clearTimeout(this.commandTimeout);
|
||||
this.commandTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Override resolve/reject to include cleanup
|
||||
const originalResolve = resolve;
|
||||
const originalReject = reject;
|
||||
|
||||
this.pendingCommand.resolve = (response: ISmtpResponse) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalResolve(response);
|
||||
};
|
||||
|
||||
this.pendingCommand.reject = (error: Error) => {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
originalReject(error);
|
||||
};
|
||||
|
||||
// Send data
|
||||
connection.socket.write(data, (error) => {
|
||||
if (error) {
|
||||
cleanup();
|
||||
this.pendingCommand = null;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server greeting
|
||||
*/
|
||||
public async waitForGreeting(connection: ISmtpConnection): Promise<ISmtpResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 30000; // 30 seconds
|
||||
let timeoutHandler: NodeJS.Timeout;
|
||||
|
||||
const dataHandler = (data: Buffer) => {
|
||||
this.responseBuffer += data.toString();
|
||||
|
||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
||||
clearTimeout(timeoutHandler);
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
|
||||
const response = parseSmtpResponse(this.responseBuffer);
|
||||
this.responseBuffer = '';
|
||||
|
||||
if (isSuccessCode(response.code)) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`Server greeting failed: ${response.message}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
timeoutHandler = setTimeout(() => {
|
||||
connection.socket.removeListener('data', dataHandler);
|
||||
reject(new Error('Greeting timeout'));
|
||||
}, timeout);
|
||||
|
||||
connection.socket.on('data', dataHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private handleIncomingData(data: string): void {
|
||||
if (!this.pendingCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.responseBuffer += data;
|
||||
|
||||
if (this.isCompleteResponse(this.responseBuffer)) {
|
||||
const response = parseSmtpResponse(this.responseBuffer);
|
||||
this.responseBuffer = '';
|
||||
|
||||
if (isSuccessCode(response.code) || response.code >= 400) {
|
||||
this.pendingCommand.resolve(response);
|
||||
} else {
|
||||
this.pendingCommand.reject(new Error(`Command failed: ${response.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isCompleteResponse(buffer: string): boolean {
|
||||
// Check if we have a complete response
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
|
||||
if (lines.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check the last non-empty line
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.length > 0) {
|
||||
// Response is complete if line starts with "XXX " (space after code)
|
||||
return /^\d{3} /.test(line);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
286
ts/mail/delivery/smtpclient/connection-manager.ts
Normal file
286
ts/mail/delivery/smtpclient/connection-manager.ts
Normal file
@ -0,0 +1,286 @@
|
||||
/**
|
||||
* SMTP Client Connection Manager
|
||||
* Connection pooling and lifecycle management
|
||||
*/
|
||||
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { DEFAULTS, CONNECTION_STATES } from './constants.js';
|
||||
import type {
|
||||
ISmtpClientOptions,
|
||||
ISmtpConnection,
|
||||
IConnectionPoolStatus,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { logConnection, logDebug } from './utils/logging.js';
|
||||
import { generateConnectionId } from './utils/helpers.js';
|
||||
|
||||
export class ConnectionManager extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private connections: Map<string, ISmtpConnection> = new Map();
|
||||
private pendingConnections: Set<string> = new Set();
|
||||
private idleTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
this.setupIdleCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection
|
||||
*/
|
||||
public async getConnection(): Promise<ISmtpConnection> {
|
||||
// Try to reuse an idle connection if pooling is enabled
|
||||
if (this.options.pool) {
|
||||
const idleConnection = this.findIdleConnection();
|
||||
if (idleConnection) {
|
||||
const connectionId = this.getConnectionId(idleConnection) || 'unknown';
|
||||
logDebug('Reusing idle connection', this.options, { connectionId });
|
||||
return idleConnection;
|
||||
}
|
||||
|
||||
// Check if we can create a new connection
|
||||
if (this.getActiveConnectionCount() >= (this.options.maxConnections || DEFAULTS.MAX_CONNECTIONS)) {
|
||||
throw new Error('Maximum number of connections reached');
|
||||
}
|
||||
}
|
||||
|
||||
return this.createConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new connection
|
||||
*/
|
||||
public async createConnection(): Promise<ISmtpConnection> {
|
||||
const connectionId = generateConnectionId();
|
||||
|
||||
try {
|
||||
this.pendingConnections.add(connectionId);
|
||||
logConnection('connecting', this.options, { connectionId });
|
||||
|
||||
const socket = await this.establishSocket();
|
||||
const connection: ISmtpConnection = {
|
||||
socket,
|
||||
state: CONNECTION_STATES.CONNECTED as ConnectionState,
|
||||
options: this.options,
|
||||
secure: this.options.secure || false,
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date(),
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
this.setupSocketHandlers(socket, connectionId);
|
||||
this.connections.set(connectionId, connection);
|
||||
this.pendingConnections.delete(connectionId);
|
||||
|
||||
logConnection('connected', this.options, { connectionId });
|
||||
this.emit('connection', connection);
|
||||
|
||||
return connection;
|
||||
} catch (error) {
|
||||
this.pendingConnections.delete(connectionId);
|
||||
logConnection('error', this.options, { connectionId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a connection back to the pool or close it
|
||||
*/
|
||||
public releaseConnection(connection: ISmtpConnection): void {
|
||||
const connectionId = this.getConnectionId(connection);
|
||||
|
||||
if (!connectionId || !this.connections.has(connectionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.pool && this.shouldReuseConnection(connection)) {
|
||||
// Return to pool
|
||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
||||
connection.lastActivity = new Date();
|
||||
logDebug('Connection returned to pool', this.options, { connectionId });
|
||||
} else {
|
||||
// Close connection
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific connection
|
||||
*/
|
||||
public closeConnection(connection: ISmtpConnection): void {
|
||||
const connectionId = this.getConnectionId(connection);
|
||||
|
||||
if (connectionId) {
|
||||
this.connections.delete(connectionId);
|
||||
}
|
||||
|
||||
connection.state = CONNECTION_STATES.CLOSING as ConnectionState;
|
||||
|
||||
try {
|
||||
if (!connection.socket.destroyed) {
|
||||
connection.socket.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logDebug('Error closing connection', this.options, { error });
|
||||
}
|
||||
|
||||
logConnection('disconnected', this.options, { connectionId });
|
||||
this.emit('disconnect', connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections
|
||||
*/
|
||||
public closeAllConnections(): void {
|
||||
logDebug('Closing all connections', this.options);
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
|
||||
this.connections.clear();
|
||||
this.pendingConnections.clear();
|
||||
|
||||
if (this.idleTimeout) {
|
||||
clearInterval(this.idleTimeout);
|
||||
this.idleTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection pool status
|
||||
*/
|
||||
public getPoolStatus(): IConnectionPoolStatus {
|
||||
const total = this.connections.size;
|
||||
const active = Array.from(this.connections.values())
|
||||
.filter(conn => conn.state === CONNECTION_STATES.BUSY).length;
|
||||
const idle = total - active;
|
||||
const pending = this.pendingConnections.size;
|
||||
|
||||
return { total, active, idle, pending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection activity timestamp
|
||||
*/
|
||||
public updateActivity(connection: ISmtpConnection): void {
|
||||
connection.lastActivity = new Date();
|
||||
}
|
||||
|
||||
private async establishSocket(): Promise<net.Socket | tls.TLSSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
let socket: net.Socket | tls.TLSSocket;
|
||||
|
||||
if (this.options.secure) {
|
||||
// Direct TLS connection
|
||||
socket = tls.connect({
|
||||
host: this.options.host,
|
||||
port: this.options.port,
|
||||
...this.options.tls
|
||||
});
|
||||
} else {
|
||||
// Plain connection
|
||||
socket = new net.Socket();
|
||||
socket.connect(this.options.port, this.options.host);
|
||||
}
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
socket.once('connect', () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setupSocketHandlers(socket: net.Socket | tls.TLSSocket, connectionId: string): void {
|
||||
const socketTimeout = this.options.socketTimeout || DEFAULTS.SOCKET_TIMEOUT;
|
||||
|
||||
socket.setTimeout(socketTimeout);
|
||||
|
||||
socket.on('timeout', () => {
|
||||
logDebug('Socket timeout', this.options, { connectionId });
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
logConnection('error', this.options, { connectionId, error });
|
||||
this.connections.delete(connectionId);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
this.connections.delete(connectionId);
|
||||
logDebug('Socket closed', this.options, { connectionId });
|
||||
});
|
||||
}
|
||||
|
||||
private findIdleConnection(): ISmtpConnection | null {
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.state === CONNECTION_STATES.READY) {
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private shouldReuseConnection(connection: ISmtpConnection): boolean {
|
||||
const maxMessages = this.options.maxMessages || DEFAULTS.MAX_MESSAGES;
|
||||
const maxAge = 300000; // 5 minutes
|
||||
const age = Date.now() - connection.createdAt.getTime();
|
||||
|
||||
return connection.messageCount < maxMessages &&
|
||||
age < maxAge &&
|
||||
!connection.socket.destroyed;
|
||||
}
|
||||
|
||||
private getActiveConnectionCount(): number {
|
||||
return this.connections.size + this.pendingConnections.size;
|
||||
}
|
||||
|
||||
private getConnectionId(connection: ISmtpConnection): string | null {
|
||||
for (const [id, conn] of this.connections.entries()) {
|
||||
if (conn === connection) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private setupIdleCleanup(): void {
|
||||
if (!this.options.pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupInterval = DEFAULTS.POOL_IDLE_TIMEOUT;
|
||||
|
||||
this.idleTimeout = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const connectionsToClose: ISmtpConnection[] = [];
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
const idleTime = now - connection.lastActivity.getTime();
|
||||
|
||||
if (connection.state === CONNECTION_STATES.READY && idleTime > cleanupInterval) {
|
||||
connectionsToClose.push(connection);
|
||||
}
|
||||
}
|
||||
|
||||
for (const connection of connectionsToClose) {
|
||||
logDebug('Closing idle connection', this.options);
|
||||
this.closeConnection(connection);
|
||||
}
|
||||
}, cleanupInterval);
|
||||
}
|
||||
}
|
145
ts/mail/delivery/smtpclient/constants.ts
Normal file
145
ts/mail/delivery/smtpclient/constants.ts
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* SMTP Client Constants and Error Codes
|
||||
* All constants, error codes, and enums for SMTP client operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* SMTP response codes
|
||||
*/
|
||||
export const SMTP_CODES = {
|
||||
// Positive completion replies
|
||||
SERVICE_READY: 220,
|
||||
SERVICE_CLOSING: 221,
|
||||
AUTHENTICATION_SUCCESSFUL: 235,
|
||||
REQUESTED_ACTION_OK: 250,
|
||||
USER_NOT_LOCAL: 251,
|
||||
CANNOT_VERIFY_USER: 252,
|
||||
|
||||
// Positive intermediate replies
|
||||
START_MAIL_INPUT: 354,
|
||||
|
||||
// Transient negative completion replies
|
||||
SERVICE_NOT_AVAILABLE: 421,
|
||||
MAILBOX_BUSY: 450,
|
||||
LOCAL_ERROR: 451,
|
||||
INSUFFICIENT_STORAGE: 452,
|
||||
UNABLE_TO_ACCOMMODATE: 455,
|
||||
|
||||
// Permanent negative completion replies
|
||||
SYNTAX_ERROR: 500,
|
||||
SYNTAX_ERROR_PARAMETERS: 501,
|
||||
COMMAND_NOT_IMPLEMENTED: 502,
|
||||
BAD_SEQUENCE: 503,
|
||||
PARAMETER_NOT_IMPLEMENTED: 504,
|
||||
MAILBOX_UNAVAILABLE: 550,
|
||||
USER_NOT_LOCAL_TRY_FORWARD: 551,
|
||||
EXCEEDED_STORAGE: 552,
|
||||
MAILBOX_NAME_NOT_ALLOWED: 553,
|
||||
TRANSACTION_FAILED: 554
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SMTP command names
|
||||
*/
|
||||
export const SMTP_COMMANDS = {
|
||||
HELO: 'HELO',
|
||||
EHLO: 'EHLO',
|
||||
MAIL_FROM: 'MAIL FROM',
|
||||
RCPT_TO: 'RCPT TO',
|
||||
DATA: 'DATA',
|
||||
RSET: 'RSET',
|
||||
NOOP: 'NOOP',
|
||||
QUIT: 'QUIT',
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Authentication methods
|
||||
*/
|
||||
export const AUTH_METHODS = {
|
||||
PLAIN: 'PLAIN',
|
||||
LOGIN: 'LOGIN',
|
||||
OAUTH2: 'XOAUTH2',
|
||||
CRAM_MD5: 'CRAM-MD5'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common SMTP extensions
|
||||
*/
|
||||
export const SMTP_EXTENSIONS = {
|
||||
PIPELINING: 'PIPELINING',
|
||||
SIZE: 'SIZE',
|
||||
STARTTLS: 'STARTTLS',
|
||||
AUTH: 'AUTH',
|
||||
EIGHT_BIT_MIME: '8BITMIME',
|
||||
CHUNKING: 'CHUNKING',
|
||||
ENHANCED_STATUS_CODES: 'ENHANCEDSTATUSCODES',
|
||||
DSN: 'DSN'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
CONNECTION_TIMEOUT: 60000, // 60 seconds
|
||||
SOCKET_TIMEOUT: 300000, // 5 minutes
|
||||
COMMAND_TIMEOUT: 30000, // 30 seconds
|
||||
MAX_CONNECTIONS: 5,
|
||||
MAX_MESSAGES: 100,
|
||||
PORT_SMTP: 25,
|
||||
PORT_SUBMISSION: 587,
|
||||
PORT_SMTPS: 465,
|
||||
RETRY_ATTEMPTS: 3,
|
||||
RETRY_DELAY: 1000,
|
||||
POOL_IDLE_TIMEOUT: 30000 // 30 seconds
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error types for classification
|
||||
*/
|
||||
export enum SmtpErrorType {
|
||||
CONNECTION_ERROR = 'CONNECTION_ERROR',
|
||||
AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR',
|
||||
PROTOCOL_ERROR = 'PROTOCOL_ERROR',
|
||||
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
|
||||
TLS_ERROR = 'TLS_ERROR',
|
||||
SYNTAX_ERROR = 'SYNTAX_ERROR',
|
||||
MAILBOX_ERROR = 'MAILBOX_ERROR',
|
||||
QUOTA_ERROR = 'QUOTA_ERROR',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expressions for parsing
|
||||
*/
|
||||
export const REGEX_PATTERNS = {
|
||||
EMAIL_ADDRESS: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
RESPONSE_CODE: /^(\d{3})([ -])(.*)/,
|
||||
ENHANCED_STATUS: /^(\d\.\d\.\d)\s/,
|
||||
AUTH_CAPABILITIES: /AUTH\s+(.+)/i,
|
||||
SIZE_EXTENSION: /SIZE\s+(\d+)/i
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Line endings and separators
|
||||
*/
|
||||
export const LINE_ENDINGS = {
|
||||
CRLF: '\r\n',
|
||||
LF: '\n',
|
||||
CR: '\r'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Connection states for internal use
|
||||
*/
|
||||
export const CONNECTION_STATES = {
|
||||
DISCONNECTED: 'disconnected',
|
||||
CONNECTING: 'connecting',
|
||||
CONNECTED: 'connected',
|
||||
AUTHENTICATED: 'authenticated',
|
||||
READY: 'ready',
|
||||
BUSY: 'busy',
|
||||
CLOSING: 'closing',
|
||||
ERROR: 'error'
|
||||
} as const;
|
94
ts/mail/delivery/smtpclient/create-client.ts
Normal file
94
ts/mail/delivery/smtpclient/create-client.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* SMTP Client Factory
|
||||
* Factory function for client creation and dependency injection
|
||||
*/
|
||||
|
||||
import { SmtpClient } from './smtp-client.js';
|
||||
import { ConnectionManager } from './connection-manager.js';
|
||||
import { CommandHandler } from './command-handler.js';
|
||||
import { AuthHandler } from './auth-handler.js';
|
||||
import { TlsHandler } from './tls-handler.js';
|
||||
import { SmtpErrorHandler } from './error-handler.js';
|
||||
import type { ISmtpClientOptions } from './interfaces.js';
|
||||
import { validateClientOptions } from './utils/validation.js';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
|
||||
/**
|
||||
* Create a complete SMTP client with all components
|
||||
*/
|
||||
export function createSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
// Validate options
|
||||
const errors = validateClientOptions(options);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid client options: ${errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
const clientOptions: ISmtpClientOptions = {
|
||||
connectionTimeout: DEFAULTS.CONNECTION_TIMEOUT,
|
||||
socketTimeout: DEFAULTS.SOCKET_TIMEOUT,
|
||||
maxConnections: DEFAULTS.MAX_CONNECTIONS,
|
||||
maxMessages: DEFAULTS.MAX_MESSAGES,
|
||||
pool: false,
|
||||
secure: false,
|
||||
debug: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Create handlers
|
||||
const errorHandler = new SmtpErrorHandler(clientOptions);
|
||||
const connectionManager = new ConnectionManager(clientOptions);
|
||||
const commandHandler = new CommandHandler(clientOptions);
|
||||
const authHandler = new AuthHandler(clientOptions, commandHandler);
|
||||
const tlsHandler = new TlsHandler(clientOptions, commandHandler);
|
||||
|
||||
// Create and return SMTP client
|
||||
return new SmtpClient({
|
||||
options: clientOptions,
|
||||
connectionManager,
|
||||
commandHandler,
|
||||
authHandler,
|
||||
tlsHandler,
|
||||
errorHandler
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client with connection pooling enabled
|
||||
*/
|
||||
export function createPooledSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: true,
|
||||
maxConnections: options.maxConnections || DEFAULTS.MAX_CONNECTIONS,
|
||||
maxMessages: options.maxMessages || DEFAULTS.MAX_MESSAGES
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client for high-volume sending
|
||||
*/
|
||||
export function createBulkSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: true,
|
||||
maxConnections: Math.max(options.maxConnections || 10, 10),
|
||||
maxMessages: Math.max(options.maxMessages || 1000, 1000),
|
||||
connectionTimeout: options.connectionTimeout || 30000,
|
||||
socketTimeout: options.socketTimeout || 120000
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SMTP client for transactional emails
|
||||
*/
|
||||
export function createTransactionalSmtpClient(options: ISmtpClientOptions): SmtpClient {
|
||||
return createSmtpClient({
|
||||
...options,
|
||||
pool: false, // Use fresh connections for transactional emails
|
||||
maxConnections: 1,
|
||||
maxMessages: 1,
|
||||
connectionTimeout: options.connectionTimeout || 10000,
|
||||
socketTimeout: options.socketTimeout || 30000
|
||||
});
|
||||
}
|
141
ts/mail/delivery/smtpclient/error-handler.ts
Normal file
141
ts/mail/delivery/smtpclient/error-handler.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* SMTP Client Error Handler
|
||||
* Error classification and recovery strategies
|
||||
*/
|
||||
|
||||
import { SmtpErrorType } from './constants.js';
|
||||
import type { ISmtpResponse, ISmtpErrorContext, ISmtpClientOptions } from './interfaces.js';
|
||||
import { logDebug } from './utils/logging.js';
|
||||
|
||||
export class SmtpErrorHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
|
||||
constructor(options: ISmtpClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type based on response or error
|
||||
*/
|
||||
public classifyError(error: Error | ISmtpResponse, context?: ISmtpErrorContext): SmtpErrorType {
|
||||
logDebug('Classifying error', this.options, { errorMessage: error instanceof Error ? error.message : String(error), context });
|
||||
|
||||
// Handle Error objects
|
||||
if (error instanceof Error) {
|
||||
return this.classifyErrorByMessage(error);
|
||||
}
|
||||
|
||||
// Handle SMTP response codes
|
||||
if (typeof error === 'object' && 'code' in error) {
|
||||
return this.classifyErrorByCode(error.code);
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if error is retryable
|
||||
*/
|
||||
public isRetryable(errorType: SmtpErrorType, response?: ISmtpResponse): boolean {
|
||||
switch (errorType) {
|
||||
case SmtpErrorType.CONNECTION_ERROR:
|
||||
case SmtpErrorType.TIMEOUT_ERROR:
|
||||
return true;
|
||||
|
||||
case SmtpErrorType.PROTOCOL_ERROR:
|
||||
// Only retry on temporary failures (4xx codes)
|
||||
return response ? response.code >= 400 && response.code < 500 : false;
|
||||
|
||||
case SmtpErrorType.AUTHENTICATION_ERROR:
|
||||
case SmtpErrorType.TLS_ERROR:
|
||||
case SmtpErrorType.SYNTAX_ERROR:
|
||||
case SmtpErrorType.MAILBOX_ERROR:
|
||||
case SmtpErrorType.QUOTA_ERROR:
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retry delay for error type
|
||||
*/
|
||||
public getRetryDelay(attempt: number, errorType: SmtpErrorType): number {
|
||||
const baseDelay = 1000; // 1 second
|
||||
const maxDelay = 30000; // 30 seconds
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||
const jitter = Math.random() * 0.1 * delay; // 10% jitter
|
||||
|
||||
return Math.floor(delay + jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced error with context
|
||||
*/
|
||||
public createError(
|
||||
message: string,
|
||||
errorType: SmtpErrorType,
|
||||
context?: ISmtpErrorContext,
|
||||
originalError?: Error
|
||||
): Error {
|
||||
const error = new Error(message);
|
||||
(error as any).type = errorType;
|
||||
(error as any).context = context;
|
||||
(error as any).originalError = originalError;
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
private classifyErrorByMessage(error: Error): SmtpErrorType {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes('timeout') || message.includes('etimedout')) {
|
||||
return SmtpErrorType.TIMEOUT_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('connect') || message.includes('econnrefused') ||
|
||||
message.includes('enotfound') || message.includes('enetunreach')) {
|
||||
return SmtpErrorType.CONNECTION_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('tls') || message.includes('ssl') ||
|
||||
message.includes('certificate') || message.includes('handshake')) {
|
||||
return SmtpErrorType.TLS_ERROR;
|
||||
}
|
||||
|
||||
if (message.includes('auth')) {
|
||||
return SmtpErrorType.AUTHENTICATION_ERROR;
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
private classifyErrorByCode(code: number): SmtpErrorType {
|
||||
if (code >= 500) {
|
||||
// Permanent failures
|
||||
if (code === 550 || code === 551 || code === 553) {
|
||||
return SmtpErrorType.MAILBOX_ERROR;
|
||||
}
|
||||
if (code === 552) {
|
||||
return SmtpErrorType.QUOTA_ERROR;
|
||||
}
|
||||
if (code === 500 || code === 501 || code === 502 || code === 504) {
|
||||
return SmtpErrorType.SYNTAX_ERROR;
|
||||
}
|
||||
return SmtpErrorType.PROTOCOL_ERROR;
|
||||
}
|
||||
|
||||
if (code >= 400) {
|
||||
// Temporary failures
|
||||
if (code === 450 || code === 451 || code === 452) {
|
||||
return SmtpErrorType.QUOTA_ERROR;
|
||||
}
|
||||
return SmtpErrorType.PROTOCOL_ERROR;
|
||||
}
|
||||
|
||||
return SmtpErrorType.UNKNOWN_ERROR;
|
||||
}
|
||||
}
|
24
ts/mail/delivery/smtpclient/index.ts
Normal file
24
ts/mail/delivery/smtpclient/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* SMTP Client Module Exports
|
||||
* Modular SMTP client implementation for robust email delivery
|
||||
*/
|
||||
|
||||
// Main client class and factory
|
||||
export * from './smtp-client.js';
|
||||
export * from './create-client.js';
|
||||
|
||||
// Core handlers
|
||||
export * from './connection-manager.js';
|
||||
export * from './command-handler.js';
|
||||
export * from './auth-handler.js';
|
||||
export * from './tls-handler.js';
|
||||
export * from './error-handler.js';
|
||||
|
||||
// Interfaces and types
|
||||
export * from './interfaces.js';
|
||||
export * from './constants.js';
|
||||
|
||||
// Utilities
|
||||
export * from './utils/validation.js';
|
||||
export * from './utils/logging.js';
|
||||
export * from './utils/helpers.js';
|
242
ts/mail/delivery/smtpclient/interfaces.ts
Normal file
242
ts/mail/delivery/smtpclient/interfaces.ts
Normal file
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* SMTP Client Interfaces and Types
|
||||
* All interface definitions for the modular SMTP client
|
||||
*/
|
||||
|
||||
import type * as tls from 'node:tls';
|
||||
import type * as net from 'node:net';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
|
||||
/**
|
||||
* SMTP client connection options
|
||||
*/
|
||||
export interface ISmtpClientOptions {
|
||||
/** Hostname of the SMTP server */
|
||||
host: string;
|
||||
|
||||
/** Port to connect to */
|
||||
port: number;
|
||||
|
||||
/** Whether to use TLS for the connection */
|
||||
secure?: boolean;
|
||||
|
||||
/** Connection timeout in milliseconds */
|
||||
connectionTimeout?: number;
|
||||
|
||||
/** Socket timeout in milliseconds */
|
||||
socketTimeout?: number;
|
||||
|
||||
/** Domain name for EHLO command */
|
||||
domain?: string;
|
||||
|
||||
/** Authentication options */
|
||||
auth?: ISmtpAuthOptions;
|
||||
|
||||
/** TLS options */
|
||||
tls?: tls.ConnectionOptions;
|
||||
|
||||
/** Maximum number of connections in pool */
|
||||
pool?: boolean;
|
||||
maxConnections?: number;
|
||||
maxMessages?: number;
|
||||
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
|
||||
/** Proxy settings */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication options for SMTP
|
||||
*/
|
||||
export interface ISmtpAuthOptions {
|
||||
/** Username */
|
||||
user?: string;
|
||||
|
||||
/** Password */
|
||||
pass?: string;
|
||||
|
||||
/** OAuth2 settings */
|
||||
oauth2?: IOAuth2Options;
|
||||
|
||||
/** Authentication method preference */
|
||||
method?: 'PLAIN' | 'LOGIN' | 'OAUTH2' | 'AUTO';
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 authentication options
|
||||
*/
|
||||
export interface IOAuth2Options {
|
||||
/** OAuth2 user identifier */
|
||||
user: string;
|
||||
|
||||
/** OAuth2 client ID */
|
||||
clientId: string;
|
||||
|
||||
/** OAuth2 client secret */
|
||||
clientSecret: string;
|
||||
|
||||
/** OAuth2 refresh token */
|
||||
refreshToken: string;
|
||||
|
||||
/** OAuth2 access token */
|
||||
accessToken?: string;
|
||||
|
||||
/** Token expiry time */
|
||||
expires?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of an email send operation
|
||||
*/
|
||||
export interface ISmtpSendResult {
|
||||
/** Whether the send was successful */
|
||||
success: boolean;
|
||||
|
||||
/** Message ID from server */
|
||||
messageId?: string;
|
||||
|
||||
/** List of accepted recipients */
|
||||
acceptedRecipients: string[];
|
||||
|
||||
/** List of rejected recipients */
|
||||
rejectedRecipients: string[];
|
||||
|
||||
/** Error information if failed */
|
||||
error?: Error;
|
||||
|
||||
/** Server response */
|
||||
response?: string;
|
||||
|
||||
/** Envelope information */
|
||||
envelope?: ISmtpEnvelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP envelope information
|
||||
*/
|
||||
export interface ISmtpEnvelope {
|
||||
/** Sender address */
|
||||
from: string;
|
||||
|
||||
/** Recipient addresses */
|
||||
to: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection pool status
|
||||
*/
|
||||
export interface IConnectionPoolStatus {
|
||||
/** Total connections in pool */
|
||||
total: number;
|
||||
|
||||
/** Active connections */
|
||||
active: number;
|
||||
|
||||
/** Idle connections */
|
||||
idle: number;
|
||||
|
||||
/** Pending connection requests */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP command response
|
||||
*/
|
||||
export interface ISmtpResponse {
|
||||
/** Response code */
|
||||
code: number;
|
||||
|
||||
/** Response message */
|
||||
message: string;
|
||||
|
||||
/** Enhanced status code */
|
||||
enhancedCode?: string;
|
||||
|
||||
/** Raw response */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state
|
||||
*/
|
||||
export enum ConnectionState {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
AUTHENTICATED = 'authenticated',
|
||||
READY = 'ready',
|
||||
BUSY = 'busy',
|
||||
CLOSING = 'closing',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP capabilities
|
||||
*/
|
||||
export interface ISmtpCapabilities {
|
||||
/** Supported extensions */
|
||||
extensions: Set<string>;
|
||||
|
||||
/** Maximum message size */
|
||||
maxSize?: number;
|
||||
|
||||
/** Supported authentication methods */
|
||||
authMethods: Set<string>;
|
||||
|
||||
/** Support for pipelining */
|
||||
pipelining: boolean;
|
||||
|
||||
/** Support for STARTTLS */
|
||||
starttls: boolean;
|
||||
|
||||
/** Support for 8BITMIME */
|
||||
eightBitMime: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal connection interface
|
||||
*/
|
||||
export interface ISmtpConnection {
|
||||
/** Socket connection */
|
||||
socket: net.Socket | tls.TLSSocket;
|
||||
|
||||
/** Connection state */
|
||||
state: ConnectionState;
|
||||
|
||||
/** Server capabilities */
|
||||
capabilities?: ISmtpCapabilities;
|
||||
|
||||
/** Connection options */
|
||||
options: ISmtpClientOptions;
|
||||
|
||||
/** Whether connection is secure */
|
||||
secure: boolean;
|
||||
|
||||
/** Connection creation time */
|
||||
createdAt: Date;
|
||||
|
||||
/** Last activity time */
|
||||
lastActivity: Date;
|
||||
|
||||
/** Number of messages sent */
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error context for detailed error reporting
|
||||
*/
|
||||
export interface ISmtpErrorContext {
|
||||
/** Command that caused the error */
|
||||
command?: string;
|
||||
|
||||
/** Server response */
|
||||
response?: ISmtpResponse;
|
||||
|
||||
/** Connection state */
|
||||
connectionState?: ConnectionState;
|
||||
|
||||
/** Additional context data */
|
||||
data?: Record<string, any>;
|
||||
}
|
350
ts/mail/delivery/smtpclient/smtp-client.ts
Normal file
350
ts/mail/delivery/smtpclient/smtp-client.ts
Normal file
@ -0,0 +1,350 @@
|
||||
/**
|
||||
* SMTP Client Core Implementation
|
||||
* Main client class with delegation to handlers
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Email } from '../../core/classes.email.js';
|
||||
import type {
|
||||
ISmtpClientOptions,
|
||||
ISmtpSendResult,
|
||||
ISmtpConnection,
|
||||
IConnectionPoolStatus,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { CONNECTION_STATES, SmtpErrorType } from './constants.js';
|
||||
import type { ConnectionManager } from './connection-manager.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
import type { AuthHandler } from './auth-handler.js';
|
||||
import type { TlsHandler } from './tls-handler.js';
|
||||
import type { SmtpErrorHandler } from './error-handler.js';
|
||||
import { validateSender, validateRecipients } from './utils/validation.js';
|
||||
import { logEmailSend, logPerformance, logDebug } from './utils/logging.js';
|
||||
|
||||
interface ISmtpClientDependencies {
|
||||
options: ISmtpClientOptions;
|
||||
connectionManager: ConnectionManager;
|
||||
commandHandler: CommandHandler;
|
||||
authHandler: AuthHandler;
|
||||
tlsHandler: TlsHandler;
|
||||
errorHandler: SmtpErrorHandler;
|
||||
}
|
||||
|
||||
export class SmtpClient extends EventEmitter {
|
||||
private options: ISmtpClientOptions;
|
||||
private connectionManager: ConnectionManager;
|
||||
private commandHandler: CommandHandler;
|
||||
private authHandler: AuthHandler;
|
||||
private tlsHandler: TlsHandler;
|
||||
private errorHandler: SmtpErrorHandler;
|
||||
private isShuttingDown: boolean = false;
|
||||
|
||||
constructor(dependencies: ISmtpClientDependencies) {
|
||||
super();
|
||||
|
||||
this.options = dependencies.options;
|
||||
this.connectionManager = dependencies.connectionManager;
|
||||
this.commandHandler = dependencies.commandHandler;
|
||||
this.authHandler = dependencies.authHandler;
|
||||
this.tlsHandler = dependencies.tlsHandler;
|
||||
this.errorHandler = dependencies.errorHandler;
|
||||
|
||||
this.setupEventForwarding();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email
|
||||
*/
|
||||
public async sendMail(email: Email): Promise<ISmtpSendResult> {
|
||||
const startTime = Date.now();
|
||||
const fromAddress = email.from;
|
||||
const recipients = Array.isArray(email.to) ? email.to : [email.to];
|
||||
|
||||
// Validate email addresses
|
||||
if (!validateSender(fromAddress)) {
|
||||
throw new Error(`Invalid sender address: ${fromAddress}`);
|
||||
}
|
||||
|
||||
const recipientErrors = validateRecipients(recipients);
|
||||
if (recipientErrors.length > 0) {
|
||||
throw new Error(`Invalid recipients: ${recipientErrors.join(', ')}`);
|
||||
}
|
||||
|
||||
logEmailSend('start', recipients, this.options);
|
||||
|
||||
let connection: ISmtpConnection | null = null;
|
||||
const result: ISmtpSendResult = {
|
||||
success: false,
|
||||
acceptedRecipients: [],
|
||||
rejectedRecipients: [],
|
||||
envelope: {
|
||||
from: fromAddress,
|
||||
to: recipients
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Get connection
|
||||
connection = await this.connectionManager.getConnection();
|
||||
connection.state = CONNECTION_STATES.BUSY as ConnectionState;
|
||||
|
||||
// Wait for greeting if new connection
|
||||
if (!connection.capabilities) {
|
||||
await this.commandHandler.waitForGreeting(connection);
|
||||
}
|
||||
|
||||
// Perform EHLO
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
|
||||
// Upgrade to TLS if needed
|
||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
||||
await this.tlsHandler.upgradeToTLS(connection);
|
||||
// Re-send EHLO after TLS upgrade
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
}
|
||||
|
||||
// Authenticate if needed
|
||||
if (this.options.auth) {
|
||||
await this.authHandler.authenticate(connection);
|
||||
}
|
||||
|
||||
// Send MAIL FROM
|
||||
const mailFromResponse = await this.commandHandler.sendMailFrom(connection, fromAddress);
|
||||
if (mailFromResponse.code >= 400) {
|
||||
throw new Error(`MAIL FROM failed: ${mailFromResponse.message}`);
|
||||
}
|
||||
|
||||
// Send RCPT TO for each recipient
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
const rcptResponse = await this.commandHandler.sendRcptTo(connection, recipient);
|
||||
if (rcptResponse.code >= 400) {
|
||||
result.rejectedRecipients.push(recipient);
|
||||
logDebug(`Recipient rejected: ${recipient}`, this.options, { response: rcptResponse });
|
||||
} else {
|
||||
result.acceptedRecipients.push(recipient);
|
||||
}
|
||||
} catch (error) {
|
||||
result.rejectedRecipients.push(recipient);
|
||||
logDebug(`Recipient error: ${recipient}`, this.options, { error });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any accepted recipients
|
||||
if (result.acceptedRecipients.length === 0) {
|
||||
throw new Error('All recipients were rejected');
|
||||
}
|
||||
|
||||
// Send DATA command
|
||||
const dataResponse = await this.commandHandler.sendData(connection);
|
||||
if (dataResponse.code !== 354) {
|
||||
throw new Error(`DATA command failed: ${dataResponse.message}`);
|
||||
}
|
||||
|
||||
// Send email content
|
||||
const emailData = await this.formatEmailData(email);
|
||||
const sendResponse = await this.commandHandler.sendDataContent(connection, emailData);
|
||||
|
||||
if (sendResponse.code >= 400) {
|
||||
throw new Error(`Email data rejected: ${sendResponse.message}`);
|
||||
}
|
||||
|
||||
// Success
|
||||
result.success = true;
|
||||
result.messageId = this.extractMessageId(sendResponse.message);
|
||||
result.response = sendResponse.message;
|
||||
|
||||
connection.messageCount++;
|
||||
logEmailSend('success', recipients, this.options, {
|
||||
messageId: result.messageId,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.error = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
// Classify error and determine if we should retry
|
||||
const errorType = this.errorHandler.classifyError(result.error);
|
||||
result.error = this.errorHandler.createError(
|
||||
result.error.message,
|
||||
errorType,
|
||||
{ command: 'SEND_MAIL' },
|
||||
result.error
|
||||
);
|
||||
|
||||
logEmailSend('failure', recipients, this.options, {
|
||||
error: result.error,
|
||||
duration: Date.now() - startTime
|
||||
});
|
||||
|
||||
} finally {
|
||||
// Release connection
|
||||
if (connection) {
|
||||
connection.state = CONNECTION_STATES.READY as ConnectionState;
|
||||
this.connectionManager.updateActivity(connection);
|
||||
this.connectionManager.releaseConnection(connection);
|
||||
}
|
||||
|
||||
logPerformance('sendMail', Date.now() - startTime, this.options);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to SMTP server
|
||||
*/
|
||||
public async verify(): Promise<boolean> {
|
||||
let connection: ISmtpConnection | null = null;
|
||||
|
||||
try {
|
||||
connection = await this.connectionManager.createConnection();
|
||||
await this.commandHandler.waitForGreeting(connection);
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
|
||||
if (this.tlsHandler.shouldUseTLS(connection)) {
|
||||
await this.tlsHandler.upgradeToTLS(connection);
|
||||
await this.commandHandler.sendEhlo(connection, this.options.domain);
|
||||
}
|
||||
|
||||
if (this.options.auth) {
|
||||
await this.authHandler.authenticate(connection);
|
||||
}
|
||||
|
||||
await this.commandHandler.sendQuit(connection);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logDebug('Connection verification failed', this.options, { error });
|
||||
return false;
|
||||
|
||||
} finally {
|
||||
if (connection) {
|
||||
this.connectionManager.closeConnection(connection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is connected
|
||||
*/
|
||||
public isConnected(): boolean {
|
||||
const status = this.connectionManager.getPoolStatus();
|
||||
return status.total > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection pool status
|
||||
*/
|
||||
public getPoolStatus(): IConnectionPoolStatus {
|
||||
return this.connectionManager.getPoolStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client options
|
||||
*/
|
||||
public updateOptions(newOptions: Partial<ISmtpClientOptions>): void {
|
||||
this.options = { ...this.options, ...newOptions };
|
||||
logDebug('Client options updated', this.options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections and shutdown client
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isShuttingDown = true;
|
||||
logDebug('Shutting down SMTP client', this.options);
|
||||
|
||||
try {
|
||||
this.connectionManager.closeAllConnections();
|
||||
this.emit('close');
|
||||
} catch (error) {
|
||||
logDebug('Error during client shutdown', this.options, { error });
|
||||
}
|
||||
}
|
||||
|
||||
private async formatEmailData(email: Email): Promise<string> {
|
||||
// Convert Email object to raw SMTP data
|
||||
const headers: string[] = [];
|
||||
|
||||
// Required headers
|
||||
headers.push(`From: ${email.from}`);
|
||||
headers.push(`To: ${Array.isArray(email.to) ? email.to.join(', ') : email.to}`);
|
||||
headers.push(`Subject: ${email.subject || ''}`);
|
||||
headers.push(`Date: ${new Date().toUTCString()}`);
|
||||
headers.push(`Message-ID: <${Date.now()}.${Math.random().toString(36)}@${this.options.host}>`);
|
||||
|
||||
// Optional headers
|
||||
if (email.cc) {
|
||||
const cc = Array.isArray(email.cc) ? email.cc.join(', ') : email.cc;
|
||||
headers.push(`Cc: ${cc}`);
|
||||
}
|
||||
|
||||
if (email.bcc) {
|
||||
const bcc = Array.isArray(email.bcc) ? email.bcc.join(', ') : email.bcc;
|
||||
headers.push(`Bcc: ${bcc}`);
|
||||
}
|
||||
|
||||
// Content headers
|
||||
if (email.html && email.text) {
|
||||
// Multipart message
|
||||
const boundary = `boundary_${Date.now()}_${Math.random().toString(36)}`;
|
||||
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
||||
headers.push('MIME-Version: 1.0');
|
||||
|
||||
const body = [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
email.text,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
email.html,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n');
|
||||
|
||||
return headers.join('\r\n') + '\r\n\r\n' + body;
|
||||
} else if (email.html) {
|
||||
headers.push('Content-Type: text/html; charset=utf-8');
|
||||
headers.push('MIME-Version: 1.0');
|
||||
return headers.join('\r\n') + '\r\n\r\n' + email.html;
|
||||
} else {
|
||||
headers.push('Content-Type: text/plain; charset=utf-8');
|
||||
headers.push('MIME-Version: 1.0');
|
||||
return headers.join('\r\n') + '\r\n\r\n' + (email.text || '');
|
||||
}
|
||||
}
|
||||
|
||||
private extractMessageId(response: string): string | undefined {
|
||||
// Try to extract message ID from server response
|
||||
const match = response.match(/queued as ([^\s]+)/i) ||
|
||||
response.match(/id=([^\s]+)/i) ||
|
||||
response.match(/Message-ID: <([^>]+)>/i);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
private setupEventForwarding(): void {
|
||||
// Forward events from connection manager
|
||||
this.connectionManager.on('connection', (connection) => {
|
||||
this.emit('connection', connection);
|
||||
});
|
||||
|
||||
this.connectionManager.on('disconnect', (connection) => {
|
||||
this.emit('disconnect', connection);
|
||||
});
|
||||
|
||||
this.connectionManager.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
}
|
254
ts/mail/delivery/smtpclient/tls-handler.ts
Normal file
254
ts/mail/delivery/smtpclient/tls-handler.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* SMTP Client TLS Handler
|
||||
* TLS and STARTTLS client functionality
|
||||
*/
|
||||
|
||||
import * as tls from 'node:tls';
|
||||
import * as net from 'node:net';
|
||||
import { DEFAULTS } from './constants.js';
|
||||
import type {
|
||||
ISmtpConnection,
|
||||
ISmtpClientOptions,
|
||||
ConnectionState
|
||||
} from './interfaces.js';
|
||||
import { CONNECTION_STATES } from './constants.js';
|
||||
import { logTLS, logDebug } from './utils/logging.js';
|
||||
import { isSuccessCode } from './utils/helpers.js';
|
||||
import type { CommandHandler } from './command-handler.js';
|
||||
|
||||
export class TlsHandler {
|
||||
private options: ISmtpClientOptions;
|
||||
private commandHandler: CommandHandler;
|
||||
|
||||
constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) {
|
||||
this.options = options;
|
||||
this.commandHandler = commandHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade connection to TLS using STARTTLS
|
||||
*/
|
||||
public async upgradeToTLS(connection: ISmtpConnection): Promise<void> {
|
||||
if (connection.secure) {
|
||||
logDebug('Connection already secure', this.options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if STARTTLS is supported
|
||||
if (!connection.capabilities?.starttls) {
|
||||
throw new Error('Server does not support STARTTLS');
|
||||
}
|
||||
|
||||
logTLS('starttls_start', this.options);
|
||||
|
||||
try {
|
||||
// Send STARTTLS command
|
||||
const response = await this.commandHandler.sendStartTls(connection);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(`STARTTLS command failed: ${response.message}`);
|
||||
}
|
||||
|
||||
// Upgrade the socket to TLS
|
||||
await this.performTLSUpgrade(connection);
|
||||
|
||||
// Clear capabilities as they may have changed after TLS
|
||||
connection.capabilities = undefined;
|
||||
connection.secure = true;
|
||||
|
||||
logTLS('starttls_success', this.options);
|
||||
|
||||
} catch (error) {
|
||||
logTLS('starttls_failure', this.options, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a direct TLS connection
|
||||
*/
|
||||
public async createTLSConnection(host: string, port: number): Promise<tls.TLSSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
host,
|
||||
port,
|
||||
...this.options.tls,
|
||||
// Default TLS options for email
|
||||
secureProtocol: 'TLS_method',
|
||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
||||
};
|
||||
|
||||
logTLS('tls_connected', this.options, { host, port });
|
||||
|
||||
const socket = tls.connect(tlsOptions);
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`TLS connection timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
|
||||
if (!socket.authorized && this.options.tls?.rejectUnauthorized !== false) {
|
||||
socket.destroy();
|
||||
reject(new Error(`TLS certificate verification failed: ${socket.authorizationError}`));
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug('TLS connection established', this.options, {
|
||||
authorized: socket.authorized,
|
||||
protocol: socket.getProtocol(),
|
||||
cipher: socket.getCipher()
|
||||
});
|
||||
|
||||
resolve(socket);
|
||||
});
|
||||
|
||||
socket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TLS certificate
|
||||
*/
|
||||
public validateCertificate(socket: tls.TLSSocket): boolean {
|
||||
if (!socket.authorized) {
|
||||
logDebug('TLS certificate not authorized', this.options, {
|
||||
error: socket.authorizationError
|
||||
});
|
||||
|
||||
// Allow self-signed certificates if explicitly configured
|
||||
if (this.options.tls?.rejectUnauthorized === false) {
|
||||
logDebug('Accepting unauthorized certificate (rejectUnauthorized: false)', this.options);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const cert = socket.getPeerCertificate();
|
||||
if (!cert) {
|
||||
logDebug('No peer certificate available', this.options);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional certificate validation
|
||||
const now = new Date();
|
||||
if (cert.valid_from && new Date(cert.valid_from) > now) {
|
||||
logDebug('Certificate not yet valid', this.options, { validFrom: cert.valid_from });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cert.valid_to && new Date(cert.valid_to) < now) {
|
||||
logDebug('Certificate expired', this.options, { validTo: cert.valid_to });
|
||||
return false;
|
||||
}
|
||||
|
||||
logDebug('TLS certificate validated', this.options, {
|
||||
subject: cert.subject,
|
||||
issuer: cert.issuer,
|
||||
validFrom: cert.valid_from,
|
||||
validTo: cert.valid_to
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TLS connection information
|
||||
*/
|
||||
public getTLSInfo(socket: tls.TLSSocket): any {
|
||||
if (!(socket instanceof tls.TLSSocket)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: socket.authorized,
|
||||
authorizationError: socket.authorizationError,
|
||||
protocol: socket.getProtocol(),
|
||||
cipher: socket.getCipher(),
|
||||
peerCertificate: socket.getPeerCertificate(),
|
||||
alpnProtocol: socket.alpnProtocol
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TLS upgrade is required or recommended
|
||||
*/
|
||||
public shouldUseTLS(connection: ISmtpConnection): boolean {
|
||||
// Already secure
|
||||
if (connection.secure) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct TLS connection configured
|
||||
if (this.options.secure) {
|
||||
return false; // Already handled in connection establishment
|
||||
}
|
||||
|
||||
// STARTTLS available and not explicitly disabled
|
||||
if (connection.capabilities?.starttls) {
|
||||
return this.options.tls !== null && this.options.tls !== undefined; // Use TLS if configured
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async performTLSUpgrade(connection: ISmtpConnection): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const plainSocket = connection.socket as net.Socket;
|
||||
const timeout = this.options.connectionTimeout || DEFAULTS.CONNECTION_TIMEOUT;
|
||||
|
||||
const tlsOptions: tls.ConnectionOptions = {
|
||||
socket: plainSocket,
|
||||
host: this.options.host,
|
||||
...this.options.tls,
|
||||
// Default TLS options for STARTTLS
|
||||
secureProtocol: 'TLS_method',
|
||||
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA',
|
||||
rejectUnauthorized: this.options.tls?.rejectUnauthorized !== false
|
||||
};
|
||||
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
reject(new Error(`TLS upgrade timeout after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
// Create TLS socket from existing connection
|
||||
const tlsSocket = tls.connect(tlsOptions);
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
clearTimeout(timeoutHandler);
|
||||
|
||||
// Validate certificate if required
|
||||
if (!this.validateCertificate(tlsSocket)) {
|
||||
tlsSocket.destroy();
|
||||
reject(new Error('TLS certificate validation failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the socket in the connection
|
||||
connection.socket = tlsSocket;
|
||||
connection.secure = true;
|
||||
|
||||
logDebug('STARTTLS upgrade completed', this.options, {
|
||||
protocol: tlsSocket.getProtocol(),
|
||||
cipher: tlsSocket.getCipher()
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
tlsSocket.once('error', (error) => {
|
||||
clearTimeout(timeoutHandler);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
224
ts/mail/delivery/smtpclient/utils/helpers.ts
Normal file
224
ts/mail/delivery/smtpclient/utils/helpers.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* SMTP Client Helper Functions
|
||||
* Protocol helper functions and utilities
|
||||
*/
|
||||
|
||||
import { SMTP_CODES, REGEX_PATTERNS, LINE_ENDINGS } from '../constants.js';
|
||||
import type { ISmtpResponse, ISmtpCapabilities } from '../interfaces.js';
|
||||
|
||||
/**
|
||||
* Parse SMTP server response
|
||||
*/
|
||||
export function parseSmtpResponse(data: string): ISmtpResponse {
|
||||
const lines = data.trim().split(/\r?\n/);
|
||||
const firstLine = lines[0];
|
||||
const match = firstLine.match(REGEX_PATTERNS.RESPONSE_CODE);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
code: 500,
|
||||
message: 'Invalid server response',
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10);
|
||||
const separator = match[2];
|
||||
const message = lines.map(line => line.substring(4)).join(' ');
|
||||
|
||||
// Check for enhanced status code
|
||||
const enhancedMatch = message.match(REGEX_PATTERNS.ENHANCED_STATUS);
|
||||
const enhancedCode = enhancedMatch ? enhancedMatch[1] : undefined;
|
||||
|
||||
return {
|
||||
code,
|
||||
message: enhancedCode ? message.substring(enhancedCode.length + 1) : message,
|
||||
enhancedCode,
|
||||
raw: data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse EHLO response and extract capabilities
|
||||
*/
|
||||
export function parseEhloResponse(response: string): ISmtpCapabilities {
|
||||
const lines = response.trim().split(/\r?\n/);
|
||||
const capabilities: ISmtpCapabilities = {
|
||||
extensions: new Set(),
|
||||
authMethods: new Set(),
|
||||
pipelining: false,
|
||||
starttls: false,
|
||||
eightBitMime: false
|
||||
};
|
||||
|
||||
for (const line of lines.slice(1)) { // Skip first line (greeting)
|
||||
const extensionLine = line.substring(4); // Remove "250-" or "250 "
|
||||
const parts = extensionLine.split(/\s+/);
|
||||
const extension = parts[0].toUpperCase();
|
||||
|
||||
capabilities.extensions.add(extension);
|
||||
|
||||
switch (extension) {
|
||||
case 'PIPELINING':
|
||||
capabilities.pipelining = true;
|
||||
break;
|
||||
case 'STARTTLS':
|
||||
capabilities.starttls = true;
|
||||
break;
|
||||
case '8BITMIME':
|
||||
capabilities.eightBitMime = true;
|
||||
break;
|
||||
case 'SIZE':
|
||||
if (parts[1]) {
|
||||
capabilities.maxSize = parseInt(parts[1], 10);
|
||||
}
|
||||
break;
|
||||
case 'AUTH':
|
||||
// Parse authentication methods
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
capabilities.authMethods.add(parts[i].toUpperCase());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format SMTP command with proper line ending
|
||||
*/
|
||||
export function formatCommand(command: string, ...args: string[]): string {
|
||||
const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command;
|
||||
return fullCommand + LINE_ENDINGS.CRLF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode authentication string for AUTH PLAIN
|
||||
*/
|
||||
export function encodeAuthPlain(username: string, password: string): string {
|
||||
const authString = `\0${username}\0${password}`;
|
||||
return Buffer.from(authString, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode authentication string for AUTH LOGIN
|
||||
*/
|
||||
export function encodeAuthLogin(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth2 authentication string
|
||||
*/
|
||||
export function generateOAuth2String(username: string, accessToken: string): string {
|
||||
const authString = `user=${username}\x01auth=Bearer ${accessToken}\x01\x01`;
|
||||
return Buffer.from(authString, 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates success
|
||||
*/
|
||||
export function isSuccessCode(code: number): boolean {
|
||||
return code >= 200 && code < 300;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates temporary failure
|
||||
*/
|
||||
export function isTemporaryFailure(code: number): boolean {
|
||||
return code >= 400 && code < 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response code indicates permanent failure
|
||||
*/
|
||||
export function isPermanentFailure(code: number): boolean {
|
||||
return code >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape email address for SMTP commands
|
||||
*/
|
||||
export function escapeEmailAddress(email: string): string {
|
||||
return `<${email.trim()}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract email address from angle brackets
|
||||
*/
|
||||
export function extractEmailAddress(email: string): string {
|
||||
const match = email.match(/^<(.+)>$/);
|
||||
return match ? match[1] : email.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique connection ID
|
||||
*/
|
||||
export function generateConnectionId(): string {
|
||||
return `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timeout duration for human readability
|
||||
*/
|
||||
export function formatTimeout(milliseconds: number): string {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${Math.round(milliseconds / 1000)}s`;
|
||||
} else {
|
||||
return `${Math.round(milliseconds / 60000)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize email data size
|
||||
*/
|
||||
export function validateEmailSize(emailData: string, maxSize?: number): boolean {
|
||||
const size = Buffer.byteLength(emailData, 'utf8');
|
||||
return !maxSize || size <= maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean sensitive data from logs
|
||||
*/
|
||||
export function sanitizeForLogging(data: any): any {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const sanitized = { ...data };
|
||||
const sensitiveFields = ['password', 'pass', 'accessToken', 'refreshToken', 'clientSecret'];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (field in sanitized) {
|
||||
sanitized[field] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
*/
|
||||
export function calculateBackoffDelay(attempt: number, baseDelay: number = 1000): number {
|
||||
return Math.min(baseDelay * Math.pow(2, attempt - 1), 30000); // Max 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse enhanced status code
|
||||
*/
|
||||
export function parseEnhancedStatusCode(code: string): { class: number; subject: number; detail: number } | null {
|
||||
const match = code.match(/^(\d)\.(\d)\.(\d)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
class: parseInt(match[1], 10),
|
||||
subject: parseInt(match[2], 10),
|
||||
detail: parseInt(match[3], 10)
|
||||
};
|
||||
}
|
212
ts/mail/delivery/smtpclient/utils/logging.ts
Normal file
212
ts/mail/delivery/smtpclient/utils/logging.ts
Normal file
@ -0,0 +1,212 @@
|
||||
/**
|
||||
* SMTP Client Logging Utilities
|
||||
* Client-side logging utilities for SMTP operations
|
||||
*/
|
||||
|
||||
import { logger } from '../../../../logger.js';
|
||||
import type { ISmtpResponse, ISmtpClientOptions } from '../interfaces.js';
|
||||
|
||||
export interface ISmtpClientLogData {
|
||||
component: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
secure?: boolean;
|
||||
command?: string;
|
||||
response?: ISmtpResponse;
|
||||
error?: Error;
|
||||
connectionId?: string;
|
||||
messageId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log SMTP client connection events
|
||||
*/
|
||||
export function logConnection(
|
||||
event: 'connecting' | 'connected' | 'disconnected' | 'error',
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
secure: options.secure,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
logger.info('SMTP client connecting', logData);
|
||||
break;
|
||||
case 'connected':
|
||||
logger.info('SMTP client connected', logData);
|
||||
break;
|
||||
case 'disconnected':
|
||||
logger.info('SMTP client disconnected', logData);
|
||||
break;
|
||||
case 'error':
|
||||
logger.error('SMTP client connection error', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log SMTP command execution
|
||||
*/
|
||||
export function logCommand(
|
||||
command: string,
|
||||
response?: ISmtpResponse,
|
||||
options?: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
command,
|
||||
response,
|
||||
host: options?.host,
|
||||
port: options?.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (response && response.code >= 400) {
|
||||
logger.warn('SMTP command failed', logData);
|
||||
} else {
|
||||
logger.debug('SMTP command executed', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
export function logAuthentication(
|
||||
event: 'start' | 'success' | 'failure',
|
||||
method: string,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event: `auth_${event}`,
|
||||
authMethod: method,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'start':
|
||||
logger.debug('SMTP authentication started', logData);
|
||||
break;
|
||||
case 'success':
|
||||
logger.info('SMTP authentication successful', logData);
|
||||
break;
|
||||
case 'failure':
|
||||
logger.error('SMTP authentication failed', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log TLS/STARTTLS events
|
||||
*/
|
||||
export function logTLS(
|
||||
event: 'starttls_start' | 'starttls_success' | 'starttls_failure' | 'tls_connected',
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (event.includes('failure')) {
|
||||
logger.error('SMTP TLS operation failed', logData);
|
||||
} else {
|
||||
logger.info('SMTP TLS operation', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log email sending events
|
||||
*/
|
||||
export function logEmailSend(
|
||||
event: 'start' | 'success' | 'failure',
|
||||
recipients: string[],
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
event: `send_${event}`,
|
||||
recipientCount: recipients.length,
|
||||
recipients: recipients.slice(0, 5), // Only log first 5 recipients for privacy
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
switch (event) {
|
||||
case 'start':
|
||||
logger.info('SMTP email send started', logData);
|
||||
break;
|
||||
case 'success':
|
||||
logger.info('SMTP email send successful', logData);
|
||||
break;
|
||||
case 'failure':
|
||||
logger.error('SMTP email send failed', logData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance metrics
|
||||
*/
|
||||
export function logPerformance(
|
||||
operation: string,
|
||||
duration: number,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client',
|
||||
operation,
|
||||
duration,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
if (duration > 10000) { // Log slow operations (>10s)
|
||||
logger.warn('SMTP slow operation detected', logData);
|
||||
} else {
|
||||
logger.debug('SMTP operation performance', logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information (only when debug is enabled)
|
||||
*/
|
||||
export function logDebug(
|
||||
message: string,
|
||||
options: ISmtpClientOptions,
|
||||
data?: Partial<ISmtpClientLogData>
|
||||
): void {
|
||||
if (!options.debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logData: ISmtpClientLogData = {
|
||||
component: 'smtp-client-debug',
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
...data
|
||||
};
|
||||
|
||||
logger.debug(`[SMTP Client Debug] ${message}`, logData);
|
||||
}
|
154
ts/mail/delivery/smtpclient/utils/validation.ts
Normal file
154
ts/mail/delivery/smtpclient/utils/validation.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* SMTP Client Validation Utilities
|
||||
* Input validation functions for SMTP client operations
|
||||
*/
|
||||
|
||||
import { REGEX_PATTERNS } from '../constants.js';
|
||||
import type { ISmtpClientOptions, ISmtpAuthOptions } from '../interfaces.js';
|
||||
|
||||
/**
|
||||
* Validate email address format
|
||||
*/
|
||||
export function validateEmailAddress(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return REGEX_PATTERNS.EMAIL_ADDRESS.test(email.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SMTP client options
|
||||
*/
|
||||
export function validateClientOptions(options: ISmtpClientOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required fields
|
||||
if (!options.host || typeof options.host !== 'string') {
|
||||
errors.push('Host is required and must be a string');
|
||||
}
|
||||
|
||||
if (!options.port || typeof options.port !== 'number' || options.port < 1 || options.port > 65535) {
|
||||
errors.push('Port must be a number between 1 and 65535');
|
||||
}
|
||||
|
||||
// Optional field validation
|
||||
if (options.connectionTimeout !== undefined) {
|
||||
if (typeof options.connectionTimeout !== 'number' || options.connectionTimeout < 1000) {
|
||||
errors.push('Connection timeout must be a number >= 1000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.socketTimeout !== undefined) {
|
||||
if (typeof options.socketTimeout !== 'number' || options.socketTimeout < 1000) {
|
||||
errors.push('Socket timeout must be a number >= 1000ms');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxConnections !== undefined) {
|
||||
if (typeof options.maxConnections !== 'number' || options.maxConnections < 1) {
|
||||
errors.push('Max connections must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxMessages !== undefined) {
|
||||
if (typeof options.maxMessages !== 'number' || options.maxMessages < 1) {
|
||||
errors.push('Max messages must be a positive number');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate authentication options
|
||||
if (options.auth) {
|
||||
errors.push(...validateAuthOptions(options.auth));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate authentication options
|
||||
*/
|
||||
export function validateAuthOptions(auth: ISmtpAuthOptions): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (auth.method && !['PLAIN', 'LOGIN', 'OAUTH2', 'AUTO'].includes(auth.method)) {
|
||||
errors.push('Invalid authentication method');
|
||||
}
|
||||
|
||||
// For basic auth, require user and pass
|
||||
if ((auth.user || auth.pass) && (!auth.user || !auth.pass)) {
|
||||
errors.push('Both user and pass are required for basic authentication');
|
||||
}
|
||||
|
||||
// For OAuth2, validate required fields
|
||||
if (auth.oauth2) {
|
||||
const oauth = auth.oauth2;
|
||||
if (!oauth.user || !oauth.clientId || !oauth.clientSecret || !oauth.refreshToken) {
|
||||
errors.push('OAuth2 requires user, clientId, clientSecret, and refreshToken');
|
||||
}
|
||||
|
||||
if (oauth.user && !validateEmailAddress(oauth.user)) {
|
||||
errors.push('OAuth2 user must be a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hostname format
|
||||
*/
|
||||
export function validateHostname(hostname: string): boolean {
|
||||
if (!hostname || typeof hostname !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic hostname validation (allow IP addresses and domain names)
|
||||
const hostnameRegex = /^(?:[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])?$|^(?:\d{1,3}\.){3}\d{1,3}$|^\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\]$/;
|
||||
return hostnameRegex.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate port number
|
||||
*/
|
||||
export function validatePort(port: number): boolean {
|
||||
return typeof port === 'number' && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize and validate domain name for EHLO
|
||||
*/
|
||||
export function validateAndSanitizeDomain(domain: string): string {
|
||||
if (!domain || typeof domain !== 'string') {
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
const sanitized = domain.trim().toLowerCase();
|
||||
if (validateHostname(sanitized)) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate recipient list
|
||||
*/
|
||||
export function validateRecipients(recipients: string | string[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const recipientList = Array.isArray(recipients) ? recipients : [recipients];
|
||||
|
||||
for (const recipient of recipientList) {
|
||||
if (!validateEmailAddress(recipient)) {
|
||||
errors.push(`Invalid email address: ${recipient}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sender address
|
||||
*/
|
||||
export function validateSender(sender: string): boolean {
|
||||
return validateEmailAddress(sender);
|
||||
}
|
@ -25,7 +25,7 @@ import { BounceManager, BounceType, BounceCategory } from '../core/classes.bounc
|
||||
import * as net from 'node:net';
|
||||
import * as tls from 'node:tls';
|
||||
import * as stream from 'node:stream';
|
||||
import { SMTPServer as MtaSmtpServer } from '../delivery/classes.smtpserver.js';
|
||||
import { createSmtpServer } from '../delivery/smtpserver/index.js';
|
||||
import { MultiModeDeliverySystem, type IMultiModeDeliveryOptions } from '../delivery/classes.delivery.system.js';
|
||||
import { UnifiedDeliveryQueue, type IQueueOptions } from '../delivery/classes.delivery.queue.js';
|
||||
import { SmtpState } from '../delivery/interfaces.js';
|
||||
@ -141,7 +141,7 @@ export interface IServerStats {
|
||||
export class UnifiedEmailServer extends EventEmitter {
|
||||
private options: IUnifiedEmailServerOptions;
|
||||
private domainRouter: DomainRouter;
|
||||
private servers: MtaSmtpServer[] = [];
|
||||
private servers: any[] = [];
|
||||
private stats: IServerStats;
|
||||
private processingTimes: number[] = [];
|
||||
|
||||
@ -361,7 +361,7 @@ export class UnifiedEmailServer extends EventEmitter {
|
||||
};
|
||||
|
||||
// Create and start the SMTP server
|
||||
const smtpServer = new MtaSmtpServer(mtaRef as any, serverOptions);
|
||||
const smtpServer = createSmtpServer(mtaRef as any, serverOptions);
|
||||
this.servers.push(smtpServer);
|
||||
|
||||
// Start the server
|
||||
|
Reference in New Issue
Block a user