/** * 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 { 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 { 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 { 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); }); }); } }