965 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			965 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import * as plugins from '../../plugins.ts'; | ||
|  | import * as paths from '../../paths.ts'; | ||
|  | import { logger } from '../../logger.ts'; | ||
|  | import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.ts'; | ||
|  | import { LRUCache } from 'lru-cache'; | ||
|  | import type { Email } from './classes.email.ts'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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<string, string>; | ||
|  |   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<string, { | ||
|  |     lastBounce: number; | ||
|  |     count: number; | ||
|  |     type: BounceType; | ||
|  |     category: BounceCategory; | ||
|  |   }>; | ||
|  |    | ||
|  |   // Suppression list for addresses that should not receive emails
 | ||
|  |   private suppressionList: Map<string, { | ||
|  |     reason: string; | ||
|  |     timestamp: number; | ||
|  |     expiresAt?: number; // undefined means permanent
 | ||
|  |   }> = new Map(); | ||
|  |    | ||
|  |   private storageManager?: any; // StorageManager instance
 | ||
|  |    | ||
|  |   constructor(options?: { | ||
|  |     retryStrategy?: Partial<RetryStrategy>; | ||
|  |     maxCacheSize?: number; | ||
|  |     cacheTTL?: number; | ||
|  |     storageManager?: any; | ||
|  |   }) { | ||
|  |     // 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<string, any>({ | ||
|  |       max: options?.maxCacheSize || 10000, | ||
|  |       ttl: options?.cacheTTL || 30 * 24 * 60 * 60 * 1000, // 30 days default
 | ||
|  |     }); | ||
|  |      | ||
|  |     // Store storage manager reference
 | ||
|  |     this.storageManager = options?.storageManager; | ||
|  |      | ||
|  |     // Load suppression list from storage
 | ||
|  |     // Note: This is async but we can't await in constructor
 | ||
|  |     // The suppression list will be loaded asynchronously
 | ||
|  |     this.loadSuppressionList().catch(error => { | ||
|  |       logger.log('error', `Failed to load suppression list on startup: ${error.message}`); | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Process a bounce notification | ||
|  |    * @param bounceData Bounce data to process | ||
|  |    * @returns Processed bounce record | ||
|  |    */ | ||
|  |   public async processBounce(bounceData: Partial<BounceRecord>): Promise<BounceRecord> { | ||
|  |     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<string, string>; | ||
|  |     } = {} | ||
|  |   ): Promise<BounceRecord> { | ||
|  |     // Create bounce data from SMTP failure
 | ||
|  |     const bounceData: Partial<BounceRecord> = { | ||
|  |       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: Email): Promise<BounceRecord | null> { | ||
|  |     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*<?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>?/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.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<BounceRecord> = { | ||
|  |         recipient, | ||
|  |         sender: bounceEmail.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<void> { | ||
|  |     // 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
 | ||
|  |     await 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<void> { | ||
|  |     // 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 | ||
|  |     }); | ||
|  |      | ||
|  |     // Save asynchronously without blocking
 | ||
|  |     this.saveSuppressionList().catch(error => { | ||
|  |       logger.log('error', `Failed to save suppression list after adding ${email}: ${error.message}`); | ||
|  |     }); | ||
|  |      | ||
|  |     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) { | ||
|  |       // Save asynchronously without blocking
 | ||
|  |       this.saveSuppressionList().catch(error => { | ||
|  |         logger.log('error', `Failed to save suppression list after removing ${email}: ${error.message}`); | ||
|  |       }); | ||
|  |       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); | ||
|  |       // Save asynchronously without blocking
 | ||
|  |       this.saveSuppressionList().catch(error => { | ||
|  |         logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`); | ||
|  |       }); | ||
|  |       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); | ||
|  |       // Save asynchronously without blocking
 | ||
|  |       this.saveSuppressionList().catch(error => { | ||
|  |         logger.log('error', `Failed to save suppression list after expiry cleanup: ${error.message}`); | ||
|  |       }); | ||
|  |       return null; | ||
|  |     } | ||
|  |      | ||
|  |     return suppression; | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Save suppression list to disk | ||
|  |    */ | ||
|  |   private async saveSuppressionList(): Promise<void> { | ||
|  |     try { | ||
|  |       const suppressionData = JSON.stringify(Array.from(this.suppressionList.entries())); | ||
|  |        | ||
|  |       if (this.storageManager) { | ||
|  |         // Use storage manager
 | ||
|  |         await this.storageManager.set('/email/bounces/suppression-list.tson', suppressionData); | ||
|  |       } else { | ||
|  |         // Fall back to filesystem
 | ||
|  |         plugins.smartfile.memory.toFsSync( | ||
|  |           suppressionData, | ||
|  |           plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson') | ||
|  |         ); | ||
|  |       } | ||
|  |     } catch (error) { | ||
|  |       logger.log('error', `Failed to save suppression list: ${error.message}`); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   /** | ||
|  |    * Load suppression list from disk | ||
|  |    */ | ||
|  |   private async loadSuppressionList(): Promise<void> { | ||
|  |     try { | ||
|  |       let entries = null; | ||
|  |       let needsMigration = false; | ||
|  |        | ||
|  |       if (this.storageManager) { | ||
|  |         // Try to load from storage manager first
 | ||
|  |         const suppressionData = await this.storageManager.get('/email/bounces/suppression-list.tson'); | ||
|  |          | ||
|  |         if (suppressionData) { | ||
|  |           entries = JSON.parse(suppressionData); | ||
|  |         } else { | ||
|  |           // Check if data exists in filesystem and migrate
 | ||
|  |           const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson'); | ||
|  |            | ||
|  |           if (plugins.fs.existsSync(suppressionPath)) { | ||
|  |             const data = plugins.fs.readFileSync(suppressionPath, 'utf8'); | ||
|  |             entries = JSON.parse(data); | ||
|  |             needsMigration = true; | ||
|  |              | ||
|  |             logger.log('info', 'Migrating suppression list from filesystem to StorageManager'); | ||
|  |           } | ||
|  |         } | ||
|  |       } else { | ||
|  |         // No storage manager, use filesystem directly
 | ||
|  |         const suppressionPath = plugins.path.join(paths.dataDir, 'emails', 'suppression_list.tson'); | ||
|  |          | ||
|  |         if (plugins.fs.existsSync(suppressionPath)) { | ||
|  |           const data = plugins.fs.readFileSync(suppressionPath, 'utf8'); | ||
|  |           entries = JSON.parse(data); | ||
|  |         } | ||
|  |       } | ||
|  |        | ||
|  |       if (entries) { | ||
|  |         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 || needsMigration) { | ||
|  |           logger.log('info', `Cleaned ${expiredCount} expired entries from suppression list`); | ||
|  |           await 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 async saveBounceRecord(bounce: BounceRecord): Promise<void> { | ||
|  |     try { | ||
|  |       const bounceData = JSON.stringify(bounce, null, 2); | ||
|  |        | ||
|  |       if (this.storageManager) { | ||
|  |         // Use storage manager
 | ||
|  |         await this.storageManager.set(`/email/bounces/records/${bounce.id}.tson`, bounceData); | ||
|  |       } else { | ||
|  |         // Fall back to filesystem
 | ||
|  |         const bouncePath = plugins.path.join( | ||
|  |           paths.dataDir, | ||
|  |           'emails', | ||
|  |           'bounces', | ||
|  |           `${bounce.id}.tson` | ||
|  |         ); | ||
|  |          | ||
|  |         // 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; | ||
|  |   } | ||
|  | } |