imap_bounce_service.rs
498 lignes · 18912 octets
//! IMAP Bounce Processing Service //! //! This service connects to an IMAP server to retrieve and process //! Delivery Status Notifications (DSN) / bounce messages. //! //! It parses DSN messages according to RFC 3464 and updates the //! EmailDeliveryStatus table accordingly. use crate::config::email::EmailConfig; use crate::error::AppError; use crate::models::email_delivery_status; use chrono::Utc; use native_tls::{Certificate, TlsConnector}; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use std::net::TcpStream; use std::sync::Arc; use tokio::time::Duration; /// IMAP Bounce Processing Service /// /// Connects to IMAP to retrieve and process bounce messages (DSN). pub struct ImapBounceService { config: EmailConfig, db: Arc<DatabaseConnection>, } impl ImapBounceService { /// Create a new ImapBounceService pub fn new(config: EmailConfig, db: Arc<DatabaseConnection>) -> Self { Self { config, db } } /// Check if IMAP bounce processing is enabled pub fn is_enabled(&self) -> bool { self.config.imap_host.is_some() } /// Start the IMAP bounce processor as a background task pub fn start(self) { if !self.is_enabled() { tracing::info!("IMAP bounce processing disabled (IMAP_HOST not set)"); return; } tokio::spawn(async move { let interval_secs = self.config.imap_poll_interval_seconds; let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); interval.tick().await; // Skip first immediate tick loop { interval.tick().await; match self.process_bounces().await { Ok(processed) => { if processed > 0 { tracing::info!( "IMAP bounce processor: processed {} bounce message(s)", processed ); } } Err(e) => { tracing::error!("IMAP bounce processor error: {}", e); } } } }); } /// Process bounce messages from IMAP async fn process_bounces(&self) -> Result<usize, AppError> { let host = self .config .imap_host .as_ref() .ok_or_else(|| AppError::Internal("IMAP host not configured".to_string()))?; let user = self .config .imap_user .as_ref() .ok_or_else(|| AppError::Internal("IMAP user not configured".to_string()))?; let password = self .config .imap_password .as_ref() .ok_or_else(|| AppError::Internal("IMAP password not configured".to_string()))?; // Connect to IMAP in a blocking task (imap crate is sync) let host_clone = host.clone(); let port = self.config.imap_port; let use_tls = self.config.imap_use_tls; let use_starttls = self.config.imap_use_starttls; let accept_invalid_certs = self.config.imap_accept_invalid_certs; let ca_cert_path = self.config.imap_ca_cert_path.clone(); let user_clone = user.clone(); let password_clone = password.clone(); let mailbox = self.config.imap_mailbox.clone(); let db = self.db.clone(); // Run IMAP operations in a blocking task let result = tokio::task::spawn_blocking(move || { Self::fetch_and_process_bounces( &host_clone, port, use_tls, use_starttls, accept_invalid_certs, ca_cert_path.as_deref(), &user_clone, &password_clone, &mailbox, ) }) .await .map_err(|e| AppError::Internal(format!("IMAP task error: {}", e)))?; let bounces = result?; // Update database with bounce information let mut processed = 0; for bounce in bounces { if let Err(e) = self.update_delivery_status(&db, &bounce).await { tracing::warn!("Failed to update delivery status for bounce: {}", e); } else { processed += 1; } } Ok(processed) } /// Connect to IMAP and fetch bounce messages (sync, runs in blocking task) #[allow(clippy::too_many_arguments)] fn fetch_and_process_bounces( host: &str, port: u16, use_tls: bool, use_starttls: bool, accept_invalid_certs: bool, ca_cert_path: Option<&str>, user: &str, password: &str, mailbox: &str, ) -> Result<Vec<BounceInfo>, AppError> { if use_tls { // Direct TLS connection (typically port 993) Self::fetch_bounces_tls( host, port, accept_invalid_certs, ca_cert_path, user, password, mailbox, ) } else if use_starttls { // Plain connection upgraded to TLS via STARTTLS (typically port 143) Self::fetch_bounces_starttls( host, port, accept_invalid_certs, ca_cert_path, user, password, mailbox, ) } else { // Plain connection without encryption (not recommended) Self::fetch_bounces_plain(host, port, user, password, mailbox) } } /// Fetch bounces over TLS connection (direct TLS, typically port 993) fn fetch_bounces_tls( host: &str, port: u16, accept_invalid_certs: bool, ca_cert_path: Option<&str>, user: &str, password: &str, mailbox: &str, ) -> Result<Vec<BounceInfo>, AppError> { let mut tls_builder = TlsConnector::builder(); // Load custom CA certificate if configured if let Some(path) = ca_cert_path { let cert_pem = std::fs::read(path).map_err(|e| { AppError::Internal(format!("Failed to read CA certificate {}: {}", path, e)) })?; let cert = Certificate::from_pem(&cert_pem).map_err(|e| { AppError::Internal(format!("Failed to parse CA certificate: {}", e)) })?; tls_builder.add_root_certificate(cert); tracing::debug!("Added custom CA certificate for IMAP: {}", path); } if accept_invalid_certs { tls_builder.danger_accept_invalid_certs(true); } let tls = tls_builder .build() .map_err(|e| AppError::Internal(format!("TLS error: {}", e)))?; let client = imap::connect((host, port), host, &tls) .map_err(|e| AppError::Internal(format!("IMAP connect error: {}", e)))?; let mut session = client .login(user, password) .map_err(|e| AppError::Internal(format!("IMAP login error: {:?}", e.0)))?; let bounces = Self::process_session(&mut session, mailbox)?; let _ = session.logout(); Ok(bounces) } /// Fetch bounces using STARTTLS (plain connection upgraded to TLS, typically port 143) fn fetch_bounces_starttls( host: &str, port: u16, accept_invalid_certs: bool, ca_cert_path: Option<&str>, user: &str, password: &str, mailbox: &str, ) -> Result<Vec<BounceInfo>, AppError> { tracing::debug!("Connecting to IMAP server {}:{} with STARTTLS", host, port); // Connect plain first with timeout let stream = TcpStream::connect((host, port)).map_err(|e| { AppError::Internal(format!("TCP connect error to {}:{}: {}", host, port, e)) })?; tracing::debug!("TCP connection established to {}:{}", host, port); let timeout_secs = crate::config::tasks::imap_socket_timeout_secs(); stream .set_read_timeout(Some(std::time::Duration::from_secs(timeout_secs))) .map_err(|e| AppError::Internal(format!("Failed to set read timeout: {}", e)))?; stream .set_write_timeout(Some(std::time::Duration::from_secs(timeout_secs))) .map_err(|e| AppError::Internal(format!("Failed to set write timeout: {}", e)))?; // Build TLS connector for STARTTLS upgrade let mut tls_builder = TlsConnector::builder(); // Load custom CA certificate if configured if let Some(path) = ca_cert_path { let cert_pem = std::fs::read(path).map_err(|e| { AppError::Internal(format!("Failed to read CA certificate {}: {}", path, e)) })?; let cert = Certificate::from_pem(&cert_pem).map_err(|e| { AppError::Internal(format!("Failed to parse CA certificate: {}", e)) })?; tls_builder.add_root_certificate(cert); tracing::debug!("Added custom CA certificate for IMAP STARTTLS: {}", path); } if accept_invalid_certs { tls_builder.danger_accept_invalid_certs(true); tracing::debug!("IMAP STARTTLS: Accepting invalid certificates (INSECURE)"); } let tls = tls_builder .build() .map_err(|e| AppError::Internal(format!("TLS connector build error: {}", e)))?; // Create IMAP client - this reads the initial greeting from the server // Note: Client::new() automatically reads the server's initial greeting response // If the server response is malformed or non-standard, this will fail with // "Unable to parse status response" tracing::debug!("Reading initial IMAP greeting from server"); let client = imap::Client::new(stream); tracing::debug!("Sending STARTTLS command to {}:{}", host, port); // Upgrade connection to TLS via STARTTLS // The secure() method sends STARTTLS and handles the server response let tls_client = client.secure(host, &tls).map_err(|e| { // Provide more detailed error information with context let error_msg = format!( "IMAP STARTTLS error on {}:{}: {}. \ Possible causes: \ 1) Server doesn't support STARTTLS \ 2) Server response was malformed or non-standard \ 3) Network issue or timeout \ 4) Certificate validation failed (check IMAP_ACCEPT_INVALID_CERTS or IMAP_CA_CERT_PATH) \ \ Diagnostic steps: \ 1) Test server response manually: \ echo 'a1 CAPABILITY' | openssl s_client -connect {}:{} -starttls imap -quiet 2>&1 \ 2) Check if server supports STARTTLS: \ telnet {} {} then type: a1 CAPABILITY (look for STARTTLS in response) \ 3) Verify certificate: \ openssl s_client -connect {}:{} -starttls imap -showcerts \ 4) If server works with other clients, consider using IMAP_USE_TLS=true with port 993 \ for direct TLS connection (bypasses STARTTLS negotiation)", host, port, e, host, port, host, port, host, port ); tracing::error!("{}", error_msg); AppError::Internal(error_msg) })?; tracing::debug!("STARTTLS upgrade successful, connection is now encrypted"); // Login after STARTTLS tracing::debug!("Attempting IMAP login for user: {}", user); let mut session = tls_client.login(user, password).map_err(|e| { tracing::error!("IMAP login failed for user {}: {:?}", user, e.0); AppError::Internal(format!("IMAP login error: {:?}", e.0)) })?; tracing::debug!("IMAP login successful, processing mailbox: {}", mailbox); let bounces = Self::process_session(&mut session, mailbox)?; let _ = session.logout(); tracing::debug!("IMAP session closed successfully"); Ok(bounces) } /// Fetch bounces over plain connection (not recommended) fn fetch_bounces_plain( host: &str, port: u16, user: &str, password: &str, mailbox: &str, ) -> Result<Vec<BounceInfo>, AppError> { let stream = TcpStream::connect((host, port)) .map_err(|e| AppError::Internal(format!("TCP connect error: {}", e)))?; let client = imap::Client::new(stream); let mut session = client .login(user, password) .map_err(|e| AppError::Internal(format!("IMAP login error: {:?}", e.0)))?; let bounces = Self::process_session(&mut session, mailbox)?; let _ = session.logout(); Ok(bounces) } /// Process an IMAP session to fetch bounce messages fn process_session<T: std::io::Read + std::io::Write>( session: &mut imap::Session<T>, mailbox: &str, ) -> Result<Vec<BounceInfo>, AppError> { // Select mailbox session .select(mailbox) .map_err(|e| AppError::Internal(format!("IMAP select error: {}", e)))?; // RFC 3464: a DSN is multipart/report with report-type=delivery-status. // HEADER matches the top-level Content-Type, so both tokens are needed. // IMAP OR is a prefix operator, not an infix one. let search_result = session .search( r#"OR (UNSEEN HEADER Content-Type "delivery-status") (UNSEEN HEADER Content-Type "multipart/report")"#, ) .map_err(|e| AppError::Internal(format!("IMAP search error: {e}")))?; let mut bounces = Vec::new(); for msg_seq in search_result.iter() { // Fetch the message let messages = session .fetch(msg_seq.to_string(), "RFC822") .map_err(|e| AppError::Internal(format!("IMAP fetch error: {}", e)))?; for message in messages.iter() { if let Some(body) = message.body() { if let Some(bounce_info) = Self::parse_dsn_message(body) { bounces.push(bounce_info); // Mark message as seen let _ = session.store(msg_seq.to_string(), "+FLAGS (\\Seen)"); } } } } Ok(bounces) } /// Parse a DSN message to extract bounce information fn parse_dsn_message(body: &[u8]) -> Option<BounceInfo> { let body_str = String::from_utf8_lossy(body); // Look for common DSN patterns // Original-Recipient or Final-Recipient let recipient = Self::extract_header_value(&body_str, "Final-Recipient:") .or_else(|| Self::extract_header_value(&body_str, "Original-Recipient:")) .map(|r| r.replace("rfc822;", "").trim().to_string())?; // Action (failed, delayed, delivered, relayed, expanded) let action = Self::extract_header_value(&body_str, "Action:") .map(|a| a.trim().to_lowercase()) .unwrap_or_else(|| "failed".to_string()); // Status code (e.g., 5.1.1, 4.2.2) let status = Self::extract_header_value(&body_str, "Status:").map(|s| s.trim().to_string()); // Diagnostic-Code let diagnostic = Self::extract_header_value(&body_str, "Diagnostic-Code:").map(|d| d.trim().to_string()); // Try to extract original message ID let original_message_id = Self::extract_header_value(&body_str, "Original-Message-ID:") .or_else(|| Self::extract_header_value(&body_str, "Message-ID:")) .map(|m| m.trim().trim_matches(['<', '>']).to_string()); // Map action to our state let state = match action.as_str() { "delivered" => "delivered", "relayed" | "expanded" => "accepted", "delayed" => "sending", _ => "bounced", }; Some(BounceInfo { recipient, action, status, diagnostic, original_message_id, state: state.to_string(), }) } /// Extract a header value from message body fn extract_header_value(body: &str, header: &str) -> Option<String> { for line in body.lines() { if line.to_lowercase().starts_with(&header.to_lowercase()) { return Some(line[header.len()..].trim().to_string()); } } None } /// Update EmailDeliveryStatus with bounce information async fn update_delivery_status( &self, db: &DatabaseConnection, bounce: &BounceInfo, ) -> Result<(), AppError> { // Find the delivery status by recipient email let status = email_delivery_status::Entity::find() .filter(email_delivery_status::Column::Recipient.eq(&bounce.recipient)) .filter(email_delivery_status::Column::State.ne("delivered")) .filter(email_delivery_status::Column::State.ne("bounced")) .one(db) .await?; if let Some(existing) = status { let now = Utc::now(); let mut active: email_delivery_status::ActiveModel = existing.into(); // Update state based on bounce action let new_state = if bounce.state == "bounced" || bounce.state == "delivered" { &bounce.state } else { // Keep current state for delayed/relayed return Ok(()); }; active.state = Set(new_state.clone()); active.smtp_code = Set(bounce.status.clone()); active.smtp_response = Set(bounce.diagnostic.clone()); active.last_state_at = Set(now); active.updated_at = Set(now); active.update(db).await?; tracing::info!( "Updated delivery status for {}: {} (action={}, status={}, msg_id={:?})", bounce.recipient, new_state, bounce.action, bounce.status.as_deref().unwrap_or("no status"), bounce.original_message_id ); } Ok(()) } } /// Information extracted from a DSN bounce message #[derive(Debug)] struct BounceInfo { /// Recipient email address recipient: String, /// DSN action (failed, delayed, delivered, etc.) action: String, /// Status code (e.g., 5.1.1) status: Option<String>, /// Diagnostic message diagnostic: Option<String>, /// Original message ID (if available) original_message_id: Option<String>, /// Mapped state for our system state: String, }
GitRust