//! IMAP client functionality for mail2couch //! //! This module provides IMAP client operations for connecting to mail servers, //! listing mailboxes, and retrieving messages. use crate::config::{MailSource, MessageFilter}; use crate::schemas::{MailDocument, AttachmentStub}; use anyhow::{anyhow, Result}; use async_imap::types::Fetch; use async_imap::{Client, Session}; use async_native_tls::{TlsConnector, TlsStream}; use async_std::io::{Read, Write}; use async_std::net::TcpStream; use async_std::stream::StreamExt; use async_std::task::{Context, Poll}; use chrono::{DateTime, Utc}; use mail_parser::{Message, MimeHeaders}; use std::collections::HashMap; use std::pin::Pin; use std::time::Duration; use thiserror::Error; #[derive(Error, Debug)] pub enum ImapError { #[error("Connection failed: {0}")] Connection(String), #[error("Authentication failed: {0}")] Authentication(String), #[error("IMAP operation failed: {0}")] Operation(String), #[error("Message parsing failed: {0}")] Parsing(String), } /// Wrapper for both TLS and plain TCP streams pub enum ImapStream { Plain(TcpStream), Tls(TlsStream), } impl Read for ImapStream { fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut [u8], ) -> Poll> { match self.get_mut() { ImapStream::Plain(stream) => Pin::new(stream).poll_read(cx, buf), ImapStream::Tls(stream) => Pin::new(stream).poll_read(cx, buf), } } } impl Write for ImapStream { fn poll_write( self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { match self.get_mut() { ImapStream::Plain(stream) => Pin::new(stream).poll_write(cx, buf), ImapStream::Tls(stream) => Pin::new(stream).poll_write(cx, buf), } } fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.get_mut() { ImapStream::Plain(stream) => Pin::new(stream).poll_flush(cx), ImapStream::Tls(stream) => Pin::new(stream).poll_flush(cx), } } fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { match self.get_mut() { ImapStream::Plain(stream) => Pin::new(stream).poll_close(cx), ImapStream::Tls(stream) => Pin::new(stream).poll_close(cx), } } } impl std::fmt::Debug for ImapStream { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ImapStream::Plain(_) => write!(f, "ImapStream::Plain(_)"), ImapStream::Tls(_) => write!(f, "ImapStream::Tls(_)"), } } } pub type ImapSession = Session; /// IMAP client for mail operations pub struct ImapClient { session: Option, source: MailSource, } /// Represents a mailbox on the IMAP server #[derive(Debug, Clone)] pub struct MailboxInfo { pub name: String, pub exists: u32, pub recent: u32, pub uid_validity: Option, pub uid_next: Option, } impl ImapClient { /// Create a new IMAP client and connect to the server with retry logic pub async fn connect(source: MailSource) -> Result { let mut client = ImapClient { session: None, source, }; client.establish_connection_with_retry().await?; Ok(client) } /// Establish connection with automatic retry logic async fn establish_connection_with_retry(&mut self) -> Result<()> { const MAX_RETRIES: u32 = 3; const RETRY_DELAY_MS: u64 = 1000; let mut last_error = None; for attempt in 1..=MAX_RETRIES { match self.establish_connection().await { Ok(()) => { if attempt > 1 { log::info!("✅ IMAP connection successful on attempt {}", attempt); } return Ok(()); } Err(e) => { last_error = Some(e); if attempt < MAX_RETRIES { log::warn!( "🔄 IMAP connection attempt {} failed, retrying in {}ms: {}", attempt, RETRY_DELAY_MS, last_error.as_ref().unwrap() ); async_std::task::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; } } } } Err(anyhow!( "Failed to establish IMAP connection after {} attempts. Last error: {}", MAX_RETRIES, last_error.unwrap() )) } /// Establish connection to IMAP server async fn establish_connection(&mut self) -> Result<()> { // Connect to the IMAP server let addr = format!("{}:{}", self.source.host, self.source.port); let tcp_stream = TcpStream::connect(&addr).await .map_err(|e| ImapError::Connection(format!("Failed to connect to {}: {}", addr, e)))?; // Determine if we should use TLS based on port let imap_stream = if self.should_use_tls() { // Use TLS for secure connection (typically port 993) let tls_connector = TlsConnector::new(); let tls_stream = tls_connector.connect(&self.source.host, tcp_stream).await .map_err(|e| ImapError::Connection(format!("TLS connection failed: {}", e)))?; ImapStream::Tls(tls_stream) } else { // Use plain connection (typically port 143 or test environments) ImapStream::Plain(tcp_stream) }; // Log connection type for debugging let connection_type = if self.should_use_tls() { "TLS" } else { "Plain" }; log::debug!( "Connecting to {}:{} using {} connection", self.source.host, self.source.port, connection_type ); // Create IMAP client let client = Client::new(imap_stream); // Perform IMAP login let session = client .login(&self.source.user, &self.source.password) .await .map_err(|e| ImapError::Authentication(format!("Login failed: {:?}", e)))?; self.session = Some(session); Ok(()) } /// Determine if TLS should be used based on port and configuration fn should_use_tls(&self) -> bool { // Standard IMAPS port (993) uses TLS // Port 143 is typically plain IMAP // Port 3143 is used in test environments (plain) match self.source.port { 993 => true, // Standard IMAPS port 143 => false, // Standard IMAP port 3143 => false, // Test environment port _ => { // For other ports, default to TLS for security // but log a warning log::warn!( "Unknown IMAP port {}, defaulting to TLS. Use port 143 for plain IMAP or 993 for TLS", self.source.port ); true } } } /// List all mailboxes pub async fn list_mailboxes(&mut self) -> Result> { let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; let mut mailboxes = session.list(Some(""), Some("*")).await .map_err(|e| ImapError::Operation(format!("Failed to list mailboxes: {:?}", e)))?; let mut mailbox_names = Vec::new(); while let Some(mailbox_result) = mailboxes.next().await { match mailbox_result { Ok(mailbox) => mailbox_names.push(mailbox.name().to_string()), Err(e) => return Err(ImapError::Operation(format!("Error processing mailbox: {:?}", e)).into()), } } Ok(mailbox_names) } /// Select a mailbox pub async fn select_mailbox(&mut self, mailbox: &str) -> Result { let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; let mailbox_data = session.select(mailbox).await .map_err(|e| ImapError::Operation(format!("Failed to select mailbox {}: {:?}", mailbox, e)))?; Ok(MailboxInfo { name: mailbox.to_string(), exists: mailbox_data.exists, recent: mailbox_data.recent, uid_validity: mailbox_data.uid_validity, uid_next: mailbox_data.uid_next, }) } /// Search for messages using IMAP SEARCH command with retry logic /// Returns UIDs of matching messages pub async fn search_messages(&mut self, since_date: Option<&DateTime>) -> Result> { const MAX_RETRIES: u32 = 3; const RETRY_DELAY_MS: u64 = 500; let mut last_error = None; for attempt in 1..=MAX_RETRIES { let result = self.search_messages_internal(since_date).await; match result { Ok(uids) => { if attempt > 1 { log::debug!("✅ IMAP search successful on attempt {}", attempt); } return Ok(uids); } Err(e) => { last_error = Some(e); if attempt < MAX_RETRIES { log::warn!( "🔄 IMAP search attempt {} failed, retrying in {}ms: {}", attempt, RETRY_DELAY_MS, last_error.as_ref().unwrap() ); async_std::task::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; } } } } Err(anyhow!( "IMAP search failed after {} attempts. Last error: {}", MAX_RETRIES, last_error.unwrap() )) } /// Internal search implementation without retry logic async fn search_messages_internal(&mut self, since_date: Option<&DateTime>) -> Result> { let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; let search_query = if let Some(since) = since_date { // Format date as required by IMAP (DD-MMM-YYYY) // IMAP months are 3-letter abbreviations in English let formatted_date = since.format("%d-%b-%Y").to_string(); log::debug!("Searching for messages since: {}", formatted_date); format!("SINCE {}", formatted_date) } else { log::debug!("Searching for all messages"); "ALL".to_string() }; log::debug!("IMAP search query: {}", search_query); let uids = session.uid_search(&search_query).await .map_err(|e| ImapError::Operation(format!("Search failed with query '{}': {:?}", search_query, e)))?; let uid_vec: Vec = uids.into_iter().collect(); log::debug!("Found {} messages matching search criteria", uid_vec.len()); Ok(uid_vec) } /// Search for messages with advanced criteria /// Supports multiple search parameters for more complex queries pub async fn search_messages_advanced( &mut self, since_date: Option<&DateTime>, before_date: Option<&DateTime>, subject_keywords: Option<&[String]>, from_keywords: Option<&[String]>, ) -> Result> { let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; let mut search_parts = Vec::new(); // Add date filters if let Some(since) = since_date { let formatted_date = since.format("%d-%b-%Y").to_string(); search_parts.push(format!("SINCE {}", formatted_date)); } if let Some(before) = before_date { let formatted_date = before.format("%d-%b-%Y").to_string(); search_parts.push(format!("BEFORE {}", formatted_date)); } // Add subject keyword filters if let Some(keywords) = subject_keywords { for keyword in keywords { search_parts.push(format!("SUBJECT \"{}\"", keyword.replace("\"", "\\\""))); } } // Add from keyword filters if let Some(keywords) = from_keywords { for keyword in keywords { search_parts.push(format!("FROM \"{}\"", keyword.replace("\"", "\\\""))); } } // Build the final search query let search_query = if search_parts.is_empty() { "ALL".to_string() } else { search_parts.join(" ") }; log::debug!("Advanced IMAP search query: {}", search_query); let uids = session.uid_search(&search_query).await .map_err(|e| ImapError::Operation(format!("Advanced search failed with query '{}': {:?}", search_query, e)))?; let uid_vec: Vec = uids.into_iter().collect(); log::debug!("Found {} messages matching advanced search criteria", uid_vec.len()); Ok(uid_vec) } /// Fetch message by UID with attachment data pub async fn fetch_message(&mut self, uid: u32, mailbox: &str) -> Result<(MailDocument, Vec<(String, String, Vec)>)> { let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; // Fetch message headers and body let mut messages = session.uid_fetch(format!("{}", uid), "RFC822").await .map_err(|e| ImapError::Operation(format!("Failed to fetch message {}: {:?}", uid, e)))?; // Collect the first message if let Some(message_result) = messages.next().await { match message_result { Ok(message) => { // Drop the messages stream to release the session borrow drop(messages); self.parse_message(&message, uid, mailbox).await } Err(e) => Err(ImapError::Operation(format!("Failed to process message {}: {:?}", uid, e)).into()), } } else { Err(anyhow!("Message {} not found", uid)) } } /// Fetch multiple messages by UIDs with attachment data pub async fn fetch_messages(&mut self, uids: &[u32], max_count: Option, mailbox: &str) -> Result)>)>> { if uids.is_empty() { return Ok(Vec::new()); } let session = self.session.as_mut() .ok_or_else(|| anyhow!("Not connected to IMAP server"))?; // Limit the number of messages if specified let uids_to_fetch = if let Some(max) = max_count { if uids.len() > max as usize { &uids[..max as usize] } else { uids } } else { uids }; // Create UID sequence let uid_sequence = uids_to_fetch.iter() .map(|uid| uid.to_string()) .collect::>() .join(","); // Fetch messages let mut messages = session.uid_fetch(&uid_sequence, "RFC822").await .map_err(|e| ImapError::Operation(format!("Failed to fetch messages: {:?}", e)))?; // Collect all messages first to avoid borrowing issues let mut fetched_messages = Vec::new(); while let Some(message_result) = messages.next().await { match message_result { Ok(message) => fetched_messages.push(message), Err(e) => log::warn!("Failed to fetch message: {:?}", e), } } // Drop the messages stream to release the session borrow drop(messages); let mut mail_documents = Vec::new(); for (i, message) in fetched_messages.iter().enumerate() { if let Some(&uid) = uids_to_fetch.get(i) { match self.parse_message(message, uid, mailbox).await { Ok((doc, attachments)) => mail_documents.push((doc, attachments)), Err(e) => { log::warn!("Failed to parse message {}: {}", uid, e); } } } } Ok(mail_documents) } /// Parse a raw IMAP message into a MailDocument with attachment data async fn parse_message(&self, message: &Fetch, uid: u32, mailbox: &str) -> Result<(MailDocument, Vec<(String, String, Vec)>)> { let body = message.body() .ok_or_else(|| ImapError::Parsing("No message body found".to_string()))?; // Parse the email using mail-parser library let parsed_message = Message::parse(body) .ok_or_else(|| ImapError::Parsing("Failed to parse email message".to_string()))?; // Extract sender addresses let from = self.extract_addresses(&parsed_message, "From"); // Extract recipient addresses let to = self.extract_addresses(&parsed_message, "To"); // Extract subject let subject = parsed_message .get_subject() .unwrap_or("No Subject") .to_string(); // Extract date let date = if let Some(date_time) = parsed_message.get_date() { DateTime::from_timestamp(date_time.to_timestamp(), 0).unwrap_or_else(|| Utc::now()) } else { Utc::now() }; // Extract body content (prefer text/plain, fallback to text/html) let body_content = self.extract_body_content(&parsed_message); // Extract headers let headers = self.extract_headers(&parsed_message); // Extract attachments and their data let (has_attachments, attachment_stubs, attachment_data) = self.extract_attachments_with_data(&parsed_message); let mut mail_doc = MailDocument::new( uid.to_string(), mailbox.to_string(), from, to, subject, date, body_content, headers, has_attachments, ); // Don't store attachment metadata in the document // CouchDB will handle this when we store attachments separately // This matches the Go implementation approach // Log attachment information if !attachment_data.is_empty() { log::info!("Found {} attachments for message {}", attachment_data.len(), uid); } Ok((mail_doc, attachment_data)) } /// Extract email addresses from a parsed message fn extract_addresses(&self, message: &Message, header_name: &str) -> Vec { if let Some(header) = message.get_header(header_name) { // For address headers, use as_text() and parse manually // mail-parser doesn't provide a direct address parsing method let header_text = header.as_text_ref().unwrap_or(""); // Simple address extraction - split by comma and clean up header_text .split(',') .map(|addr| addr.trim().to_string()) .filter(|addr| !addr.is_empty() && addr.contains('@')) .collect() } else { Vec::new() } } /// Extract body content from a parsed message (prefer text/plain, fallback to text/html) fn extract_body_content(&self, message: &Message) -> String { // Try to get text/plain body first (index 0 = first text part) if let Some(text_body) = message.get_text_body(0) { return text_body.to_string(); } // Fallback to HTML body if no plain text (index 0 = first HTML part) if let Some(html_body) = message.get_html_body(0) { return html_body.to_string(); } // If neither standard method works, try to extract from parts manually for part in &message.parts { // Check content type for text parts if let Some(content_type) = part.get_content_type() { if content_type.c_type.starts_with("text/plain") { if let Some(body) = part.get_text_contents() { return body.to_string(); } } } } // Second pass for HTML parts if no plain text found for part in &message.parts { if let Some(content_type) = part.get_content_type() { if content_type.c_type.starts_with("text/html") { if let Some(body) = part.get_text_contents() { return body.to_string(); } } } } // Last resort - try any text content for part in &message.parts { if let Some(body) = part.get_text_contents() { if !body.trim().is_empty() { return body.to_string(); } } } // Absolutely last resort - empty body "No body content found".to_string() } /// Extract all headers from a parsed message fn extract_headers(&self, message: &Message) -> HashMap> { let mut headers = HashMap::new(); for header in message.get_headers() { let name = header.name().to_lowercase(); let value = match header.value().as_text_ref() { Some(text) => text.to_string(), None => format!("{:?}", header.value()), // Fallback for non-text values }; headers.entry(name) .or_insert_with(Vec::new) .push(value); } headers } /// Extract attachments from a parsed message with binary data /// Returns (has_attachments, attachment_stubs, attachment_data) fn extract_attachments_with_data(&self, message: &Message) -> (bool, HashMap, Vec<(String, String, Vec)>) { let mut attachment_stubs = HashMap::new(); let mut attachment_data = Vec::new(); // Iterate through all message parts looking for attachments for (index, part) in message.parts.iter().enumerate() { // Check if this part is an attachment if let Some(content_type) = part.get_content_type() { let is_attachment = self.is_attachment_part(part, &content_type); if is_attachment { // Generate a filename for the attachment let filename = self.get_attachment_filename(part, index); // Get the content data (try different methods based on content type) let body_data = if let Some(text_content) = part.get_text_contents() { // Text-based attachments log::debug!("Found text attachment content: {} bytes", text_content.len()); text_content.as_bytes().to_vec() } else { // For now, skip attachments without text content // TODO: Implement binary attachment support with proper mail-parser API log::debug!("Skipping non-text attachment for part {} (content-type: {})", index, content_type.c_type); vec![] }; let content_type_str = content_type.c_type.to_string(); // Only create attachment stub if we have actual data if !body_data.is_empty() { let attachment_stub = AttachmentStub { content_type: content_type_str.clone(), length: Some(body_data.len() as u64), stub: None, // Will be stored as actual attachment data }; attachment_stubs.insert(filename.clone(), attachment_stub); attachment_data.push((filename, content_type_str, body_data)); } } } } let has_attachments = !attachment_stubs.is_empty(); (has_attachments, attachment_stubs, attachment_data) } /// Extract attachments from a parsed message (deprecated - use extract_attachments_with_data) /// Returns (has_attachments, attachment_stubs) fn extract_attachments(&self, message: &Message) -> (bool, HashMap) { let (has_attachments, attachment_stubs, _) = self.extract_attachments_with_data(message); (has_attachments, attachment_stubs) } /// Determine if a message part is an attachment fn is_attachment_part(&self, part: &mail_parser::MessagePart, content_type: &mail_parser::ContentType) -> bool { // Check Content-Disposition header first if let Some(disposition) = part.get_content_disposition() { return disposition.c_type.to_lowercase() == "attachment"; } // If no explicit disposition, check content type // Consider non-text types as potential attachments let main_type = content_type.c_type.split('/').next().unwrap_or(""); match main_type { "text" => false, // Text parts are usually body content "multipart" => false, // Multipart containers are not attachments _ => true, // Images, applications, etc. are likely attachments } } /// Generate a filename for an attachment fn get_attachment_filename(&self, part: &mail_parser::MessagePart, index: usize) -> String { // Try to get filename from Content-Disposition if let Some(disposition) = part.get_content_disposition() { // Find filename in attributes vector if let Some(attrs) = &disposition.attributes { for (key, value) in attrs { if key.to_lowercase() == "filename" { return value.to_string(); } } } } // Try to get filename from Content-Type if let Some(content_type) = part.get_content_type() { // Find name in attributes vector if let Some(attrs) = &content_type.attributes { for (key, value) in attrs { if key.to_lowercase() == "name" { return value.to_string(); } } } } // Generate a default filename based on content type and index if let Some(content_type) = part.get_content_type() { let extension = self.get_extension_from_content_type(&content_type.c_type); format!("attachment_{}{}", index, extension) } else { format!("attachment_{}.bin", index) } } /// Get file extension from MIME content type fn get_extension_from_content_type(&self, content_type: &str) -> &'static str { match content_type { "image/jpeg" => ".jpg", "image/png" => ".png", "image/gif" => ".gif", "application/pdf" => ".pdf", "application/zip" => ".zip", "application/msword" => ".doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => ".docx", "application/vnd.ms-excel" => ".xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => ".xlsx", "text/plain" => ".txt", "text/html" => ".html", _ => ".bin", // Default binary extension } } /// Close the IMAP connection pub async fn close(self) -> Result<()> { if let Some(mut session) = self.session { session.logout().await .map_err(|e| ImapError::Operation(format!("Logout failed: {:?}", e)))?; } Ok(()) } } /// Apply message filters to determine if a message should be processed pub fn should_process_message( mail_doc: &MailDocument, filter: &MessageFilter, ) -> bool { // Check subject keywords if !filter.subject_keywords.is_empty() { let subject_lower = mail_doc.subject.to_lowercase(); let has_subject_keyword = filter.subject_keywords.iter() .any(|keyword| subject_lower.contains(&keyword.to_lowercase())); if !has_subject_keyword { return false; } } // Check sender keywords if !filter.sender_keywords.is_empty() { let has_sender_keyword = mail_doc.from.iter() .any(|from_addr| { let from_lower = from_addr.to_lowercase(); filter.sender_keywords.iter() .any(|keyword| from_lower.contains(&keyword.to_lowercase())) }); if !has_sender_keyword { return false; } } // Check recipient keywords if !filter.recipient_keywords.is_empty() { let has_recipient_keyword = mail_doc.to.iter() .any(|to_addr| { let to_lower = to_addr.to_lowercase(); filter.recipient_keywords.iter() .any(|keyword| to_lower.contains(&keyword.to_lowercase())) }); if !has_recipient_keyword { return false; } } true } #[cfg(test)] mod tests { use super::*; use crate::config::MessageFilter; #[test] fn test_message_filtering() { let mail_doc = MailDocument::new( "123".to_string(), "INBOX".to_string(), vec!["sender@example.com".to_string()], vec!["recipient@test.com".to_string()], "Urgent: Meeting tomorrow".to_string(), Utc::now(), "Test body".to_string(), HashMap::new(), false, ); // Test subject keyword filtering let mut filter = MessageFilter { subject_keywords: vec!["urgent".to_string()], ..Default::default() }; assert!(should_process_message(&mail_doc, &filter)); filter.subject_keywords = vec!["spam".to_string()]; assert!(!should_process_message(&mail_doc, &filter)); // Test sender keyword filtering filter = MessageFilter { sender_keywords: vec!["@example.com".to_string()], ..Default::default() }; assert!(should_process_message(&mail_doc, &filter)); filter.sender_keywords = vec!["@spam.com".to_string()]; assert!(!should_process_message(&mail_doc, &filter)); } #[test] fn test_rfc822_parsing() { let client = ImapClient { session: None, source: MailSource { name: "test".to_string(), enabled: true, protocol: "imap".to_string(), host: "localhost".to_string(), port: 143, user: "test".to_string(), password: "test".to_string(), mode: "archive".to_string(), folder_filter: Default::default(), message_filter: Default::default(), }, }; // Test email parsing with the new mail-parser implementation // This test needs to be updated to use actual message parsing // For now, we'll skip the detailed test since it requires a full email message } }