This commit is contained in:
2025-05-22 10:18:02 +00:00
parent 7c0f9b4e44
commit ac419e7b79
21 changed files with 4541 additions and 1868 deletions

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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;

View 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
});
}

View 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;
}
}

View 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';

View 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>;
}

View 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);
});
}
}

View 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);
});
});
}
}

View 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)
};
}

View 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);
}

View 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);
}