import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import { logger } from '../logger.js'; import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../security/index.js'; import { LRUCache } from 'lru-cache'; /** * Bounce types for categorizing the reasons for bounces */ export enum BounceType { // Hard bounces (permanent failures) INVALID_RECIPIENT = 'invalid_recipient', DOMAIN_NOT_FOUND = 'domain_not_found', MAILBOX_FULL = 'mailbox_full', MAILBOX_INACTIVE = 'mailbox_inactive', BLOCKED = 'blocked', SPAM_RELATED = 'spam_related', POLICY_RELATED = 'policy_related', // Soft bounces (temporary failures) SERVER_UNAVAILABLE = 'server_unavailable', TEMPORARY_FAILURE = 'temporary_failure', QUOTA_EXCEEDED = 'quota_exceeded', NETWORK_ERROR = 'network_error', TIMEOUT = 'timeout', // Special cases AUTO_RESPONSE = 'auto_response', CHALLENGE_RESPONSE = 'challenge_response', UNKNOWN = 'unknown' } /** * Hard vs soft bounce classification */ export enum BounceCategory { HARD = 'hard', SOFT = 'soft', AUTO_RESPONSE = 'auto_response', UNKNOWN = 'unknown' } /** * Bounce data structure */ export interface BounceRecord { id: string; originalEmailId?: string; recipient: string; sender: string; domain: string; subject?: string; bounceType: BounceType; bounceCategory: BounceCategory; timestamp: number; smtpResponse?: string; diagnosticCode?: string; statusCode?: string; headers?: Record; processed: boolean; retryCount?: number; nextRetryTime?: number; } /** * Email bounce patterns to identify bounce types in SMTP responses and bounce messages */ const BOUNCE_PATTERNS = { // Hard bounce patterns [BounceType.INVALID_RECIPIENT]: [ /no such user/i, /user unknown/i, /does not exist/i, /invalid recipient/i, /unknown recipient/i, /no mailbox/i, /user not found/i, /recipient address rejected/i, /550 5\.1\.1/i ], [BounceType.DOMAIN_NOT_FOUND]: [ /domain not found/i, /unknown domain/i, /no such domain/i, /host not found/i, /domain invalid/i, /550 5\.1\.2/i ], [BounceType.MAILBOX_FULL]: [ /mailbox full/i, /over quota/i, /quota exceeded/i, /552 5\.2\.2/i ], [BounceType.MAILBOX_INACTIVE]: [ /mailbox disabled/i, /mailbox inactive/i, /account disabled/i, /mailbox not active/i, /account suspended/i ], [BounceType.BLOCKED]: [ /blocked/i, /rejected/i, /denied/i, /blacklisted/i, /prohibited/i, /refused/i, /550 5\.7\./i ], [BounceType.SPAM_RELATED]: [ /spam/i, /bulk mail/i, /content rejected/i, /message rejected/i, /550 5\.7\.1/i ], // Soft bounce patterns [BounceType.SERVER_UNAVAILABLE]: [ /server unavailable/i, /service unavailable/i, /try again later/i, /try later/i, /451 4\.3\./i, /421 4\.3\./i ], [BounceType.TEMPORARY_FAILURE]: [ /temporary failure/i, /temporary error/i, /temporary problem/i, /try again/i, /451 4\./i ], [BounceType.QUOTA_EXCEEDED]: [ /quota temporarily exceeded/i, /mailbox temporarily full/i, /452 4\.2\.2/i ], [BounceType.NETWORK_ERROR]: [ /network error/i, /connection error/i, /connection timed out/i, /routing error/i, /421 4\.4\./i ], [BounceType.TIMEOUT]: [ /timed out/i, /timeout/i, /450 4\.4\.2/i ], // Auto-responses [BounceType.AUTO_RESPONSE]: [ /auto[- ]reply/i, /auto[- ]response/i, /vacation/i, /out of office/i, /away from office/i, /on vacation/i, /automatic reply/i ], [BounceType.CHALLENGE_RESPONSE]: [ /challenge[- ]response/i, /verify your email/i, /confirm your email/i, /email verification/i ] }; /** * Retry strategy configuration for soft bounces */ interface RetryStrategy { maxRetries: number; initialDelay: number; // milliseconds maxDelay: number; // milliseconds backoffFactor: number; } /** * Manager for handling email bounces */ export class BounceManager { // Retry strategy with exponential backoff private retryStrategy: RetryStrategy = { maxRetries: 5, initialDelay: 15 * 60 * 1000, // 15 minutes maxDelay: 24 * 60 * 60 * 1000, // 24 hours backoffFactor: 2 }; // Store of bounced emails private bounceStore: BounceRecord[] = []; // Cache of recently bounced email addresses to avoid sending to known bad addresses private bounceCache: LRUCache; // Suppression list for addresses that should not receive emails private suppressionList: Map = new Map(); constructor(options?: { retryStrategy?: Partial; maxCacheSize?: number; cacheTTL?: number; }) { // Set retry strategy with defaults if (options?.retryStrategy) { this.retryStrategy = { ...this.retryStrategy, ...options.retryStrategy }; } // Initialize bounce cache with LRU (least recently used) caching this.bounceCache = new LRUCache({ max: options?.maxCacheSize || 10000, ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default }); // Load suppression list from storage this.loadSuppressionList(); } /** * Process a bounce notification * @param bounceData Bounce data to process * @returns Processed bounce record */ public async processBounce(bounceData: Partial): Promise { try { // Add required fields if missing const bounce: BounceRecord = { id: bounceData.id || plugins.uuid.v4(), recipient: bounceData.recipient, sender: bounceData.sender, domain: bounceData.domain || bounceData.recipient.split('@')[1], subject: bounceData.subject, bounceType: bounceData.bounceType || BounceType.UNKNOWN, bounceCategory: bounceData.bounceCategory || BounceCategory.UNKNOWN, timestamp: bounceData.timestamp || Date.now(), smtpResponse: bounceData.smtpResponse, diagnosticCode: bounceData.diagnosticCode, statusCode: bounceData.statusCode, headers: bounceData.headers, processed: false, originalEmailId: bounceData.originalEmailId, retryCount: bounceData.retryCount || 0, nextRetryTime: bounceData.nextRetryTime }; // Determine bounce type and category if not provided if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) { const bounceInfo = this.detectBounceType( bounce.smtpResponse || '', bounce.diagnosticCode || '', bounce.statusCode || '' ); bounce.bounceType = bounceInfo.type; bounce.bounceCategory = bounceInfo.category; } // Process the bounce based on category switch (bounce.bounceCategory) { case BounceCategory.HARD: // Handle hard bounce - add to suppression list await this.handleHardBounce(bounce); break; case BounceCategory.SOFT: // Handle soft bounce - schedule retry if eligible await this.handleSoftBounce(bounce); break; case BounceCategory.AUTO_RESPONSE: // Handle auto-response - typically no action needed logger.log('info', `Auto-response detected for ${bounce.recipient}`); break; default: // Unknown bounce type - log for investigation logger.log('warn', `Unknown bounce type for ${bounce.recipient}`, { bounceType: bounce.bounceType, smtpResponse: bounce.smtpResponse }); break; } // Store the bounce record bounce.processed = true; this.bounceStore.push(bounce); // Update the bounce cache this.updateBounceCache(bounce); // Log the bounce logger.log( bounce.bounceCategory === BounceCategory.HARD ? 'warn' : 'info', `Email bounce processed: ${bounce.bounceCategory} bounce for ${bounce.recipient}`, { bounceType: bounce.bounceType, domain: bounce.domain, category: bounce.bounceCategory } ); // Enhanced security logging SecurityLogger.getInstance().logEvent({ level: bounce.bounceCategory === BounceCategory.HARD ? SecurityLogLevel.WARN : SecurityLogLevel.INFO, type: SecurityEventType.EMAIL_VALIDATION, message: `Email bounce detected: ${bounce.bounceCategory} bounce for recipient`, domain: bounce.domain, details: { recipient: bounce.recipient, bounceType: bounce.bounceType, smtpResponse: bounce.smtpResponse, diagnosticCode: bounce.diagnosticCode, statusCode: bounce.statusCode }, success: false }); return bounce; } catch (error) { logger.log('error', `Error processing bounce: ${error.message}`, { error: error.message, bounceData }); throw error; } } /** * Process an SMTP failure as a bounce * @param recipient Recipient email * @param smtpResponse SMTP error response * @param options Additional options * @returns Processed bounce record */ public async processSmtpFailure( recipient: string, smtpResponse: string, options: { sender?: string; originalEmailId?: string; statusCode?: string; headers?: Record; } = {} ): Promise { // Create bounce data from SMTP failure const bounceData: Partial = { recipient, sender: options.sender || '', domain: recipient.split('@')[1], smtpResponse, statusCode: options.statusCode, headers: options.headers, originalEmailId: options.originalEmailId, timestamp: Date.now() }; // Process as a regular bounce return this.processBounce(bounceData); } /** * Process a bounce notification email * @param bounceEmail The email containing bounce information * @returns Processed bounce record or null if not a bounce */ public async processBounceEmail(bounceEmail: plugins.smartmail.Smartmail): Promise { try { // Check if this is a bounce notification const subject = bounceEmail.getSubject(); const body = bounceEmail.getBody(); // Check for common bounce notification subject patterns const isBounceSubject = /mail delivery|delivery (failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem/i.test(subject); if (!isBounceSubject) { // Not a bounce notification based on subject return null; } // Extract original recipient from the body or headers let recipient = ''; let originalMessageId = ''; // Extract recipient from common bounce formats const recipientMatch = body.match(/(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*?/i); if (recipientMatch && recipientMatch[1]) { recipient = recipientMatch[1]; } // Extract diagnostic code let diagnosticCode = ''; const diagnosticMatch = body.match(/diagnostic(?:-|\\s+)code:\s*(.+)(?:\n|$)/i); if (diagnosticMatch && diagnosticMatch[1]) { diagnosticCode = diagnosticMatch[1].trim(); } // Extract SMTP status code let statusCode = ''; const statusMatch = body.match(/status(?:-|\\s+)code:\s*([0-9.]+)/i); if (statusMatch && statusMatch[1]) { statusCode = statusMatch[1].trim(); } // If recipient not found in standard patterns, try DSN (Delivery Status Notification) format if (!recipient) { // Look for DSN format with Original-Recipient or Final-Recipient fields const originalRecipientMatch = body.match(/original-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); const finalRecipientMatch = body.match(/final-recipient:.*?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i); if (originalRecipientMatch && originalRecipientMatch[1]) { recipient = originalRecipientMatch[1]; } else if (finalRecipientMatch && finalRecipientMatch[1]) { recipient = finalRecipientMatch[1]; } } // If still no recipient, can't process as bounce if (!recipient) { logger.log('warn', 'Could not extract recipient from bounce notification', { subject, sender: bounceEmail.options.from }); return null; } // Extract original message ID if available const messageIdMatch = body.match(/original[ -]message[ -]id:[ \t]*]+)>?/i); if (messageIdMatch && messageIdMatch[1]) { originalMessageId = messageIdMatch[1].trim(); } // Create bounce data const bounceData: Partial = { recipient, sender: bounceEmail.options.from, domain: recipient.split('@')[1], subject: bounceEmail.getSubject(), diagnosticCode, statusCode, timestamp: Date.now(), headers: {} }; // Process as a regular bounce return this.processBounce(bounceData); } catch (error) { logger.log('error', `Error processing bounce email: ${error.message}`); return null; } } /** * Handle a hard bounce by adding to suppression list * @param bounce The bounce record */ private async handleHardBounce(bounce: BounceRecord): Promise { // Add to suppression list permanently (no expiry) this.addToSuppressionList(bounce.recipient, `Hard bounce: ${bounce.bounceType}`, undefined); // Increment bounce count in cache this.updateBounceCache(bounce); // Save to permanent storage this.saveBounceRecord(bounce); // Log hard bounce for monitoring logger.log('warn', `Hard bounce for ${bounce.recipient}: ${bounce.bounceType}`, { domain: bounce.domain, smtpResponse: bounce.smtpResponse, diagnosticCode: bounce.diagnosticCode }); } /** * Handle a soft bounce by scheduling a retry if eligible * @param bounce The bounce record */ private async handleSoftBounce(bounce: BounceRecord): Promise { // Check if we've exceeded max retries if (bounce.retryCount >= this.retryStrategy.maxRetries) { logger.log('warn', `Max retries exceeded for ${bounce.recipient}, treating as hard bounce`); // Convert to hard bounce after max retries bounce.bounceCategory = BounceCategory.HARD; await this.handleHardBounce(bounce); return; } // Calculate next retry time with exponential backoff const delay = Math.min( this.retryStrategy.initialDelay * Math.pow(this.retryStrategy.backoffFactor, bounce.retryCount), this.retryStrategy.maxDelay ); bounce.retryCount++; bounce.nextRetryTime = Date.now() + delay; // Add to suppression list temporarily (with expiry) this.addToSuppressionList( bounce.recipient, `Soft bounce: ${bounce.bounceType}`, bounce.nextRetryTime ); // Log the retry schedule logger.log('info', `Scheduled retry ${bounce.retryCount} for ${bounce.recipient} at ${new Date(bounce.nextRetryTime).toISOString()}`, { bounceType: bounce.bounceType, retryCount: bounce.retryCount, nextRetry: bounce.nextRetryTime }); } /** * Add an email address to the suppression list * @param email Email address to suppress * @param reason Reason for suppression * @param expiresAt Expiration timestamp (undefined for permanent) */ public addToSuppressionList( email: string, reason: string, expiresAt?: number ): void { this.suppressionList.set(email.toLowerCase(), { reason, timestamp: Date.now(), expiresAt }); this.saveSuppressionList(); logger.log('info', `Added ${email} to suppression list`, { reason, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : 'permanent' }); } /** * Remove an email address from the suppression list * @param email Email address to remove */ public removeFromSuppressionList(email: string): void { const wasRemoved = this.suppressionList.delete(email.toLowerCase()); if (wasRemoved) { this.saveSuppressionList(); logger.log('info', `Removed ${email} from suppression list`); } } /** * Check if an email is on the suppression list * @param email Email address to check * @returns Whether the email is suppressed */ public isEmailSuppressed(email: string): boolean { const lowercaseEmail = email.toLowerCase(); const suppression = this.suppressionList.get(lowercaseEmail); if (!suppression) { return false; } // Check if suppression has expired if (suppression.expiresAt && Date.now() > suppression.expiresAt) { this.suppressionList.delete(lowercaseEmail); this.saveSuppressionList(); return false; } return true; } /** * Get suppression information for an email * @param email Email address to check * @returns Suppression information or null if not suppressed */ public getSuppressionInfo(email: string): { reason: string; timestamp: number; expiresAt?: number; } | null { const lowercaseEmail = email.toLowerCase(); const suppression = this.suppressionList.get(lowercaseEmail); if (!suppression) { return null; } // Check if suppression has expired if (suppression.expiresAt && Date.now() > suppression.expiresAt) { this.suppressionList.delete(lowercaseEmail); this.saveSuppressionList(); return null; } return suppression; } /** * Save suppression list to disk */ private saveSuppressionList(): void { try { const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries())); plugins.smartfile.memory.toFsSync( suppressionData, plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json') ); } catch (error) { logger.log('error', `Failed to save suppression list: ${error.message}`); } } /** * Load suppression list from disk */ private loadSuppressionList(): void { try { const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.json'); if (plugins.fs.existsSync(suppressionPath)) { const data = plugins.fs.readFileSync(suppressionPath, 'utf8'); const entries = JSON.parse(data); this.suppressionList = new Map(entries); // Clean expired entries const now = Date.now(); let expiredCount = 0; for (const [email, info] of this.suppressionList.entries()) { if (info.expiresAt && now > info.expiresAt) { this.suppressionList.delete(email); expiredCount++; } } if (expiredCount > 0) { logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`); this.saveSuppressionList(); } logger.log('info', `Loaded ${this.suppressionList.size} entries from suppression list`); } } catch (error) { logger.log('error', `Failed to load suppression list: ${error.message}`); } } /** * Save bounce record to disk * @param bounce Bounce record to save */ private saveBounceRecord(bounce: BounceRecord): void { try { const bounceData = JSON.stringify(bounce); const bouncePath = plugins.path.join( paths.dataDir, 'emails', 'bounces', `${bounce.id}.json` ); // Ensure directory exists const bounceDir = plugins.path.join(paths.dataDir, 'emails', 'bounces'); plugins.smartfile.fs.ensureDirSync(bounceDir); plugins.smartfile.memory.toFsSync(bounceData, bouncePath); } catch (error) { logger.log('error', `Failed to save bounce record: ${error.message}`); } } /** * Update bounce cache with new bounce information * @param bounce Bounce record to update cache with */ private updateBounceCache(bounce: BounceRecord): void { const email = bounce.recipient.toLowerCase(); const existing = this.bounceCache.get(email); if (existing) { // Update existing cache entry existing.lastBounce = bounce.timestamp; existing.count++; existing.type = bounce.bounceType; existing.category = bounce.bounceCategory; } else { // Create new cache entry this.bounceCache.set(email, { lastBounce: bounce.timestamp, count: 1, type: bounce.bounceType, category: bounce.bounceCategory }); } } /** * Check bounce history for an email address * @param email Email address to check * @returns Bounce information or null if no bounces */ public getBounceInfo(email: string): { lastBounce: number; count: number; type: BounceType; category: BounceCategory; } | null { return this.bounceCache.get(email.toLowerCase()) || null; } /** * Analyze SMTP response and diagnostic codes to determine bounce type * @param smtpResponse SMTP response string * @param diagnosticCode Diagnostic code from bounce * @param statusCode Status code from bounce * @returns Detected bounce type and category */ private detectBounceType( smtpResponse: string, diagnosticCode: string, statusCode: string ): { type: BounceType; category: BounceCategory; } { // Combine all text for comprehensive pattern matching const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase(); // Check for auto-responses first if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) || this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) { return { type: BounceType.AUTO_RESPONSE, category: BounceCategory.AUTO_RESPONSE }; } // Check for hard bounces for (const bounceType of [ BounceType.INVALID_RECIPIENT, BounceType.DOMAIN_NOT_FOUND, BounceType.MAILBOX_FULL, BounceType.MAILBOX_INACTIVE, BounceType.BLOCKED, BounceType.SPAM_RELATED, BounceType.POLICY_RELATED ]) { if (this.matchesPattern(fullText, bounceType)) { return { type: bounceType, category: BounceCategory.HARD }; } } // Check for soft bounces for (const bounceType of [ BounceType.SERVER_UNAVAILABLE, BounceType.TEMPORARY_FAILURE, BounceType.QUOTA_EXCEEDED, BounceType.NETWORK_ERROR, BounceType.TIMEOUT ]) { if (this.matchesPattern(fullText, bounceType)) { return { type: bounceType, category: BounceCategory.SOFT }; } } // Handle DSN (Delivery Status Notification) status codes if (statusCode) { // Format: class.subject.detail const parts = statusCode.split('.'); if (parts.length >= 2) { const statusClass = parts[0]; const statusSubject = parts[1]; // 5.X.X is permanent failure (hard bounce) if (statusClass === '5') { // Try to determine specific type based on subject if (statusSubject === '1') { return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD }; } else if (statusSubject === '2') { return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD }; } else if (statusSubject === '7') { return { type: BounceType.BLOCKED, category: BounceCategory.HARD }; } else { return { type: BounceType.UNKNOWN, category: BounceCategory.HARD }; } } // 4.X.X is temporary failure (soft bounce) if (statusClass === '4') { // Try to determine specific type based on subject if (statusSubject === '2') { return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT }; } else if (statusSubject === '3') { return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT }; } else if (statusSubject === '4') { return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT }; } else { return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT }; } } } } // Default to unknown return { type: BounceType.UNKNOWN, category: BounceCategory.UNKNOWN }; } /** * Check if text matches any pattern for a bounce type * @param text Text to check against patterns * @param bounceType Bounce type to get patterns for * @returns Whether the text matches any pattern */ private matchesPattern(text: string, bounceType: BounceType): boolean { const patterns = BOUNCE_PATTERNS[bounceType]; if (!patterns) { return false; } for (const pattern of patterns) { if (pattern.test(text)) { return true; } } return false; } /** * Get all known hard bounced addresses * @returns Array of hard bounced email addresses */ public getHardBouncedAddresses(): string[] { const hardBounced: string[] = []; for (const [email, info] of this.bounceCache.entries()) { if (info.category === BounceCategory.HARD) { hardBounced.push(email); } } return hardBounced; } /** * Get suppression list * @returns Array of suppressed email addresses */ public getSuppressionList(): string[] { return Array.from(this.suppressionList.keys()); } /** * Clear old bounce records (for maintenance) * @param olderThan Timestamp to remove records older than * @returns Number of records removed */ public clearOldBounceRecords(olderThan: number): number { let removed = 0; this.bounceStore = this.bounceStore.filter(bounce => { if (bounce.timestamp < olderThan) { removed++; return false; } return true; }); return removed; } }