486 lines
15 KiB
Rust
486 lines
15 KiB
Rust
|
|
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<Regex>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// All bounce detection patterns, compiled once.
|
||
|
|
static BOUNCE_PATTERNS: LazyLock<Vec<BouncePatterns>> = 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<Regex> = 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<Regex> = LazyLock::new(|| {
|
||
|
|
Regex::new(r"(?i)(?:failed recipient|to[:=]\s*|recipient:|delivery failed:)\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?")
|
||
|
|
.expect("invalid bounce recipient regex")
|
||
|
|
});
|
||
|
|
|
||
|
|
/// Regex for extracting diagnostic code.
|
||
|
|
static DIAGNOSTIC_CODE_RE: LazyLock<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> {
|
||
|
|
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<BounceDetection> {
|
||
|
|
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<String> {
|
||
|
|
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<String> {
|
||
|
|
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<String> {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|