use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::LazyLock; /// Type of email bounce. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BounceType { // Hard bounces InvalidRecipient, DomainNotFound, MailboxFull, MailboxInactive, Blocked, SpamRelated, PolicyRelated, // Soft bounces ServerUnavailable, TemporaryFailure, QuotaExceeded, NetworkError, Timeout, // Special AutoResponse, ChallengeResponse, Unknown, } /// Broad category of a bounce. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BounceCategory { Hard, Soft, AutoResponse, Unknown, } impl BounceType { /// Get the category for this bounce type. pub fn category(&self) -> BounceCategory { match self { BounceType::InvalidRecipient | BounceType::DomainNotFound | BounceType::MailboxFull | BounceType::MailboxInactive | BounceType::Blocked | BounceType::SpamRelated | BounceType::PolicyRelated => BounceCategory::Hard, BounceType::ServerUnavailable | BounceType::TemporaryFailure | BounceType::QuotaExceeded | BounceType::NetworkError | BounceType::Timeout => BounceCategory::Soft, BounceType::AutoResponse | BounceType::ChallengeResponse => { BounceCategory::AutoResponse } BounceType::Unknown => BounceCategory::Unknown, } } } /// Result of bounce detection. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BounceDetection { pub bounce_type: BounceType, pub category: BounceCategory, } /// Pattern set for a bounce type: compiled regexes for matching against SMTP responses. struct BouncePatterns { bounce_type: BounceType, patterns: Vec, } /// All bounce detection patterns, compiled once. static BOUNCE_PATTERNS: LazyLock> = LazyLock::new(|| { vec![ BouncePatterns { bounce_type: BounceType::InvalidRecipient, patterns: compile_patterns(&[ r"(?i)no such user", r"(?i)user unknown", r"(?i)does not exist", r"(?i)invalid recipient", r"(?i)unknown recipient", r"(?i)no mailbox", r"(?i)user not found", r"(?i)recipient address rejected", r"(?i)550 5\.1\.1", ]), }, BouncePatterns { bounce_type: BounceType::DomainNotFound, patterns: compile_patterns(&[ r"(?i)domain not found", r"(?i)unknown domain", r"(?i)no such domain", r"(?i)host not found", r"(?i)domain invalid", r"(?i)550 5\.1\.2", ]), }, BouncePatterns { bounce_type: BounceType::MailboxFull, patterns: compile_patterns(&[ r"(?i)mailbox full", r"(?i)over quota", r"(?i)quota exceeded", r"(?i)552 5\.2\.2", ]), }, BouncePatterns { bounce_type: BounceType::MailboxInactive, patterns: compile_patterns(&[ r"(?i)mailbox disabled", r"(?i)mailbox inactive", r"(?i)account disabled", r"(?i)mailbox not active", r"(?i)account suspended", ]), }, BouncePatterns { bounce_type: BounceType::Blocked, patterns: compile_patterns(&[ r"(?i)blocked", r"(?i)rejected", r"(?i)denied", r"(?i)blacklisted", r"(?i)prohibited", r"(?i)refused", r"(?i)550 5\.7\.", ]), }, BouncePatterns { bounce_type: BounceType::SpamRelated, patterns: compile_patterns(&[ r"(?i)spam", r"(?i)bulk mail", r"(?i)content rejected", r"(?i)message rejected", r"(?i)550 5\.7\.1", ]), }, BouncePatterns { bounce_type: BounceType::ServerUnavailable, patterns: compile_patterns(&[ r"(?i)server unavailable", r"(?i)service unavailable", r"(?i)try again later", r"(?i)try later", r"(?i)451 4\.3\.", r"(?i)421 4\.3\.", ]), }, BouncePatterns { bounce_type: BounceType::TemporaryFailure, patterns: compile_patterns(&[ r"(?i)temporary failure", r"(?i)temporary error", r"(?i)temporary problem", r"(?i)try again", r"(?i)451 4\.", ]), }, BouncePatterns { bounce_type: BounceType::QuotaExceeded, patterns: compile_patterns(&[ r"(?i)quota temporarily exceeded", r"(?i)mailbox temporarily full", r"(?i)452 4\.2\.2", ]), }, BouncePatterns { bounce_type: BounceType::NetworkError, patterns: compile_patterns(&[ r"(?i)network error", r"(?i)connection error", r"(?i)connection timed out", r"(?i)routing error", r"(?i)421 4\.4\.", ]), }, BouncePatterns { bounce_type: BounceType::Timeout, patterns: compile_patterns(&[ r"(?i)timed out", r"(?i)timeout", r"(?i)450 4\.4\.2", ]), }, BouncePatterns { bounce_type: BounceType::AutoResponse, patterns: compile_patterns(&[ r"(?i)auto[- ]reply", r"(?i)auto[- ]response", r"(?i)vacation", r"(?i)out of office", r"(?i)away from office", r"(?i)on vacation", r"(?i)automatic reply", ]), }, BouncePatterns { bounce_type: BounceType::ChallengeResponse, patterns: compile_patterns(&[ r"(?i)challenge[- ]response", r"(?i)verify your email", r"(?i)confirm your email", r"(?i)email verification", ]), }, ] }); /// Regex for detecting bounce email subjects. static BOUNCE_SUBJECT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)mail delivery|delivery (?:failed|status|notification)|failure notice|returned mail|undeliverable|delivery problem") .expect("invalid bounce subject regex") }); /// Regex for extracting recipient from bounce messages. static BOUNCE_RECIPIENT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*?") .expect("invalid bounce recipient regex") }); /// Regex for extracting diagnostic code. static DIAGNOSTIC_CODE_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)diagnostic(?:-|\s+)code:\s*(.+)") .expect("invalid diagnostic code regex") }); /// Regex for extracting status code. static STATUS_CODE_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)status(?:-|\s+)code:\s*([0-9.]+)") .expect("invalid status code regex") }); /// Regex for DSN original-recipient. static DSN_ORIGINAL_RECIPIENT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)original-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})") .expect("invalid DSN original-recipient regex") }); /// Regex for DSN final-recipient. static DSN_FINAL_RECIPIENT_RE: LazyLock = LazyLock::new(|| { Regex::new(r"(?i)final-recipient:.*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})") .expect("invalid DSN final-recipient regex") }); fn compile_patterns(patterns: &[&str]) -> Vec { patterns .iter() .map(|p| Regex::new(p).expect("invalid bounce pattern regex")) .collect() } /// Detect bounce type from an SMTP response, diagnostic code, or status code. pub fn detect_bounce_type( smtp_response: Option<&str>, diagnostic_code: Option<&str>, status_code: Option<&str>, ) -> BounceDetection { // Check all text sources against patterns let texts: Vec<&str> = [smtp_response, diagnostic_code, status_code] .into_iter() .flatten() .collect(); for bp in BOUNCE_PATTERNS.iter() { for text in &texts { for pattern in &bp.patterns { if pattern.is_match(text) { return BounceDetection { bounce_type: bp.bounce_type, category: bp.bounce_type.category(), }; } } } } // Fallback: parse DSN status code (class.subject.detail) if let Some(code) = status_code { if let Some(detection) = parse_dsn_status(code) { return detection; } } // Try to find DSN code in SMTP response if let Some(resp) = smtp_response { if let Some(code) = STATUS_CODE_RE.captures(resp).and_then(|c| c.get(1)) { if let Some(detection) = parse_dsn_status(code.as_str()) { return detection; } } } BounceDetection { bounce_type: BounceType::Unknown, category: BounceCategory::Unknown, } } /// Parse a DSN enhanced status code like "5.1.1" or "4.2.2". fn parse_dsn_status(code: &str) -> Option { let parts: Vec<&str> = code.split('.').collect(); if parts.len() < 2 { return None; } let class: u8 = parts[0].parse().ok()?; let subject: u8 = parts[1].parse().ok()?; let bounce_type = match (class, subject) { (5, 1) => BounceType::InvalidRecipient, (5, 2) => BounceType::MailboxFull, (5, 7) => BounceType::Blocked, (5, _) => BounceType::PolicyRelated, (4, 2) => BounceType::QuotaExceeded, (4, 3) => BounceType::ServerUnavailable, (4, 4) => BounceType::NetworkError, (4, _) => BounceType::TemporaryFailure, _ => return None, }; Some(BounceDetection { category: bounce_type.category(), bounce_type, }) } /// Check if a subject line looks like a bounce notification. pub fn is_bounce_subject(subject: &str) -> bool { BOUNCE_SUBJECT_RE.is_match(subject) } /// Extract the bounced recipient email from a bounce message body. pub fn extract_bounce_recipient(body: &str) -> Option { BOUNCE_RECIPIENT_RE .captures(body) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) .or_else(|| { DSN_FINAL_RECIPIENT_RE .captures(body) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) }) .or_else(|| { DSN_ORIGINAL_RECIPIENT_RE .captures(body) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) }) } /// Extract the diagnostic code from a bounce message body. pub fn extract_diagnostic_code(body: &str) -> Option { DIAGNOSTIC_CODE_RE .captures(body) .and_then(|c| c.get(1)) .map(|m| m.as_str().trim().to_string()) } /// Extract the status code from a bounce message body. pub fn extract_status_code(body: &str) -> Option { STATUS_CODE_RE .captures(body) .and_then(|c| c.get(1)) .map(|m| m.as_str().trim().to_string()) } /// Calculate retry delay using exponential backoff. /// /// * `retry_count` - Number of retries so far (0-based) /// * `initial_delay_ms` - Initial delay in milliseconds (default 15 min = 900_000) /// * `max_delay_ms` - Maximum delay in milliseconds (default 24h = 86_400_000) /// * `backoff_factor` - Multiplier per retry (default 2.0) pub fn retry_delay_ms( retry_count: u32, initial_delay_ms: u64, max_delay_ms: u64, backoff_factor: f64, ) -> u64 { let delay = (initial_delay_ms as f64) * backoff_factor.powi(retry_count as i32); (delay as u64).min(max_delay_ms) } /// Default retry delay with standard parameters. pub fn default_retry_delay_ms(retry_count: u32) -> u64 { retry_delay_ms( retry_count, 15 * 60 * 1000, // 15 minutes 24 * 60 * 60 * 1000, // 24 hours 2.0, ) } #[cfg(test)] mod tests { use super::*; #[test] fn test_detect_invalid_recipient() { let result = detect_bounce_type(Some("550 5.1.1 User unknown"), None, None); assert_eq!(result.bounce_type, BounceType::InvalidRecipient); assert_eq!(result.category, BounceCategory::Hard); } #[test] fn test_detect_mailbox_full() { let result = detect_bounce_type(Some("552 5.2.2 Mailbox full"), None, None); assert_eq!(result.bounce_type, BounceType::MailboxFull); assert_eq!(result.category, BounceCategory::Hard); } #[test] fn test_detect_temporary_failure() { let result = detect_bounce_type(Some("451 4.3.0 Try again later"), None, None); assert_eq!(result.bounce_type, BounceType::ServerUnavailable); assert_eq!(result.category, BounceCategory::Soft); } #[test] fn test_detect_auto_response() { let result = detect_bounce_type(Some("Auto-reply: Out of office"), None, None); assert_eq!(result.bounce_type, BounceType::AutoResponse); assert_eq!(result.category, BounceCategory::AutoResponse); } #[test] fn test_detect_from_dsn_status() { let result = detect_bounce_type(None, None, Some("5.1.1")); assert_eq!(result.bounce_type, BounceType::InvalidRecipient); let result = detect_bounce_type(None, None, Some("4.4.1")); assert_eq!(result.bounce_type, BounceType::NetworkError); } #[test] fn test_detect_unknown() { let result = detect_bounce_type(Some("Something weird happened"), None, None); assert_eq!(result.bounce_type, BounceType::Unknown); } #[test] fn test_is_bounce_subject() { assert!(is_bounce_subject("Mail Delivery Failure")); assert!(is_bounce_subject("Delivery Status Notification")); assert!(is_bounce_subject("Returned mail: see transcript for details")); assert!(is_bounce_subject("Undeliverable: Your message")); assert!(!is_bounce_subject("Hello World")); assert!(!is_bounce_subject("Meeting tomorrow")); } #[test] fn test_extract_bounce_recipient() { let body = "Delivery to the following recipient failed:\n recipient: user@example.com"; assert_eq!( extract_bounce_recipient(body), Some("user@example.com".to_string()) ); let body = "Final-Recipient: rfc822;bounce@test.org"; assert_eq!( extract_bounce_recipient(body), Some("bounce@test.org".to_string()) ); } #[test] fn test_retry_delay() { assert_eq!(default_retry_delay_ms(0), 900_000); // 15 min assert_eq!(default_retry_delay_ms(1), 1_800_000); // 30 min assert_eq!(default_retry_delay_ms(2), 3_600_000); // 1 hour // Capped at 24h assert_eq!(default_retry_delay_ms(20), 86_400_000); } }