/** * SMTP / email delivery with nodemailer pooled transport * * Replaces both Python's SMTPPool and EmailDelivery classes. * nodemailer handles connection pooling, keepalive, and reconnection natively. * * Removed: LMTP delivery path (never used in production). */ import { createTransport, type Transporter } from 'nodemailer'; import { log } from '../logger.js'; import { config } from '../config.js'; // --------------------------------------------------------------------------- // Permanent error detection // --------------------------------------------------------------------------- const PERMANENT_INDICATORS = [ '550', '551', '553', 'mailbox not found', 'user unknown', 'no such user', 'recipient rejected', 'does not exist', 'invalid recipient', 'unknown user', ]; function isPermanentRecipientError(errorMsg: string): boolean { const lower = errorMsg.toLowerCase(); return PERMANENT_INDICATORS.some((ind) => lower.includes(ind)); } // --------------------------------------------------------------------------- // Delivery class // --------------------------------------------------------------------------- export class EmailDelivery { private transport: Transporter; constructor() { this.transport = createTransport({ host: config.smtpHost, port: config.smtpPort, secure: config.smtpUseTls, pool: true, maxConnections: config.smtpPoolSize, maxMessages: Infinity, // reuse connections indefinitely tls: { rejectUnauthorized: false }, ...(config.smtpUser && config.smtpPass ? { auth: { user: config.smtpUser, pass: config.smtpPass } } : {}), }); log( `📡 SMTP pool initialized → ${config.smtpHost}:${config.smtpPort} ` + `(max ${config.smtpPoolSize} connections)`, ); } /** * Send raw email to ONE recipient via the local DMS. * * Returns: [success, errorMessage?, isPermanent] */ async sendToRecipient( fromAddr: string, recipient: string, rawMessage: Buffer, workerName: string, maxRetries = 2, ): Promise<[boolean, string | null, boolean]> { let lastError: string | null = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { await this.transport.sendMail({ envelope: { from: fromAddr, to: [recipient] }, raw: rawMessage, }); log(` ✓ ${recipient}: Delivered (SMTP)`, 'SUCCESS', workerName); return [true, null, false]; } catch (err: any) { const errorMsg = err.message ?? String(err); const responseCode = err.responseCode ?? 0; // Check for permanent errors (5xx) if ( responseCode >= 550 || isPermanentRecipientError(errorMsg) ) { log( ` ✗ ${recipient}: ${errorMsg} (permanent)`, 'ERROR', workerName, ); return [false, errorMsg, true]; } // Connection-level errors → retry if ( err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || errorMsg.toLowerCase().includes('disconnect') || errorMsg.toLowerCase().includes('closed') || errorMsg.toLowerCase().includes('connection') ) { log( ` ⚠ ${recipient}: Connection error, retrying... ` + `(attempt ${attempt + 1}/${maxRetries + 1})`, 'WARNING', workerName, ); lastError = errorMsg; await sleep(300); continue; } // Other SMTP errors const isPerm = isPermanentRecipientError(errorMsg); log( ` ✗ ${recipient}: ${errorMsg} (${isPerm ? 'permanent' : 'temporary'})`, 'ERROR', workerName, ); return [false, errorMsg, isPerm]; } } // All retries exhausted log( ` ✗ ${recipient}: All retries failed - ${lastError}`, 'ERROR', workerName, ); return [false, lastError ?? 'Connection failed after retries', false]; } /** Verify the transport is reachable (used during startup). */ async verify(): Promise { try { await this.transport.verify(); return true; } catch { return false; } } /** Close all pooled connections. */ close(): void { this.transport.close(); } } // --------------------------------------------------------------------------- function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }