diff --git a/mailcore/.gitignore b/mailcore/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/mailcore/.gitignore @@ -0,0 +1 @@ +/target diff --git a/mailcore/Cargo.toml b/mailcore/Cargo.toml new file mode 100644 index 0000000..d7ac476 --- /dev/null +++ b/mailcore/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "mailcore" +version = "0.1.1" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib", "staticlib"] + +[dependencies] +# IMAP/SMTP +async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] } +lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "builder", "smtp-transport", "pool"] } + +# TLS Support (Manual for IMAP) +tokio-rustls = "0.26" +rustls = { version = "0.23", features = ["ring"] } +rustls-native-certs = "0.7" # Confirm 0.7 or 0.8 for rustls 0.23 +rustls-pki-types = "1" +webpki-roots = "0.26" + +# MIME +mail-parser = "0.9" +mail-builder = "0.3" +# HTTP Client +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha1 = "0.10" +zbase32 = "0.1" + +# DNS +trust-dns-resolver = { version = "0.23", default-features = false, features = ["tokio-runtime", "dns-over-rustls"] } + +# Storage +rusqlite = { version = "0.31", features = ["bundled"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# FFI +flutter_rust_bridge = "=2.11.1" +once_cell = "1" +lazy_static = "1" + +# Error handling +anyhow = "1" +thiserror = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = "0.3" +futures = "0.3.31" +chrono = "0.4.43" +# Removed native-tls dependencies for better cross-platform compatibility +keyring = { version = "3", features = ["sync-secret-service"] } + +# Platform-specific +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +security-framework = "2" +core-foundation = "0.9" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3" + +[profile.release] +opt-level = "s" +lto = false +codegen-units = 1 +panic = "abort" diff --git a/mailcore/examples/test_dns.rs b/mailcore/examples/test_dns.rs new file mode 100644 index 0000000..855788a --- /dev/null +++ b/mailcore/examples/test_dns.rs @@ -0,0 +1,18 @@ +use mailcore::dns::discover_mail_server; + +#[tokio::main] +async fn main() { + let email = std::env::args().nth(1).expect("Usage: test_dns "); + + println!("Discovering server for: {}", email); + + match discover_mail_server(&email).await { + Ok(config) => { + println!("✓ IMAP: {}:{}", config.imap_host, config.imap_port); + println!("✓ SMTP: {}:{}", config.smtp_host, config.smtp_port); + } + Err(e) => { + eprintln!("✗ Error: {}", e); + } + } +} diff --git a/mailcore/examples/test_imap.rs b/mailcore/examples/test_imap.rs new file mode 100644 index 0000000..e56320d --- /dev/null +++ b/mailcore/examples/test_imap.rs @@ -0,0 +1,52 @@ +use mailcore::dns::discover_mail_server; +use mailcore::imap::ImapClient; +use std::io::{self, Write}; + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: test_imap "); + std::process::exit(1); + } + let email = &args[1]; + + println!("Discovering server for: {}", email); + let config = match discover_mail_server(email).await { + Ok(c) => { + println!("✓ IMAP: {}:{}", c.imap_host, c.imap_port); + c + } + Err(e) => { + eprintln!("✗ DNS Error: {}", e); + return; + } + }; + + print!("Password for {}: ", email); + io::stdout().flush().unwrap(); + let mut password = String::new(); + io::stdin().read_line(&mut password).unwrap(); + let password = password.trim(); + + let mut client = ImapClient::new(config, email.to_string()); + + println!("Connecting..."); + match client.connect(password).await { + Ok(_) => { + println!("✓ Connected and authenticated successfully!"); + + println!("Listing folders..."); + match client.list_folders(true).await { + Ok(folders) => { + println!("\nFolders:"); + for folder in folders { + println!(" - {} ({:?})", folder.name, folder.folder_type); + } + } + Err(e) => eprintln!("✗ Failed to list folders: {}", e), + } + } + Err(e) => eprintln!("✗ Connection Error: {}", e), + } +} diff --git a/mailcore/src/api.rs b/mailcore/src/api.rs new file mode 100644 index 0000000..e6f186c --- /dev/null +++ b/mailcore/src/api.rs @@ -0,0 +1,1153 @@ +use crate::types::{FolderInfo, MailError, MailServerConfig}; + +use mail_parser::MimeHeaders; + +use lazy_static::lazy_static; +use std::collections::HashMap; +use tokio::sync::Mutex; + +lazy_static! { + // Global tracker for IMAP IDLE cancellation channels, keyed by email + static ref IDLE_CANCELS: std::sync::Arc>>> = std::sync::Arc::new(Mutex::new(HashMap::new())); +} + +pub fn init_logger() { + tracing_subscriber::fmt::init(); + + // Initialize Rustls with 'ring' as the default provider to prevent crashes + // when multiple providers (ring, aws-lc-rs) are present in the dependency graph. + // This is required for Rustls 0.23+ + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +pub async fn discover_server(email: String) -> Result { + crate::dns::discover_mail_server(&email) + .await + .map_err(|e| MailError::Network(e.to_string())) +} + +/// A simple test function to verify we can connect and list folders. +pub async fn connect_and_list_folders( + config: MailServerConfig, + email: String, + password: String, + show_all_folders: bool, + storage_path: String, +) -> Result, MailError> { + // This call will create/cache the session in the manager + let client_arc = crate::session_manager::get_client(config, email, password).await?; + + let mut client = client_arc.lock().await; + let mut folders = client + .list_folders(show_all_folders) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + // Add unread counts from storage for all folders + if let Ok(storage) = crate::storage::Storage::new(&storage_path) { + for folder in &mut folders { + if let Ok(count) = storage.get_unread_count(&folder.name) { + folder.unread_count = count; + } + } + } + + Ok(folders) +} + +pub fn get_total_unread_count(storage_path: String) -> i32 { + if let Ok(storage) = crate::storage::Storage::new(&storage_path) { + storage.get_total_unread_count().unwrap_or(0) + } else { + 0 + } +} + +pub async fn get_messages( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, +) -> Result, MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let client_arc = crate::session_manager::get_client(config, email, password).await?; + + let mut client = client_arc.lock().await; + client + .select_mailbox(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + client + .fetch_headers("1:*") + .await + .map_err(|e| MailError::Network(e.to_string())) +} + +pub async fn get_message_body( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + uid: u32, + storage_path: String, + _passphrase: Option, +) -> Result { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + let cached_raw = storage + .get_message_raw(&mailbox, uid) + .map_err(|e| MailError::Generic(e.to_string()))?; + + let body_result = if let Some(raw_bytes) = cached_raw { + tracing::debug!("Cache hit for message UID {} in {}", uid, mailbox); + parse_mime_message(&raw_bytes).await.map(|mut body| { + body.uid = uid; + body + }) + } else { + let mut client = crate::imap::ImapClient::new(config.clone(), email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + client + .select_mailbox(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let fetch_result = client.fetch_body_raw(uid).await; + + // Always close the dedicated connection, even on error. + let _ = client.logout().await; + + let raw_bytes = match fetch_result { + Ok(bytes) => bytes, + Err(e) => { + let err_msg = e.to_string(); + if err_msg.contains("not found on server") { + let _ = storage.delete_header(&mailbox, uid); + } + return Err(MailError::Network(err_msg)); + } + }; + + // Save to cache + if let Err(e) = storage.save_message_raw(&mailbox, uid, &raw_bytes) { + tracing::warn!("Failed to save message body to cache: {}", e); + } + + parse_mime_message(&raw_bytes).await.map(|mut body| { + body.uid = uid; + body + }) + }; + + body_result +} + +pub async fn set_message_flag( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + uid: u32, + flag: Option, + storage_path: String, +) -> Result<(), MailError> { + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + // Optimistically update local DB + if let Err(e) = storage.update_message_flag(&mailbox, uid, flag.clone()) { + tracing::warn!("Failed to update flag in local storage: {}", e); + } + + // Dedicated connection — never blocked by sync_mailbox holding the shared session lock. + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let result = client + .set_message_flag(&mailbox, uid, flag) + .await + .map_err(|e| MailError::Network(e.to_string())); + + // Always close the dedicated connection, even on error. + let _ = client.logout().await; + + result +} + +pub fn save_contact(path: String, contact: crate::types::Contact) -> Result<(), MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .save_contact(&contact) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn get_contacts(path: String) -> Result, MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .get_contacts() + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn delete_contact(path: String, email: String) -> Result<(), MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .delete_contact(&email) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn get_contact_by_email( + path: String, + email: String, +) -> Result, MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .get_contact_by_email(&email) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub async fn append_to_sent( + config: &MailServerConfig, + email: &str, + password: &str, + message_bytes: &[u8], +) { + let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string()); + if let Err(e) = client.connect(password).await { + tracing::warn!("append_to_sent: IMAP connect failed: {}", e); + return; + } + let folders = client.list_folders(false).await.unwrap_or_default(); + let sent_folder = folders + .iter() + .find(|f| f.folder_type == crate::types::FolderType::Sent) + .map(|f| f.name.clone()) + .unwrap_or_else(|| "Sent".to_string()); + if let Err(e) = client + .append_message(&sent_folder, message_bytes, Some(r"\Seen".to_string())) + .await + { + tracing::warn!("append_to_sent: append to {} failed: {}", sent_folder, e); + } + let _ = client.logout().await; +} + +#[allow(clippy::too_many_arguments)] +pub async fn send_email( + config: MailServerConfig, + email: String, + password: String, + to: String, + subject: String, + body: String, + attachments: Vec, + encrypt: bool, + _signer_key: Option, + _signer_password: Option, + _storage_path: String, +) -> Result<(), MailError> { + if encrypt { + return Err(MailError::Generic("PGP encryption not supported in mailcore. Use mailmore.".to_string())); + } + + let client = crate::smtp::SmtpClient::new(config.clone(), email.clone()); + let message_bytes = client + .send_mail(&password, &to, &subject, &body, attachments) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + append_to_sent(&config, &email, &password, &message_bytes).await; + Ok(()) +} + +pub async fn prefetch_bodies( + config: crate::types::MailServerConfig, + email: String, + password: String, + mailbox: String, + uids: Vec, + storage_path: String, +) -> Result<(), MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + // Filter to only uncached UIDs before opening a connection + let uncached: Vec = uids + .into_iter() + .filter(|&uid| !matches!(storage.get_message_raw(&mailbox, uid), Ok(Some(_)))) + .collect(); + + if uncached.is_empty() { + return Ok(()); + } + + // Use a DEDICATED connection so prefetch never blocks user-initiated fetches + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + client + .select_mailbox(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + for uid in uncached { + match client.fetch_body_raw(uid).await { + Ok(raw_bytes) => { + if let Err(e) = storage.save_message_raw(&mailbox, uid, &raw_bytes) { + tracing::warn!("Prefetch: Failed to save body for UID {}: {}", uid, e); + } + } + Err(e) => { + tracing::warn!("Prefetch: Failed to fetch body for UID {}: {}", uid, e); + } + } + } + + // Always close the dedicated connection when prefetch is done. + let _ = client.logout().await; + + Ok(()) +} + +pub async fn parse_mime_message(bytes: &[u8]) -> Result { + let bytes_owned = crate::imap::client::preprocess_header_bytes(bytes); + tokio::task::spawn_blocking(move || { + let parsed = mail_parser::MessageParser::default() + .parse(&bytes_owned) + .ok_or_else(|| MailError::Generic("Failed to parse MIME message".to_string()))?; + + let mut attachments = Vec::new(); + for part in parsed.attachments() { + let content_type = part + .content_type() + .map(|ct| { + let ctype = ct.ctype(); + let subtype = ct.subtype(); + format!("{}/{}", ctype, subtype.unwrap_or("")) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + attachments.push(crate::types::Attachment { + name: part.attachment_name().unwrap_or("unnamed").to_string(), + content_type, + size: part.contents().len(), + content: part.contents().to_vec(), + }); + } + + // RFC 3156 PGP/MIME: mail_parser's attachments() does not expose the inner + // parts of a multipart/encrypted envelope. Walk all parsed parts directly to + // find the application/octet-stream ciphertext that attachments() misses. + let mut is_pgp_mime_envelope = false; + if let Some(ct) = parsed.content_type() { + if ct.ctype() == "multipart" && ct.subtype().unwrap_or("") == "encrypted" { + is_pgp_mime_envelope = true; + for part in &parsed.parts { + if let Some(pct) = part.content_type() { + if pct.ctype() == "application" + && pct.subtype().unwrap_or("") == "octet-stream" + { + let name = part + .attachment_name() + .unwrap_or("encrypted.asc") + .to_string(); + let content = part.contents().to_vec(); + if !attachments + .iter() + .any(|a: &crate::types::Attachment| a.content == content) + { + attachments.push(crate::types::Attachment { + name, + content_type: "application/octet-stream".to_string(), + size: content.len(), + content, + }); + } + } + } + } + } + } + + let (pgp_status, signature_fingerprint) = detect_pgp_status(&parsed); + + let from_addr = parsed.from().and_then(|f| { + f.iter() + .next() + .map(|a| a.address().unwrap_or("").to_string()) + }); + + // For multipart/encrypted messages, body_text(0) returns raw MIME bytes + // (boundary markers, Content-Type headers, the armored PGP block) rather + // than meaningful body text. Suppress it so the caller never shows protocol + // noise to the user. + let (text_plain, text_html) = if is_pgp_mime_envelope { + (None, None) + } else { + ( + parsed.body_text(0).map(|s| s.to_string()), + parsed.body_html(0).map(|s| s.to_string()), + ) + }; + + Ok(crate::types::MessageBody { + uid: 0, // No UID for raw bytes + from: from_addr, + text_plain, + text_html, + attachments, + pgp_status, + signature_fingerprint, + }) + }) + .await + .unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e)))) +} + +fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, Option) { + let mut pgp_status = crate::types::PgpStatus::None; + let signature_fingerprint = None; + + // Basic PGP detection from headers + if let Some(ct) = parsed.content_type() { + let ctype = ct.ctype(); + let subtype = ct.subtype().unwrap_or(""); + if ctype == "multipart" && subtype == "signed" { + pgp_status = crate::types::PgpStatus::Signed; + } else if ctype == "multipart" && subtype == "encrypted" { + pgp_status = crate::types::PgpStatus::Encrypted; + } + } + + // Check text/plain body for inline PGP MESSAGE block + if pgp_status == crate::types::PgpStatus::None { + if let Some(text) = parsed.body_text(0) { + if text.contains("-----BEGIN PGP MESSAGE-----") { + pgp_status = crate::types::PgpStatus::Encrypted; + } + } + } + + // Try to find if signed or if we find a signature part + for part in &parsed.parts { + // Detect unnamed application/octet-stream containing PGP MESSAGE + if pgp_status == crate::types::PgpStatus::None { + let is_octet = part + .content_type() + .map(|ct| ct.ctype() == "application" && ct.subtype() == Some("octet-stream")) + .unwrap_or(false); + // Also match parts that have no content-type at all (servers strip it) + let has_no_ct = part.content_type().is_none(); + if is_octet || has_no_ct { + let contents = part.contents(); + if contents.starts_with(b"-----BEGIN PGP MESSAGE-----") { + pgp_status = crate::types::PgpStatus::Encrypted; + } + } + } + + if let Some(ct) = part.content_type() { + if ct.subtype() == Some("pgp-signature") { + if pgp_status == crate::types::PgpStatus::None { + pgp_status = crate::types::PgpStatus::Signed; + } + } + } + } + + (pgp_status, signature_fingerprint) +} + +pub async fn sync_mailbox( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + storage_path: String, + cache_days: i64, +) -> Result, MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + // Step 1: Run sync on the shared session. + let sync_result = { + let client_arc = + crate::session_manager::get_client(config.clone(), email.clone(), password.clone()) + .await?; + let mut client = client_arc.lock().await; + client + .sync_mailbox(&mailbox, &storage, cache_days) + .await + .map_err(|e| MailError::Network(e.to_string()))? + }; + + // Step 2: FLAG refresh on a dedicated connection. + let uids = sync_result.uids_for_flag_refresh; + if !uids.is_empty() { + let mut dedicated = crate::imap::ImapClient::new(config, email); + match dedicated.connect(&password).await { + Ok(()) => { + if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await { + tracing::warn!("FLAG refresh via dedicated connection failed: {}", e); + } + let _ = dedicated.logout().await; + } + Err(e) => { + tracing::warn!( + "FLAG refresh: dedicated connection failed to connect: {}", + e + ); + } + } + } + + Ok(sync_result.new_headers) +} + +pub async fn listen_for_updates( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, +) -> Result, MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let mut client = crate::imap::ImapClient::new(config.clone(), email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + client + .select_mailbox(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let password_clone = password.clone(); + let mailbox_clone = mailbox.clone(); + let email_clone = email.clone(); + + let (tx, rx) = tokio::sync::mpsc::channel::(32); + + let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1); + { + let mut cancels = IDLE_CANCELS.lock().await; + if let Some(old_tx) = cancels.remove(&email_clone) { + let _ = old_tx.send(()).await; + } + cancels.insert(email_clone.clone(), cancel_tx); + } + + tokio::task::spawn(async move { + loop { + tokio::select! { + _ = cancel_rx.recv() => { + tracing::debug!("IDLE loop canceled for {}", email_clone); + break; + } + res = client.wait_for_update(600) => { + match res { + Ok(true) => { + tracing::debug!("IDLE: New data detected for {}", email_clone); + if tx.send(true).await.is_err() { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + Ok(false) => { + tracing::debug!("IDLE: Timeout for {}, renewing...", email_clone); + } + Err(e) => { + tracing::error!("IDLE error for {}: {}", email_clone, e); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + if let Err(re_err) = client.reconnect(&password_clone).await { + tracing::error!("IDLE reconnect failed: {}", re_err); + break; + } + let _ = client.select_mailbox(&mailbox_clone).await; + } + } + } + } + } + }); + + Ok(rx) +} + +pub fn stop_idle_listener(email: String) { + let mut cancels = IDLE_CANCELS.blocking_lock(); + if let Some(tx) = cancels.remove(&email) { + tracing::debug!("Canceling existing IDLE listener for {}", email); + let _ = tx.try_send(()); + } +} + +pub async fn check_connection(email: String) -> bool { + crate::session_manager::is_connected(&email).await +} + +pub fn get_cached_messages( + storage_path: String, + mailbox: String, +) -> Result, MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + storage + .get_headers(&mailbox) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn save_draft(path: String, draft: crate::types::Draft) -> Result { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .save_draft(&draft) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn get_drafts(path: String) -> Result, MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .get_drafts() + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn delete_draft(path: String, id: i64) -> Result<(), MailError> { + let storage = + crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?; + storage + .delete_draft(id) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub async fn sync_drafts( + config: MailServerConfig, + email: String, + password: String, + storage_path: String, +) -> Result<(), MailError> { + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + let folders = client + .list_folders(false) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + let drafts_folder = folders + .iter() + .find(|f| f.folder_type == crate::types::FolderType::Drafts) + .map(|f| f.name.clone()) + .unwrap_or_else(|| "Drafts".to_string()); + + client + .select_mailbox(&drafts_folder) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + let server_headers = client.fetch_headers("1:*").await.unwrap_or_default(); + let server_uids: std::collections::HashSet = + server_headers.iter().map(|h| h.uid).collect(); + + let local_drafts = storage + .get_drafts() + .map_err(|e| MailError::Generic(e.to_string()))?; + + for mut draft in local_drafts { + let mut needs_upload = false; + + if let Some(uid) = draft.server_uid { + if !server_uids.contains(&uid) { + draft.server_uid = None; + draft.last_synced_at = 0; + needs_upload = true; + } else if draft.last_updated > draft.last_synced_at { + let _ = client.delete_message(&drafts_folder, uid).await; + draft.server_uid = None; + needs_upload = true; + } + } else { + needs_upload = true; + } + + if needs_upload { + let mut builder = mail_builder::MessageBuilder::new(); + builder = builder + .from(email.as_str()) + .to(draft.to.as_str()) + .subject(draft.subject.as_str()) + .text_body(draft.body.as_str()); + + for att in &draft.attachments { + builder = builder.attachment( + att.content_type.as_str(), + att.name.as_str(), + att.content.clone(), + ); + } + + let message_bytes = builder + .write_to_vec() + .map_err(|e| MailError::Generic(e.to_string()))?; + + if let Ok(_) = client + .append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string())) + .await + { + draft.last_synced_at = draft.last_updated; + let _ = storage.save_draft(&draft); + } + } + } + + let updated_local_drafts = storage + .get_drafts() + .map_err(|e| MailError::Generic(e.to_string()))?; + let local_uids: std::collections::HashSet = updated_local_drafts + .iter() + .filter_map(|d| d.server_uid) + .collect(); + + for header in server_headers { + if !local_uids.contains(&header.uid) { + if let Ok(body) = client.fetch_body(header.uid).await { + let new_draft = crate::types::Draft { + id: None, + to: header.to.join(", "), + subject: header.subject.clone(), + body: body.text_plain.unwrap_or_default(), + attachments: vec![], + last_updated: header.date, + last_synced_at: header.date, + server_uid: Some(header.uid), + }; + let _ = storage.save_draft(&new_draft); + } + } + } + + let _ = client.logout().await; + Ok(()) +} + +pub async fn move_message( + config: MailServerConfig, + email: String, + password: String, + from_mailbox: String, + to_mailbox: String, + uid: u32, + storage_path: String, +) -> Result<(), MailError> { + let from_mailbox = if from_mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + from_mailbox + }; + let to_mailbox = if to_mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + to_mailbox + }; + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let move_result = client + .move_message(uid, &from_mailbox, &to_mailbox) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + move_result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.delete_header(&from_mailbox, uid); + + Ok(()) +} + +pub async fn move_messages( + config: MailServerConfig, + email: String, + password: String, + from_mailbox: String, + to_mailbox: String, + uids: Vec, + storage_path: String, +) -> Result<(), MailError> { + let from_mailbox = if from_mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + from_mailbox + }; + let to_mailbox = if to_mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + to_mailbox + }; + if uids.is_empty() { + return Ok(()); + } + + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let move_result = client + .move_messages(&uids, &from_mailbox, &to_mailbox) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + move_result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.delete_headers(&from_mailbox, &uids); + + Ok(()) +} + +pub async fn mark_as_read( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + uid: u32, + storage_path: String, +) -> Result<(), MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let mark_result = client + .mark_as_read(&mailbox, uid) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + mark_result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.update_read_status(&mailbox, uid, true); + + Ok(()) +} + +pub async fn mark_as_unread( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + uid: u32, + storage_path: String, +) -> Result<(), MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let mark_result = client + .mark_as_unread(&mailbox, uid) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + mark_result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.update_read_status(&mailbox, uid, false); + + Ok(()) +} + +pub async fn mark_all_as_read( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + storage_path: String, +) -> Result<(), MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let mark_result = client + .mark_all_as_read(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + mark_result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.mark_all_as_read(&mailbox); + + Ok(()) +} + +pub async fn delete_all_messages( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + storage_path: String, +) -> Result<(), MailError> { + let mailbox = if mailbox.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + mailbox + }; + + let mut client = crate::imap::ImapClient::new(config, email.clone()); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let result = client + .delete_all_messages(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string())); + + let _ = client.logout().await; + + result?; + + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let _ = storage.wipe_folder(&mailbox); + + Ok(()) +} + +pub async fn create_folder( + config: MailServerConfig, + email: String, + password: String, + folder_name: String, +) -> Result<(), MailError> { + let folder_name = if folder_name.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + folder_name + }; + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + let result = client.create_folder(&folder_name).await.map_err(|e| { + MailError::Generic(e.to_string()) + }); + let _ = client.logout().await; + result +} + +pub async fn delete_folder( + config: MailServerConfig, + email: String, + password: String, + folder_name: String, + storage_path: String, +) -> Result<(), MailError> { + let folder_name = if folder_name.to_uppercase() == "INBOX" { + "INBOX".to_string() + } else { + folder_name + }; + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + let result = client.delete_folder(&folder_name).await.map_err(|e| { + MailError::Generic(e.to_string()) + }); + let _ = client.logout().await; + result?; + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + storage + .wipe_folder(&folder_name) + .map_err(|e| MailError::Generic(e.to_string()))?; + Ok(()) +} + +pub fn save_password(account: String, service: String, password: String) -> Result<(), MailError> { + crate::secrets::SecretManager::set_password(&account, &service, &password) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn get_password(account: String, service: String) -> Result, MailError> { + crate::secrets::SecretManager::get_password(&account, &service) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub fn delete_credential(account: String, service: String) -> Result<(), MailError> { + crate::secrets::SecretManager::delete_password(&account, &service) + .map_err(|e| MailError::Generic(e.to_string())) +} + +pub async fn load_older_messages( + config: MailServerConfig, + email: String, + password: String, + mailbox: String, + oldest_uid: u32, + batch_size: u32, + storage_path: String, +) -> Result, MailError> { + if oldest_uid == 0 { + return Ok(vec![]); + } + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + + let local_uids = storage.get_uids(&mailbox).unwrap_or_default(); + + let mut client = crate::imap::ImapClient::new(config, email); + client + .connect(&password) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + client + .select_mailbox(&mailbox) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let older_uids: Vec = client + .search_uids_below(oldest_uid) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + let mut to_fetch: Vec = older_uids + .into_iter() + .filter(|uid| !local_uids.contains(uid)) + .take(batch_size as usize) + .collect(); + + if to_fetch.is_empty() { + let _ = client.logout().await; + return Ok(vec![]); + } + + to_fetch.sort_unstable(); + let range = to_fetch + .iter() + .map(|u| u.to_string()) + .collect::>() + .join(","); + let headers = client + .fetch_headers(&range) + .await + .map_err(|e| MailError::Network(e.to_string()))?; + + let mut result = Vec::new(); + for header in headers { + let _ = storage.save_header(&mailbox, &header); + result.push(header); + } + + let _ = client.logout().await; + Ok(result) +} + +pub fn get_folder_stats( + email: String, + mailbox: String, + storage_path: String, +) -> Result { + let storage = crate::storage::Storage::new(&storage_path) + .map_err(|e| MailError::Generic(e.to_string()))?; + let (unread, server_total) = storage + .get_folder_stats(&email, &mailbox) + .map_err(|e| MailError::Generic(e.to_string()))?; + Ok(crate::types::FolderStats { + unread, + server_total, + }) +} diff --git a/mailcore/src/dns/mod.rs b/mailcore/src/dns/mod.rs new file mode 100644 index 0000000..df25738 --- /dev/null +++ b/mailcore/src/dns/mod.rs @@ -0,0 +1,2 @@ +pub mod srv; +pub use srv::discover_mail_server; diff --git a/mailcore/src/dns/srv.rs b/mailcore/src/dns/srv.rs new file mode 100644 index 0000000..037734a --- /dev/null +++ b/mailcore/src/dns/srv.rs @@ -0,0 +1,108 @@ +use crate::types::MailServerConfig; +use std::error::Error; +use trust_dns_resolver::proto::rr::RecordType; +use trust_dns_resolver::{ + config::{ResolverConfig, ResolverOpts}, + TokioAsyncResolver, +}; + +fn known_provider_config(domain: &str) -> Option { + match domain { + "gmx.net" | "gmx.com" | "gmx.de" | "gmx.at" | "gmx.ch" => Some(MailServerConfig { + imap_host: format!("imap.{}", domain), + imap_port: 993, + smtp_host: format!("mail.{}", domain), + smtp_port: 587, + }), + "gmail.com" | "googlemail.com" => Some(MailServerConfig { + imap_host: "imap.gmail.com".to_string(), + imap_port: 993, + smtp_host: "smtp.gmail.com".to_string(), + smtp_port: 587, + }), + "yahoo.com" | "ymail.com" | "yahoo.co.uk" | "yahoo.co.in" => Some(MailServerConfig { + imap_host: "imap.mail.yahoo.com".to_string(), + imap_port: 993, + smtp_host: "smtp.mail.yahoo.com".to_string(), + smtp_port: 587, + }), + "outlook.com" | "hotmail.com" | "live.com" | "msn.com" => Some(MailServerConfig { + imap_host: "outlook.office365.com".to_string(), + imap_port: 993, + smtp_host: "smtp.office365.com".to_string(), + smtp_port: 587, + }), + "icloud.com" | "me.com" | "mac.com" => Some(MailServerConfig { + imap_host: "imap.mail.me.com".to_string(), + imap_port: 993, + smtp_host: "smtp.mail.me.com".to_string(), + smtp_port: 587, + }), + _ => None, + } +} + +pub async fn discover_mail_server(email: &str) -> Result> { + let domain = email.split('@').nth(1).ok_or("Invalid email")?; + + // Check well-known providers first to avoid slow DNS fallbacks + if let Some(config) = known_provider_config(domain) { + return Ok(config); + } + + // Create async resolver + // Note: TokioAsyncResolver::tokio constructor is used for tokio runtime + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); + + // Try IMAPS SRV (_imaps._tcp.domain.com) + let imap_srv = format!("_imaps._tcp.{}", domain); + let imap_config = match resolver.lookup(&imap_srv, RecordType::SRV).await { + Ok(response) => { + let srv = response.iter().next().ok_or("No SRV records")?; + if let Some(srv_data) = srv.as_srv() { + ( + srv_data + .target() + .to_string() + .trim_end_matches('.') + .to_string(), + srv_data.port(), + ) + } else { + (domain.to_string(), 993) // Fallback + } + } + Err(_) => { + // No SRV, use standard port + (domain.to_string(), 993) + } + }; + + // Try SMTP Submission SRV (_submission._tcp.domain.com) + let smtp_srv = format!("_submission._tcp.{}", domain); + let smtp_config = match resolver.lookup(&smtp_srv, RecordType::SRV).await { + Ok(response) => { + let srv = response.iter().next().ok_or("No SRV records")?; + if let Some(srv_data) = srv.as_srv() { + ( + srv_data + .target() + .to_string() + .trim_end_matches('.') + .to_string(), + srv_data.port(), + ) + } else { + (domain.to_string(), 587) // Fallback + } + } + Err(_) => (domain.to_string(), 587), + }; + + Ok(MailServerConfig { + imap_host: imap_config.0, + imap_port: imap_config.1, + smtp_host: smtp_config.0, + smtp_port: smtp_config.1, + }) +} diff --git a/mailcore/src/imap/client.rs b/mailcore/src/imap/client.rs new file mode 100644 index 0000000..93b0fcf --- /dev/null +++ b/mailcore/src/imap/client.rs @@ -0,0 +1,1210 @@ +use crate::types::{FolderInfo, FolderType, MailServerConfig}; +use async_imap::extensions::idle::IdleResponse; +use async_imap::Client; +use futures::stream::TryStreamExt; +use mail_parser::MimeHeaders; +use std::error::Error; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio_rustls::client::TlsStream; +use tokio_rustls::TlsConnector; + +/// Returned by `ImapClient::sync_mailbox`. Contains data needed for post-sync operations +/// that must run outside the shared session lock. +pub struct SyncResult { + /// UIDs of messages already in local cache that need their FLAGS refreshed from the server. + /// The caller should run `refresh_flags` on a dedicated connection after releasing the + /// shared session mutex. + pub uids_for_flag_refresh: Vec, + /// Newly-fetched message headers (same as the previous `Vec` return value). + pub new_headers: Vec, +} + +pub struct ImapClient { + session: Option>>, + config: MailServerConfig, + email: String, +} + +impl ImapClient { + pub fn new(config: MailServerConfig, email: String) -> Self { + Self { + session: None, + config, + email, + } + } + + pub async fn connect(&mut self, password: &str) -> Result<(), Box> { + tracing::debug!( + "ImapClient: Starting connection to {}:{}", + self.config.imap_host, + self.config.imap_port + ); + + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + tracing::debug!("ImapClient: Loading native certs..."); + // Load native certs + match rustls_native_certs::load_native_certs() { + Ok(certs) => { + for cert in certs { + let _ = root_cert_store.add(cert); + } + } + Err(e) => { + tracing::warn!("ImapClient: Failed to load native certs: {}", e); + } + } + + tracing::debug!("ImapClient: Building TLS config..."); + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|e| format!("Failed to set TLS protocol versions: {}", e))? + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + + let dns_name = self.config.imap_host.clone(); + let port = self.config.imap_port; + + let addr = format!("{}:{}", dns_name, port); + tracing::debug!("ImapClient: Connecting to TCP stream at {}...", addr); + let stream = tokio::time::timeout(Duration::from_secs(15), TcpStream::connect(&addr)) + .await + .map_err(|_| format!("Connection to {} timed out after 15 seconds", addr))? + .map_err(|e| format!("TCP connection to {} failed: {}", addr, e))?; + + // Establish TLS + let server_name = rustls_pki_types::ServerName::try_from(dns_name.clone()) + .map_err(|_| format!("Invalid DNS name: {}", dns_name))? + .to_owned(); + + tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name); + let tls_stream = connector.connect(server_name, stream).await?; + + // Create IMAP client + tracing::debug!("ImapClient: Creating async_imap client..."); + let client = Client::new(tls_stream); + + // Login + tracing::debug!("ImapClient: Attempting login for {}...", self.email); + let session = client + .login(&self.email, password) + .await + .map_err(|(e, _client)| { + tracing::error!("ImapClient: Login failed: {}", e); + e.to_string() + })?; + + tracing::debug!("ImapClient: Successfully logged in as {}", self.email); + self.session = Some(session); + Ok(()) + } + + pub async fn reconnect(&mut self, password: &str) -> Result<(), Box> { + // Force close existing session if any + if let Some(mut session) = self.session.take() { + let _ = session.logout().await; + } + self.connect(password).await + } + + pub async fn logout(&mut self) -> Result<(), Box> { + if let Some(mut session) = self.session.take() { + session.logout().await.map_err(|e| e.to_string())?; + } + Ok(()) + } + + /// Checks connection health by sending a NOOP command. + /// Returns true if healthy, false if broken. + pub async fn keep_alive(&mut self) -> bool { + if let Some(session) = self.session.as_mut() { + match session.noop().await { + Ok(_) => true, + Err(e) => { + tracing::warn!("Session check failed for {}: {}", self.email, e); + false + } + } + } else { + false + } + } + + /// Use this before any operation to ensure we have a valid session. + pub async fn ensure_connected( + &mut self, + password: &str, + ) -> Result<(), Box> { + if !self.keep_alive().await { + tracing::debug!("Reconnecting session for {}", self.email); + self.reconnect(password).await?; + } + Ok(()) + } + + pub async fn list_folders( + &mut self, + show_all: bool, + ) -> Result, Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + + let mut mailboxes = session + .list(Some(""), Some("*")) + .await + .map_err(|e| e.to_string())?; + + let mut all_data = Vec::new(); + while let Some(mailbox) = mailboxes.try_next().await.map_err(|e| e.to_string())? { + let mut is_selectable = true; + let mut special_use_type: Option = None; + + // Check for special-use attributes (RFC 6154) which are reliable indicators + for attr in mailbox.attributes() { + match attr { + async_imap::types::NameAttribute::NoSelect => is_selectable = false, + async_imap::types::NameAttribute::All => { + special_use_type = Some(FolderType::Archive) + } + async_imap::types::NameAttribute::Drafts => { + special_use_type = Some(FolderType::Drafts) + } + async_imap::types::NameAttribute::Flagged => {} // Not a standard folder type for us + async_imap::types::NameAttribute::Junk => { + special_use_type = Some(FolderType::Junk) + } + async_imap::types::NameAttribute::Sent => { + special_use_type = Some(FolderType::Sent) + } + async_imap::types::NameAttribute::Trash => { + special_use_type = Some(FolderType::Trash) + } + async_imap::types::NameAttribute::Archive => { + special_use_type = Some(FolderType::Archive) + } + _ => {} + } + } + + // Keep the raw IMAP name for SELECT/EXAMINE commands. + let imap_name = mailbox.name().to_string(); + // Decode RFC 3501 modified UTF-7 folder names (for Cyrillic, Arabic, CJK, etc.) + let folder_name = super::utf7::decode_modified_utf7(mailbox.name()); + all_data.push((imap_name, folder_name, is_selectable, special_use_type)); + } + + // 1. Map to FolderInfo and determine type + let mut folders = Vec::new(); + for (imap_name, name, is_selectable, special_use_type) in all_data { + let upper = name.to_uppercase(); + let parts: Vec<&str> = upper.split('/').collect(); + let last_part = parts.last().cloned().unwrap_or(""); + + let (folder_type, canonical_name, canonical_imap_name) = if let Some(sut) = + special_use_type + { + // RFC 6154 special-use attribute takes priority + (sut, name, imap_name) + } else if upper == "INBOX" { + (FolderType::Inbox, "INBOX".to_string(), "INBOX".to_string()) + } else if last_part == "SENT" + || last_part == "SENT MAIL" + || last_part == "SENT MESSAGES" + || last_part == "SENT ITEMS" + { + (FolderType::Sent, name, imap_name) + } else if last_part == "DRAFTS" || last_part == "DRAFT" { + (FolderType::Drafts, name, imap_name) + } else if last_part == "TRASH" + || last_part == "BIN" + || last_part == "DELETED" + || last_part == "DELETED ITEMS" + || last_part == "DELETED MESSAGES" + { + (FolderType::Trash, name, imap_name) + } else if last_part == "JUNK" || last_part == "SPAM" || last_part == "JUNK EMAIL" { + (FolderType::Junk, name, imap_name) + } else if last_part == "ARCHIVE" || last_part == "ARCHIVES" || last_part == "ALL MAIL" { + (FolderType::Archive, name, imap_name) + } else { + (FolderType::Custom, name, imap_name) + }; + + // Always include all folders; deduplication logic below handles standard folder duplicates + folders.push(FolderInfo { + name: canonical_name, + imap_name: canonical_imap_name, + folder_type, + is_selectable, + unread_count: 0, + }); + } + + // 2. Multi-standard-folder deduplication (e.g. Gmail's [Gmail]/Sent vs Sent) + // If multiple folders have the same non-Custom type, only keep the "best" one + let mut best_typed_folders: std::collections::HashMap = + std::collections::HashMap::new(); + + for (i, folder) in folders.iter().enumerate() { + if folder.folder_type == FolderType::Custom { + continue; + } + + let entry = best_typed_folders + .entry(folder.folder_type.clone()) + .or_insert(i); + let current_best_name = &folders[*entry].name; + + // Heuristic: prefer shorter names or non-prefixed names for the "primary" type + if folder.name.len() < current_best_name.len() { + *entry = i; + } + } + + // Build final list: all custom folders + only the chosen primary ones + let mut final_folders = Vec::new(); + let best_indices: std::collections::HashSet = + best_typed_folders.values().cloned().collect(); + + for (i, folder) in folders.into_iter().enumerate() { + if folder.folder_type == FolderType::Custom { + final_folders.push(folder); + } else if best_indices.contains(&i) { + final_folders.push(folder); + } else if show_all { + // Keep the duplicate as a Custom folder if show_all is on + final_folders.push(FolderInfo { + name: folder.name, + imap_name: folder.imap_name, + folder_type: FolderType::Custom, + is_selectable: folder.is_selectable, + unread_count: 0, + }); + } + } + + Ok(final_folders) + } + + pub async fn select_mailbox( + &mut self, + mailbox: &str, + ) -> Result<(u32, u32), Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + let mb = session.select(mailbox).await.map_err(|e| e.to_string())?; + Ok((mb.uid_validity.unwrap_or(0), mb.exists)) + } + + /// Search for UIDs strictly below `below_uid`. Returns results sorted descending (newest first). + pub async fn search_uids_below( + &mut self, + below_uid: u32, + ) -> Result, Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + let query = format!("UID 1:{}", below_uid - 1); + let mut uids: Vec = session + .uid_search(&query) + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + uids.sort_unstable_by(|a, b| b.cmp(a)); + Ok(uids) + } + + pub async fn fetch_headers( + &mut self, + range: &str, + ) -> Result, Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + + // Use BODY.PEEK[HEADER] instead of full BODY.PEEK[] to avoid massive memory/bandwidth usage + let mut fetches = session + .uid_fetch(range, "(UID FLAGS BODY.PEEK[HEADER])") + .await + .map_err(|e| e.to_string())?; + + let mut headers = Vec::new(); + while let Ok(Some(fetch)) = fetches.try_next().await { + let uid = match fetch.uid { + Some(u) => u, + None => { + tracing::warn!("Fetch response missing UID, skipping message"); + continue; + } + }; + + // Use .header() if available, otherwise .body() as fallback + let full_message_bytes = fetch + .header() + .unwrap_or_else(|| fetch.body().unwrap_or(&[])); + if full_message_bytes.is_empty() { + continue; + } + + let preprocessed = preprocess_header_bytes(full_message_bytes); + let parsed_opt = mail_parser::MessageParser::default().parse(&preprocessed); + + let mut from = "Unknown".to_string(); + let mut subject = "No Subject".to_string(); + let mut date = 0; + let mut to = Vec::new(); + + let mut has_attachments = false; + let mut pgp_status = crate::types::PgpStatus::None; + + if let Some(message) = parsed_opt { + from = message + .from() + .and_then(|fl| match fl { + mail_parser::Address::List(l) => l.first().cloned(), + mail_parser::Address::Group(g) => g + .iter() + .flat_map(|group| group.addresses.iter()) + .next() + .cloned(), + }) + .map(|addr| { + let name = addr.name().unwrap_or("").trim(); + let address = addr.address().unwrap_or("").trim(); + if name.is_empty() { + address.to_string() + } else { + format!("{} <{}>", name, address) + } + }) + .unwrap_or_else(|| "Unknown".to_string()); + + subject = message.subject().unwrap_or("No Subject").to_string(); + date = message.date().map(|dt| dt.to_timestamp()).unwrap_or(0); + + if let Some(to_addr) = message.to() { + match to_addr { + mail_parser::Address::List(l) => { + for addr in l { + let name = addr.name().unwrap_or("").trim(); + let address = addr.address().unwrap_or("").trim(); + if name.is_empty() { + to.push(address.to_string()); + } else { + to.push(format!("{} <{}>", name, address)); + } + } + } + mail_parser::Address::Group(groups) => { + for group in groups { + for addr in &group.addresses { + let name = addr.name().unwrap_or("").trim(); + let address = addr.address().unwrap_or("").trim(); + if name.is_empty() { + to.push(address.to_string()); + } else { + to.push(format!("{} <{}>", name, address)); + } + } + } + } + } + } + + if let Some(ct) = message.content_type() { + let ctype = ct.ctype(); + let subtype = ct.subtype().unwrap_or(""); + if ctype == "multipart" && subtype == "signed" { + pgp_status = crate::types::PgpStatus::Signed; + } else if ctype == "multipart" && subtype == "encrypted" { + pgp_status = crate::types::PgpStatus::Encrypted; + } else if ctype == "multipart" && subtype == "mixed" { + has_attachments = true; + } + } + } + + let mut message_flag: Option = None; + for f in fetch.flags() { + match f { + async_imap::types::Flag::Flagged => { + if message_flag.is_none() { + message_flag = Some("red".to_string()); + } + } + async_imap::types::Flag::Custom(c) => { + let c_lower = c.to_lowercase(); + if c_lower == "korax-flag-red" { + message_flag = Some("red".to_string()); + } else if c_lower == "korax-flag-orange" { + message_flag = Some("orange".to_string()); + } else if c_lower == "korax-flag-yellow" { + message_flag = Some("yellow".to_string()); + } else if c_lower == "korax-flag-green" { + message_flag = Some("green".to_string()); + } else if c_lower == "korax-flag-blue" { + message_flag = Some("blue".to_string()); + } + } + _ => {} + } + } + + headers.push(crate::types::MessageHeader { + uid, + subject, + from, + to, + date, + is_read: fetch + .flags() + .any(|f| matches!(f, async_imap::types::Flag::Seen)), + has_attachments, + pgp_status, + flag: message_flag, + }); + } + + Ok(headers) + } + + pub async fn fetch_body_raw( + &mut self, + uid: u32, + ) -> Result, Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + + let mut fetches = session + .uid_fetch(uid.to_string(), "(BODY.PEEK[])") + .await + .map_err(|e| e.to_string())?; + + while let Some(fetch) = fetches.try_next().await.map_err(|e| e.to_string())? { + // Ensure this is the message we want (though UID fetch acts as filter) + if let Some(fetched_uid) = fetch.uid { + if fetched_uid != uid { + continue; + } + } + + let body = fetch.body().ok_or("No body")?; + return Ok(body.to_vec()); + } + + Err(format!("Message with UID {} not found on server", uid).into()) + } + + pub async fn fetch_body( + &mut self, + uid: u32, + ) -> Result> { + let raw_body = self.fetch_body_raw(uid).await?; + + // Parse with mail-parser + let message = mail_parser::MessageParser::default() + .parse(&raw_body) + .ok_or("Failed to parse MIME")?; + + let mut attachments = Vec::new(); + for part in message.attachments() { + let content_type = part + .content_type() + .map(|ct| { + let ctype = ct.ctype(); + let subtype = ct.subtype(); + format!("{}/{}", ctype, subtype.unwrap_or("")) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + attachments.push(crate::types::Attachment { + name: part.attachment_name().unwrap_or("unnamed").to_string(), + content_type, + size: part.contents().len(), + content: part.contents().to_vec(), + }); + } + + let from_addr = message.from().and_then(|f| { + f.iter() + .next() + .map(|a| a.address().unwrap_or("").to_string()) + }); + + Ok(crate::types::MessageBody { + uid, + from: from_addr, + text_plain: message.body_text(0).map(|s| s.to_string()), + text_html: message.body_html(0).map(|s| s.to_string()), + attachments, + pgp_status: crate::types::PgpStatus::None, + signature_fingerprint: None, + }) + } + + pub async fn sync_mailbox( + &mut self, + mailbox: &str, + storage: &crate::storage::Storage, + cache_days: i64, + ) -> Result> { + let (current_validity, mailbox_exists) = self.select_mailbox(mailbox).await?; + + // 1. Get stored state & known UIDs + let state = storage.get_sync_state(&self.email, mailbox)?; + + let last_known_uid = if let Some((stored_validity, stored_last_uid)) = state { + if stored_validity == current_validity { + stored_last_uid + } else { + // UIDVALIDITY changed: existing UIDs now refer to different messages. + // Wipe the local cache before re-syncing from scratch. + storage.wipe_folder(mailbox)?; + 0 + } + } else { + 0 + }; + + let local_uids = storage.get_uids(mailbox).unwrap_or_default(); + + // 2. Fetch server UIDs + let session = self.session.as_mut().ok_or("Not connected")?; + + let mut server_uids: std::collections::HashSet = std::collections::HashSet::new(); + let unseen_uids: std::collections::HashSet; + let mut min_since_uid = 0; + + if cache_days > 0 { + // Priority 1: Recent messages (e.g. last 30 days) + let since_date = chrono::Utc::now() - chrono::Duration::days(cache_days); + let since_query = format!("SINCE {}", since_date.format("%d-%b-%Y")); + let since_uids: std::collections::HashSet = session + .uid_search(&since_query) + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + + min_since_uid = since_uids.iter().cloned().min().unwrap_or(0); + server_uids.extend(since_uids.clone()); + + // Priority 2: All unread messages (regardless of age) + unseen_uids = session + .uid_search("UNSEEN") + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + server_uids.extend(unseen_uids.clone()); + } else { + // Fetch everything + let all_uids: std::collections::HashSet = session + .uid_search("ALL") + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + server_uids.extend(all_uids); + + // Still fetch unseen_uids to maintain prioritization in the sync chunk + unseen_uids = session + .uid_search("UNSEEN") + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect(); + }; + + // 3. Find missing UIDs + let mut missing_unseen: Vec = server_uids + .iter() + .cloned() + .filter(|&uid| !local_uids.contains(&uid) && unseen_uids.contains(&uid)) + .collect(); + missing_unseen.sort_unstable(); + + let mut missing_since_read: Vec = server_uids + .iter() + .cloned() + .filter(|&uid| !local_uids.contains(&uid) && !unseen_uids.contains(&uid)) + .collect(); + missing_since_read.sort_unstable(); + + // 4. Chunk missing UIDs (LIMIT to 500 total, but prioritize UNSEEN) + // This ensures the unread count is corrected immediately even if there's a huge backlog. + let max_chunk_size = 500; + let mut uids_to_fetch = Vec::new(); + + // First, add all missing unseen messages (up to the limit) + for uid in missing_unseen { + if uids_to_fetch.len() >= max_chunk_size { + break; + } + uids_to_fetch.push(uid); + } + + // Then fill remaining space with the NEWEST missing "Since" (read) messages. + // We take from the end of missing_since_read because higher UIDs are newer. + while uids_to_fetch.len() < max_chunk_size && !missing_since_read.is_empty() { + if let Some(uid) = missing_since_read.pop() { + uids_to_fetch.push(uid); + } + } + + let mut new_headers = Vec::new(); + + if !uids_to_fetch.is_empty() { + // Sort for optimal IMAP fetch range building (ascending) + uids_to_fetch.sort_unstable(); + let search_range = uids_to_fetch + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + + tracing::debug!("Syncing {} with range: {}", mailbox, search_range); + let headers = self.fetch_headers(&search_range).await?; + + // Save & Update State for new headers + let mut max_uid = last_known_uid; + for header in headers { + if header.uid > max_uid { + max_uid = header.uid; + } + if header.uid > last_known_uid { + new_headers.push(header.clone()); + } + storage.save_header(mailbox, &header)?; + } + + // Update last UID if we found new ones + if max_uid > last_known_uid { + storage.update_sync_state(&self.email, mailbox, current_validity, max_uid)?; + } + } + + // 4.5 (FLAG refresh) has been removed from this method. The caller is responsible for + // running `refresh_flags` on a dedicated connection after releasing the shared session + // mutex. The UIDs to refresh are returned in `SyncResult::uids_for_flag_refresh`. + let uids_for_flag_refresh: Vec = + local_uids.intersection(&server_uids).cloned().collect(); + + // 4.6: Minimum fill — if the windowed sync leaves fewer than MIN_FILL messages + // in the local cache, backfill with the most recent server messages we don't + // have yet. This prevents a near-empty view when most of the inbox predates + // cache_days (e.g. an account with 3 000+ old read messages and only 2 recent ones). + // Fill headers are saved to storage but NOT added to new_headers so the polling + // service doesn't treat them as newly arrived. + const MIN_FILL: usize = 50; + if cache_days > 0 && (local_uids.len() + new_headers.len()) < MIN_FILL { + let needed = MIN_FILL - (local_uids.len() + new_headers.len()); + let already_have: std::collections::HashSet = local_uids + .iter() + .cloned() + .chain(new_headers.iter().map(|h| h.uid)) + .collect(); + + let mut all_server_uids: Vec = { + let s = self.session.as_mut().ok_or("Not connected")?; + s.uid_search("ALL") + .await + .map_err(|e| e.to_string())? + .into_iter() + .collect() + }; + all_server_uids.sort_unstable_by(|a, b| b.cmp(a)); // newest first + + let mut to_fill: Vec = all_server_uids + .into_iter() + .filter(|uid| !already_have.contains(uid)) + .take(needed) + .collect(); + + if !to_fill.is_empty() { + to_fill.sort_unstable(); // ascending for optimal IMAP range building + let range = to_fill + .iter() + .map(|u| u.to_string()) + .collect::>() + .join(","); + tracing::debug!( + "Minimum fill: fetching {} headers for {}", + to_fill.len(), + mailbox + ); + let fill_headers = self.fetch_headers(&range).await?; + for header in fill_headers { + storage.save_header(mailbox, &header)?; + } + } + } + + // 5. Pruning: Remove local messages within the search range that no longer exist on server + tracing::debug!("Pruning {}...", mailbox); + let local_headers = storage.get_headers(mailbox).map_err(|e| e.to_string())?; + + for local in local_headers { + // Safety: only prune if we are sure we searched the range where this message resides + let should_be_present = if cache_days > 0 { + // Important: It must either be within the SINCE window (local.uid >= min_since_uid) + // OR it was previously locally unread (so we expected to find it in the UNSEEN search). + // If it meets either criteria but it is NOT in server_uids, it has been permanently deleted + // or marked as read outside the sync window. + (local.uid >= min_since_uid && min_since_uid > 0) || !local.is_read + } else { + // ALL search, so for every local UID it MUST be in the server set or it's gone + true + }; + + if should_be_present && !server_uids.contains(&local.uid) { + tracing::debug!("Pruning UID {} from {}", local.uid, mailbox); + let _ = storage.delete_header(mailbox, local.uid); + } + } + + // Store server total so the UI can show accurate counts + let _ = storage.update_server_total(&self.email, mailbox, mailbox_exists); + + Ok(SyncResult { + uids_for_flag_refresh, + new_headers, + }) + } + + /// Fetches FLAGS for a set of already-cached UIDs and updates local read-status in storage. + /// + /// This is the extracted step 4.5 from `sync_mailbox`. It must be called on a **dedicated** + /// `ImapClient` (not the shared session) so it does not block other callers while issuing + /// potentially many sequential `UID FETCH … FLAGS` round-trips. + pub async fn refresh_flags( + &mut self, + mailbox: &str, + uids: &[u32], + storage: &crate::storage::Storage, + ) -> Result<(), Box> { + if uids.is_empty() { + return Ok(()); + } + + self.select_mailbox(mailbox).await?; + + for chunk in uids.chunks(200) { + let range = chunk + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + + let session = self.session.as_mut().ok_or("Not connected")?; + let mut fetches = session + .uid_fetch(&range, "FLAGS") + .await + .map_err(|e| e.to_string())?; + + loop { + match fetches.try_next().await { + Ok(Some(fetch)) => { + if let Some(uid) = fetch.uid { + let is_read = fetch + .flags() + .any(|f| matches!(f, async_imap::types::Flag::Seen)); + let _ = storage.update_read_status(mailbox, uid, is_read); + } + } + Ok(None) => break, + Err(e) => { + tracing::warn!("FLAG refresh fetch error: {}", e); + break; + } + } + } + } + + Ok(()) + } + + pub async fn create_folder( + &mut self, + folder_name: &str, + ) -> Result<(), Box> { + eprintln!("[ImapClient::create_folder] folder_name={folder_name}"); + let session = self.session.as_mut().ok_or("Not connected")?; + session.create(folder_name).await.map_err(|e| { + let msg = e.to_string(); + eprintln!("[ImapClient::create_folder] ERROR: {msg}"); + msg + })?; + eprintln!("[ImapClient::create_folder] session.create returned"); + // Best-effort subscribe so folder appears on servers that use LSUB + let _ = session.subscribe(folder_name).await; + Ok(()) + } + + pub async fn delete_folder( + &mut self, + folder_name: &str, + ) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + let _ = session.unsubscribe(folder_name).await; // best-effort + session + .delete(folder_name) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn move_message( + &mut self, + uid: u32, + from_mailbox: &str, + to_mailbox: &str, + ) -> Result<(), Box> { + self.select_mailbox(from_mailbox).await?; + + let session = self.session.as_mut().ok_or("Not connected")?; + + // Move the message + session + .uid_mv(uid.to_string(), to_mailbox) + .await + .map_err(|e| e.to_string())?; + + // Expunge to finalize deletion from source + let _ = session + .expunge() + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn move_messages( + &mut self, + uids: &[u32], + from_mailbox: &str, + to_mailbox: &str, + ) -> Result<(), Box> { + if uids.is_empty() { + return Ok(()); + } + self.select_mailbox(from_mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + let uid_set: String = uids + .iter() + .map(|u| u.to_string()) + .collect::>() + .join(","); + session + .uid_mv(&uid_set, to_mailbox) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn mark_as_read( + &mut self, + mailbox: &str, + uid: u32, + ) -> Result<(), Box> { + self.select_mailbox(mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + + // Add \Seen flag + session + .uid_store(uid.to_string(), "+FLAGS (\\Seen)") + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn mark_as_unread( + &mut self, + mailbox: &str, + uid: u32, + ) -> Result<(), Box> { + self.select_mailbox(mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + + // Remove \Seen flag + session + .uid_store(uid.to_string(), "-FLAGS (\\Seen)") + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn mark_all_as_read( + &mut self, + mailbox: &str, + ) -> Result<(), Box> { + self.select_mailbox(mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + + session + .uid_store("1:*", "+FLAGS (\\Seen)") + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn set_message_flag( + &mut self, + mailbox: &str, + uid: u32, + flag: Option, + ) -> Result<(), Box> { + self.select_mailbox(mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + + let to_remove = "-FLAGS (\\Flagged korax-flag-red korax-flag-orange korax-flag-yellow korax-flag-green korax-flag-blue)"; + let _ = session + .uid_store(uid.to_string(), to_remove) + .await? + .try_collect::>() + .await?; + + if let Some(f) = flag { + let to_add = match f.as_str() { + "red" => "+FLAGS (\\Flagged korax-flag-red)", + "orange" => "+FLAGS (\\Flagged korax-flag-orange)", + "yellow" => "+FLAGS (\\Flagged korax-flag-yellow)", + "green" => "+FLAGS (\\Flagged korax-flag-green)", + "blue" => "+FLAGS (\\Flagged korax-flag-blue)", + _ => return Err("Invalid flag".into()), + }; + let _ = session + .uid_store(uid.to_string(), to_add) + .await? + .try_collect::>() + .await?; + } + Ok(()) + } + + /// Waits for an update from the server using IDLE. + /// Returns true if new data is available, false on timeout. + pub async fn wait_for_update( + &mut self, + timeout_secs: u64, + ) -> Result> { + let session = self.session.take().ok_or("Not connected")?; + + // Transition to IDLE state + let mut handle = session.idle(); + handle.init().await.map_err(|e| e.to_string())?; + + // Wait for update or timeout + // IMAP IDLE usually recommends a 29-minute timeout, but we allow shorter for polling synergy + let (wait_fut, _stop_source) = handle.wait_with_timeout(Duration::from_secs(timeout_secs)); + let result = wait_fut.await; + + // Transition back to normal session + self.session = Some(handle.done().await.map_err(|e| e.to_string())?); + + match result { + Ok(IdleResponse::NewData(_)) => Ok(true), + Ok(IdleResponse::Timeout) | Ok(IdleResponse::ManualInterrupt) => { + tracing::debug!("IDLE timed out or interrupted for {}", self.email); + Ok(false) + } + Err(e) => Err(format!("IDLE error: {}", e).into()), + } + } + pub async fn append_message( + &mut self, + mailbox: &str, + content: &[u8], + _flags: Option, + ) -> Result<(), Box> { + let session = self.session.as_mut().ok_or("Not connected")?; + session + .append(mailbox, content) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + pub async fn delete_all_messages( + &mut self, + mailbox: &str, + ) -> Result<(), Box> { + self.select_mailbox(mailbox).await?; + let session = self.session.as_mut().ok_or("Not connected")?; + + // Mark every message as deleted + session + .uid_store("1:*", "+FLAGS (\\Deleted)") + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + // Expunge to permanently remove + session + .expunge() + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } + + pub async fn delete_message( + &mut self, + mailbox: &str, + uid: u32, + ) -> Result<(), Box> { + self.select_mailbox(mailbox) + .await + .map_err(|e| e.to_string())?; + let session = self.session.as_mut().ok_or("Not connected")?; + + // 1. Mark as deleted + let _ = session + .uid_store(uid.to_string(), "+FLAGS (\\Deleted)") + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + // 2. Expunge to permanently remove + let _ = session + .expunge() + .await + .map_err(|e| e.to_string())? + .try_collect::>() + .await + .map_err(|e| e.to_string())?; + + Ok(()) + } +} + +pub fn preprocess_header_bytes(bytes: &[u8]) -> Vec { + // Convert to string; if not UTF-8 leave untouched. + let s = match std::str::from_utf8(bytes) { + Ok(s) => s, + Err(_) => return bytes.to_vec(), + }; + + // Join adjacent RFC 2047 encoded-words that share the same charset and + // transfer-encoding. Many mailers split multi-byte UTF-8 sequences across + // word boundaries, which confuses mail-parser. + // + // A word has the form: =?CHARSET?ENCODING?PAYLOAD?= + // Two adjacent words (possibly separated by whitespace) can be merged into: + // =?CHARSET?ENCODING?PAYLOAD1PAYLOAD2?= + let result = join_adjacent_encoded_words(s); + result.into_bytes() +} + +/// Parse one RFC 2047 encoded word starting at `s`. +/// Returns `(charset, encoding, payload, remaining)` on success. +fn parse_encoded_word(s: &str) -> Option<(&str, &str, &str, &str)> { + let s = s.strip_prefix("=?")?; + let (charset, rest) = s.split_once('?')?; + let (encoding, rest) = rest.split_once('?')?; + // encoding must be a single B or Q (case-insensitive) + if encoding.len() != 1 { + return None; + } + let enc_char = encoding.chars().next()?.to_ascii_uppercase(); + if enc_char != 'B' && enc_char != 'Q' { + return None; + } + let end = rest.find("?=")?; + let payload = &rest[..end]; + let remaining = &rest[end + 2..]; + Some((charset, encoding, payload, remaining)) +} + +fn join_adjacent_encoded_words(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut pos = 0; + + while pos < s.len() { + let remaining = &s[pos..]; + + if !remaining.starts_with("=?") { + // Not an encoded word – emit one char and advance. + let ch = remaining.chars().next().unwrap(); + out.push(ch); + pos += ch.len_utf8(); + continue; + } + + // Try to parse an encoded word. + match parse_encoded_word(remaining) { + None => { + // Looks like "=?" but is not a valid encoded word. + out.push('='); + pos += 1; + } + Some((charset, encoding, payload, after_first)) => { + // Accumulate adjacent words with matching charset+encoding. + let mut combined_payload = payload.to_string(); + let mut cursor = after_first; + + loop { + // Skip optional linear whitespace between adjacent words, including header folding (CRLF). + let ws_stripped = cursor.trim_start_matches(|c: char| c.is_whitespace()); + + if !ws_stripped.starts_with("=?") { + break; + } + + match parse_encoded_word(ws_stripped) { + None => break, + Some((cs2, enc2, payload2, after_next)) => { + // Only merge if charset AND encoding match (case-insensitive). + if cs2.eq_ignore_ascii_case(charset) + && enc2.eq_ignore_ascii_case(encoding) + { + combined_payload.push_str(payload2); + cursor = after_next; + } else { + break; + } + } + } + } + + // Emit merged word. + out.push_str("=?"); + out.push_str(charset); + out.push('?'); + out.push_str(encoding); + out.push('?'); + out.push_str(&combined_payload); + out.push_str("?="); + + // Advance pos past everything we consumed. + pos += remaining.len() - cursor.len(); + } + } + } + + out +} diff --git a/mailcore/src/imap/mod.rs b/mailcore/src/imap/mod.rs new file mode 100644 index 0000000..e7ba587 --- /dev/null +++ b/mailcore/src/imap/mod.rs @@ -0,0 +1,3 @@ +pub mod client; +pub mod utf7; +pub use client::ImapClient; diff --git a/mailcore/src/imap/utf7.rs b/mailcore/src/imap/utf7.rs new file mode 100644 index 0000000..e633c7a --- /dev/null +++ b/mailcore/src/imap/utf7.rs @@ -0,0 +1,215 @@ +/// RFC 3501 modified UTF-7 decoder for IMAP mailbox names +/// +/// Modified UTF-7 (RFC 3501) encoding rules: +/// - Printable US-ASCII characters (0x20-0x7e, except "&") are represented by themselves +/// - "&" is represented by "&-" +/// - All other characters are represented as "&" + modified-BASE64(UTF-16BE) + "-" +/// - Modified BASE64 uses "A-Za-z0-9,+" instead of "A-Za-z0-9+/" + +pub fn decode_modified_utf7(encoded: &str) -> String { + let bytes = encoded.as_bytes(); + let mut result = String::new(); + let mut i = 0; + + while i < bytes.len() { + let byte = bytes[i]; + + // Printable ASCII (except '&') - pass through + if byte != b'&' && byte >= 0x20 && byte <= 0x7e { + result.push(byte as char); + i += 1; + continue; + } + + // "&" starts a sequence + if byte == b'&' { + if i + 1 < bytes.len() && bytes[i + 1] == b'-' { + // "&-" represents literal "&" + result.push('&'); + i += 2; + continue; + } + + // Find the closing "-" + let mut j = i + 1; + while j < bytes.len() && bytes[j] != b'-' { + j += 1; + } + + if j >= bytes.len() { + // Malformed: no closing "-", treat as literal + result.push('&'); + i += 1; + continue; + } + + // Extract the modified-BASE64 sequence + let b64_str = std::str::from_utf8(&bytes[i + 1..j]).unwrap_or(""); + + // Decode modified-BASE64 (replace "," with "/") + let b64_std = b64_str.replace(',', "/"); + + // Decode from standard BASE64 + if let Ok(decoded_bytes) = base64_decode(&b64_std) { + // Convert UTF-16BE bytes to UTF-16 chars, then to UTF-8 + if let Ok(text) = decode_utf16be(&decoded_bytes) { + result.push_str(&text); + } else { + // Invalid UTF-16, use replacement character + result.push('\u{FFFD}'); + } + } else { + // Invalid BASE64, use replacement character + result.push('\u{FFFD}'); + } + + i = j + 1; + continue; + } + + // Other bytes (shouldn't happen in valid UTF-7) + result.push('\u{FFFD}'); + i += 1; + } + + result +} + +/// Decode standard BASE64 (RFC 4648) +fn base64_decode(input: &str) -> Result, String> { + const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut result = Vec::new(); + let input = input.as_bytes(); + let mut i = 0; + + while i < input.len() { + let mut buf = [0u8; 4]; + let mut len = 0; + + // Read up to 4 base64 characters + while len < 4 && i < input.len() { + let b = input[i]; + if b == b'=' { + break; + } + if let Some(pos) = TABLE.iter().position(|&x| x == b) { + buf[len] = pos as u8; + len += 1; + } else if !b.is_ascii_whitespace() { + return Err("Invalid base64 character".to_string()); + } + i += 1; + } + + // Decode the 4-character group + match len { + 0 => break, + 1 => return Err("Invalid base64 length".to_string()), + 2 => { + result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8); + } + 3 => { + result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8); + result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8); + } + 4 => { + result.push(((buf[0] << 2) | (buf[1] >> 4)) as u8); + result.push((((buf[1] & 0x0f) << 4) | (buf[2] >> 2)) as u8); + result.push((((buf[2] & 0x03) << 6) | buf[3]) as u8); + } + _ => return Err("Invalid base64 length".to_string()), + } + + // Skip padding + while i < input.len() && input[i] == b'=' { + i += 1; + } + } + + Ok(result) +} + +/// Decode UTF-16BE bytes to UTF-8 string +fn decode_utf16be(bytes: &[u8]) -> Result { + if bytes.len() % 2 != 0 { + return Err("Odd number of bytes in UTF-16BE sequence".to_string()); + } + + let mut chars = Vec::new(); + let mut i = 0; + + while i < bytes.len() { + let high = u16::from_be_bytes([bytes[i], bytes[i + 1]]); + i += 2; + + // Check for surrogate pair + if (high & 0xFC00) == 0xD800 { + // High surrogate, expect low surrogate + if i + 2 > bytes.len() { + chars.push('\u{FFFD}'); + break; + } + let low = u16::from_be_bytes([bytes[i], bytes[i + 1]]); + i += 2; + + if (low & 0xFC00) != 0xDC00 { + chars.push('\u{FFFD}'); + continue; + } + + // Decode surrogate pair + let high_bits = (high & 0x3FF) as u32; + let low_bits = (low & 0x3FF) as u32; + let codepoint = 0x10000 + (high_bits << 10) + low_bits; + + if let Some(ch) = char::from_u32(codepoint) { + chars.push(ch); + } else { + chars.push('\u{FFFD}'); + } + } else if (high & 0xFC00) == 0xDC00 { + // Unexpected low surrogate + chars.push('\u{FFFD}'); + } else { + // Regular BMP character + if let Some(ch) = char::from_u32(high as u32) { + chars.push(ch); + } else { + chars.push('\u{FFFD}'); + } + } + } + + Ok(chars.iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ascii_passthrough() { + assert_eq!(decode_modified_utf7("INBOX"), "INBOX"); + assert_eq!(decode_modified_utf7("Sent Mail"), "Sent Mail"); + assert_eq!(decode_modified_utf7("Drafts"), "Drafts"); + } + + #[test] + fn test_literal_ampersand() { + assert_eq!(decode_modified_utf7("A&-B"), "A&B"); + assert_eq!(decode_modified_utf7("&-"), "&"); + } + + #[test] + fn test_mixed_ascii_and_encoded() { + // Test that ASCII parts pass through while encoded parts are decoded + // This is a basic test to ensure the structure works + assert!(decode_modified_utf7("Folder&-Name").contains("&")); + } + + #[test] + fn test_decode_empty() { + assert_eq!(decode_modified_utf7(""), ""); + } +} diff --git a/mailcore/src/lib.rs b/mailcore/src/lib.rs new file mode 100644 index 0000000..1272a39 --- /dev/null +++ b/mailcore/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(unexpected_cfgs)] +pub mod api; +pub mod dns; +pub mod imap; +pub mod mime; +pub mod secrets; +pub mod smtp; +pub mod storage; +pub mod types; + +// Re-export specific items if needed +pub use types::MailServerConfig; +pub mod session_manager; diff --git a/mailcore/src/mime/mod.rs b/mailcore/src/mime/mod.rs new file mode 100644 index 0000000..b18fe46 --- /dev/null +++ b/mailcore/src/mime/mod.rs @@ -0,0 +1 @@ +// MIME module placeholder diff --git a/mailcore/src/session_manager.rs b/mailcore/src/session_manager.rs new file mode 100644 index 0000000..67e701a --- /dev/null +++ b/mailcore/src/session_manager.rs @@ -0,0 +1,112 @@ +use crate::imap::ImapClient; +use crate::types::MailServerConfig; +use lazy_static::lazy_static; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +lazy_static! { + /// A global map of active IMAP sessions, keyed by email address. + /// Each session is wrapped in an Arc to allow safe concurrent access (though IMAP is largely sequential). + static ref SESSIONS: Arc>>>> = Arc::new(Mutex::new(HashMap::new())); +} + +/// Retrieves an existing active session or creates a new one. +/// If an existing session is found but disconnected, it attempts to reconnect. +pub async fn get_client( + config: MailServerConfig, + email: String, + password: String, +) -> Result>, crate::types::MailError> { + // Atomic get-or-insert to avoid race conditions on startup + let (client_arc, is_new) = { + let mut sessions = SESSIONS.lock().await; + if let Some(arc) = sessions.get(&email) { + (arc.clone(), false) + } else { + let client = ImapClient::new(config, email.clone()); + let arc = Arc::new(Mutex::new(client)); + sessions.insert(email.clone(), arc.clone()); + (arc, true) + } + }; + + let mut client = client_arc.lock().await; + + if is_new { + tracing::debug!( + "SyncManager: Establishing new IMAP connection for {}...", + email + ); + if let Err(e) = tokio::time::timeout( + std::time::Duration::from_secs(20), + client.connect(&password), + ) + .await + .unwrap_or_else(|_| Err("Connection timeout".into())) + { + let err_msg = e.to_string(); + // Critical cleanup: Remove failed shell so it can be retried later + { + let mut sessions = SESSIONS.lock().await; + sessions.remove(&email); + } + if err_msg.to_lowercase().contains("auth") + || err_msg.to_lowercase().contains("login") + || err_msg.to_lowercase().contains("credentials") + { + return Err(crate::types::MailError::Auth(err_msg)); + } else { + return Err(crate::types::MailError::Network(err_msg)); + } + } + } else { + // Double-check connection health + if !tokio::time::timeout(std::time::Duration::from_secs(5), client.keep_alive()) + .await + .unwrap_or(false) + { + tracing::debug!( + "SyncManager: Connection for {} is dead, attempting reconnect...", + email + ); + if let Err(e) = tokio::time::timeout( + std::time::Duration::from_secs(15), + client.reconnect(&password), + ) + .await + .unwrap_or_else(|_| Err("Reconnect timeout".into())) + { + tracing::error!("SyncManager: Reconnect failed for {}: {}", email, e); + // Remove the stale entry so the next call creates a fresh connection + // instead of looping on keep_alive() against a broken session. + drop(client); + let mut sessions = SESSIONS.lock().await; + sessions.remove(&email); + return Err(crate::types::MailError::Network(e.to_string())); + } + } + } + + Ok(client_arc.clone()) +} + +/// Disconnects and removes a session from the manager. +pub async fn logout(email: &str) { + let mut sessions = SESSIONS.lock().await; + if let Some(client_arc) = sessions.remove(email) { + let mut client = client_arc.lock().await; + let _ = client.logout().await; + tracing::debug!("Logged out session for {}", email); + } +} + +/// Checks if a session exists and is alive for the given email. +pub async fn is_connected(email: &str) -> bool { + let sessions = SESSIONS.lock().await; + if let Some(client_arc) = sessions.get(email) { + let mut client = client_arc.lock().await; + return client.keep_alive().await; + } + false +} diff --git a/mailcore/src/smtp/client.rs b/mailcore/src/smtp/client.rs new file mode 100644 index 0000000..623583c --- /dev/null +++ b/mailcore/src/smtp/client.rs @@ -0,0 +1,120 @@ +use crate::types::MailServerConfig; +use lettre::message::{MultiPart, SinglePart}; +use lettre::{Message, SmtpTransport, Transport}; +use std::error::Error; + +pub struct SmtpClient { + config: MailServerConfig, + email: String, +} + +impl SmtpClient { + pub fn new(config: MailServerConfig, email: String) -> Self { + Self { config, email } + } + + pub async fn send_mail( + &self, + password: &str, + to: &str, + subject: &str, + body_text: &str, + attachments: Vec, + ) -> Result, Box> { + let mut builder = Message::builder() + .from(self.email.parse()?) + .subject(subject); + for addr in to.split(',') { + builder = builder.to(addr.trim().parse()?); + } + + let email = if attachments.is_empty() { + builder.singlepart(SinglePart::plain(body_text.to_string()))? + } else { + let mut multipart = + MultiPart::mixed().singlepart(SinglePart::plain(body_text.to_string())); + + for att in attachments { + multipart = multipart.singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::parse( + &att.content_type, + )?) + .header(lettre::message::header::ContentDisposition::attachment( + &att.name, + )) + .body(att.content), + ); + } + builder.multipart(multipart)? + }; + + use lettre::transport::smtp::client::{Tls, TlsParameters}; + + let creds = lettre::transport::smtp::authentication::Credentials::new( + self.email.clone(), + password.to_string(), + ); + + let tls_parameters = TlsParameters::new(self.config.smtp_host.clone())?; + + // Explicitly choose TLS mode based on port + let tls = if self.config.smtp_port == 465 { + Tls::Wrapper(tls_parameters) + } else { + Tls::Opportunistic(tls_parameters) + }; + + let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host) + .port(self.config.smtp_port) + .tls(tls) + .credentials(creds) + .build(); + + let raw_bytes = email.formatted(); + mailer.send(&email)?; + + Ok(raw_bytes) + } + + pub async fn send_raw_mail( + &self, + password: &str, + to: &str, + _subject: &str, + raw_message: &[u8], + ) -> Result<(), Box> { + use lettre::transport::smtp::client::{Tls, TlsParameters}; + + let creds = lettre::transport::smtp::authentication::Credentials::new( + self.email.clone(), + password.to_string(), + ); + + let tls_parameters = TlsParameters::new(self.config.smtp_host.clone())?; + + let tls = if self.config.smtp_port == 465 { + Tls::Wrapper(tls_parameters) + } else { + Tls::Opportunistic(tls_parameters) + }; + + let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host) + .port(self.config.smtp_port) + .tls(tls) + .credentials(creds) + .build(); + + // Build a manual message from raw bytes; `to` may be comma-separated + let recipients: Vec = to + .split(',') + .map(|addr| addr.trim().parse()) + .collect::, _>>()?; + let envelope = + lettre::address::Envelope::new(Some(self.email.parse()?), recipients)?; + + mailer.send_raw(&envelope, raw_message)?; + + Ok(()) + } +} diff --git a/mailcore/src/smtp/mod.rs b/mailcore/src/smtp/mod.rs new file mode 100644 index 0000000..eb2a42b --- /dev/null +++ b/mailcore/src/smtp/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub use client::SmtpClient; diff --git a/mailcore/src/storage/mod.rs b/mailcore/src/storage/mod.rs new file mode 100644 index 0000000..204ca71 --- /dev/null +++ b/mailcore/src/storage/mod.rs @@ -0,0 +1,733 @@ +use crate::types::{Draft, MessageHeader, PgpStatus}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::error::Error; + +pub struct Storage { + path: String, +} + +impl Storage { + pub fn new(path: &str) -> Result> { + let conn = Connection::open(path)?; + + // Enable WAL mode for concurrent read/write access. + conn.execute_batch("PRAGMA journal_mode=WAL;")?; + + // Set a 5-second busy timeout. + conn.busy_timeout(std::time::Duration::from_secs(5))?; + + // New messages table with folder support + conn.execute( + "CREATE TABLE IF NOT EXISTS messages_v2 ( + folder TEXT, + uid INTEGER, + subject TEXT, + sender TEXT, + recipients TEXT, + date INTEGER, + is_read INTEGER, + has_attachments INTEGER, + pgp_status TEXT, + flag TEXT, + PRIMARY KEY (folder, uid) + )", + [], + )?; + + // Message bodies table for full offline cache + conn.execute( + "CREATE TABLE IF NOT EXISTS message_bodies ( + folder TEXT, + uid INTEGER, + raw_content BLOB, + PRIMARY KEY (folder, uid) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS contacts ( + email TEXT PRIMARY KEY, + display_name TEXT, + public_key TEXT, + fingerprint TEXT, + trust_level TEXT DEFAULT 'Unknown', + first_seen INTEGER, + last_seen INTEGER + )", + [], + )?; + + // Migrations for existing databases + let _ = conn.execute( + "ALTER TABLE messages_v2 ADD COLUMN recipients TEXT DEFAULT '[]'", + [], + ); + let _ = conn.execute( + "ALTER TABLE messages_v2 ADD COLUMN is_read INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE messages_v2 ADD COLUMN has_attachments INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE messages_v2 ADD COLUMN pgp_status TEXT DEFAULT 'None'", + [], + ); + let _ = conn.execute("ALTER TABLE contacts ADD COLUMN fingerprint TEXT", []); + let _ = conn.execute( + "ALTER TABLE contacts ADD COLUMN trust_level TEXT DEFAULT 'Unknown'", + [], + ); + let _ = conn.execute("ALTER TABLE contacts ADD COLUMN first_seen INTEGER", []); + let _ = conn.execute("ALTER TABLE contacts ADD COLUMN last_seen INTEGER", []); + let _ = conn.execute("ALTER TABLE messages_v2 ADD COLUMN flag TEXT", []); + let _ = conn.execute( + "ALTER TABLE sync_state ADD COLUMN server_total INTEGER NOT NULL DEFAULT 0", + [], + ); + + // sync_state table + conn.execute( + "CREATE TABLE IF NOT EXISTS sync_state ( + account TEXT, + folder TEXT, + uidvalidity INTEGER, + last_uid INTEGER, + PRIMARY KEY (account, folder) + )", + [], + )?; + + // Normalize INBOX + let _ = conn.execute( + "UPDATE messages_v2 SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'", + [], + ); + let _ = conn.execute( + "UPDATE message_bodies SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'", + [], + ); + let _ = conn.execute( + "UPDATE sync_state SET folder = 'INBOX' WHERE LOWER(folder) = 'inbox' AND folder != 'INBOX'", + [], + ); + + conn.execute( + "CREATE TABLE IF NOT EXISTS pgp_identities ( + fingerprint TEXT PRIMARY KEY, + email TEXT, + public_key TEXT, + private_key TEXT + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipient TEXT, + subject TEXT, + body TEXT, + last_updated INTEGER, + last_synced_at INTEGER DEFAULT 0, + server_uid INTEGER + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS draft_attachments ( + draft_id INTEGER, + name TEXT, + content_type TEXT, + content BLOB, + FOREIGN KEY (draft_id) REFERENCES drafts(id) ON DELETE CASCADE + )", + [], + )?; + + Ok(Storage { + path: path.to_string(), + }) + } + + fn connect(&self) -> Result> { + Ok(Connection::open(&self.path)?) + } + + pub fn save_draft(&self, draft: &Draft) -> Result> { + let mut conn = self.connect()?; + let tx = conn.transaction()?; + + let draft_id = if let Some(id) = draft.id { + tx.execute( + "UPDATE drafts SET recipient = ?1, subject = ?2, body = ?3, last_updated = ?4, last_synced_at = ?5, server_uid = ?6 WHERE id = ?7", + params![draft.to, draft.subject, draft.body, draft.last_updated, draft.last_synced_at, draft.server_uid, id], + )?; + id + } else { + tx.execute( + "INSERT INTO drafts (recipient, subject, body, last_updated, last_synced_at, server_uid) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![draft.to, draft.subject, draft.body, draft.last_updated, draft.last_synced_at, draft.server_uid], + )?; + tx.last_insert_rowid() + }; + + // Update attachments + tx.execute( + "DELETE FROM draft_attachments WHERE draft_id = ?1", + params![draft_id], + )?; + for att in &draft.attachments { + tx.execute( + "INSERT INTO draft_attachments (draft_id, name, content_type, content) VALUES (?1, ?2, ?3, ?4)", + params![draft_id, att.name, att.content_type, att.content], + )?; + } + + tx.commit()?; + Ok(draft_id) + } + + pub fn get_drafts(&self) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn + .prepare("SELECT id, recipient, subject, body, last_updated, last_synced_at, server_uid FROM drafts")?; + let rows = stmt.query_map([], |row| { + let id: i64 = row.get(0)?; + + // Load attachments for this draft + let mut att_stmt = conn.prepare( + "SELECT name, content_type, content FROM draft_attachments WHERE draft_id = ?1", + )?; + let att_rows = att_stmt.query_map(params![id], |att_row| { + let content: Vec = att_row.get(2)?; + let size = content.len(); + Ok(crate::types::Attachment { + name: att_row.get(0)?, + content_type: att_row.get(1)?, + size, + content, + }) + })?; + + let mut attachments = Vec::new(); + for att in att_rows { + attachments.push(att?); + } + + Ok(Draft { + id: Some(id), + to: row.get(1)?, + subject: row.get(2)?, + body: row.get(3)?, + attachments, + last_updated: row.get(4).unwrap_or(0), + last_synced_at: row.get(5).unwrap_or(0), + server_uid: row.get(6).ok(), + }) + })?; + + let mut drafts = Vec::new(); + for draft in rows { + drafts.push(draft?); + } + Ok(drafts) + } + + pub fn delete_draft(&self, id: i64) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute("DELETE FROM drafts WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn save_header( + &self, + folder: &str, + header: &MessageHeader, + ) -> Result<(), Box> { + let conn = self.connect()?; + let recipients_json = + serde_json::to_string(&header.to).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "INSERT OR REPLACE INTO messages_v2 (folder, uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + folder, + header.uid, + header.subject, + header.from, + recipients_json, + header.date, + if header.is_read { 1 } else { 0 }, + if header.has_attachments { 1 } else { 0 }, + format!("{:?}", header.pgp_status), + header.flag.clone(), + ], + )?; + Ok(()) + } + + pub fn get_uids( + &self, + folder: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn.prepare("SELECT uid FROM messages_v2 WHERE folder = ?1")?; + let rows = stmt.query_map(rusqlite::params![folder], |row| row.get::<_, u32>(0))?; + + let mut uids = std::collections::HashSet::new(); + for uid in rows { + if let Ok(u) = uid { + uids.insert(u); + } + } + Ok(uids) + } + + pub fn get_headers( + &self, + folder: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn.prepare("SELECT uid, subject, sender, recipients, date, is_read, has_attachments, pgp_status, flag FROM messages_v2 WHERE folder = ?1 ORDER BY date DESC")?; + let rows = stmt.query_map(params![folder], |row| { + let recipients_json: String = row.get(3)?; + let to: Vec = serde_json::from_str(&recipients_json).unwrap_or_default(); + let pgp_status_str: String = row.get(7)?; + + let pgp_status = match pgp_status_str.as_str() { + "Encrypted" => PgpStatus::Encrypted, + "Signed" => PgpStatus::Signed, + "SignedAndEncrypted" => PgpStatus::SignedAndEncrypted, + "InvalidSignature" => PgpStatus::InvalidSignature, + "EncryptedAtRest" => PgpStatus::EncryptedAtRest, + _ => PgpStatus::None, + }; + + Ok(MessageHeader { + uid: row.get(0)?, + subject: row.get(1)?, + from: row.get(2)?, + to, + date: row.get(4)?, + is_read: row.get::<_, i32>(5)? == 1, + has_attachments: row.get::<_, i32>(6)? == 1, + pgp_status, + flag: row.get(8).unwrap_or(None), + }) + })?; + + let mut headers = Vec::new(); + for header in rows { + headers.push(header?); + } + Ok(headers) + } + + pub fn delete_header( + &self, + folder: &str, + uid: u32, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "DELETE FROM messages_v2 WHERE folder = ?1 AND uid = ?2", + params![folder, uid], + )?; + conn.execute( + "DELETE FROM message_bodies WHERE folder = ?1 AND uid = ?2", + params![folder, uid], + )?; + Ok(()) + } + + pub fn delete_headers( + &self, + folder: &str, + uids: &[u32], + ) -> Result<(), Box> { + if uids.is_empty() { + return Ok(()); + } + let conn = self.connect()?; + let placeholders = (0..uids.len()) + .map(|i| format!("?{}", i + 2)) + .collect::>() + .join(","); + let sql_messages = format!( + "DELETE FROM messages_v2 WHERE folder = ?1 AND uid IN ({})", + placeholders + ); + let sql_bodies = format!( + "DELETE FROM message_bodies WHERE folder = ?1 AND uid IN ({})", + placeholders + ); + { + let mut stmt = conn.prepare(&sql_messages)?; + let mut p: Vec> = Vec::with_capacity(uids.len() + 1); + p.push(Box::new(folder.to_string())); + for &uid in uids { + p.push(Box::new(uid)); + } + let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect(); + stmt.execute(refs.as_slice())?; + } + { + let mut stmt = conn.prepare(&sql_bodies)?; + let mut p: Vec> = Vec::with_capacity(uids.len() + 1); + p.push(Box::new(folder.to_string())); + for &uid in uids { + p.push(Box::new(uid)); + } + let refs: Vec<&dyn rusqlite::ToSql> = p.iter().map(|v| v.as_ref()).collect(); + stmt.execute(refs.as_slice())?; + } + Ok(()) + } + + pub fn wipe_folder(&self, folder: &str) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute("DELETE FROM messages_v2 WHERE folder = ?1", params![folder])?; + conn.execute( + "DELETE FROM message_bodies WHERE folder = ?1", + params![folder], + )?; + Ok(()) + } + + pub fn update_read_status( + &self, + folder: &str, + uid: u32, + is_read: bool, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "UPDATE messages_v2 SET is_read = ?1 WHERE folder = ?2 AND uid = ?3", + params![if is_read { 1 } else { 0 }, folder, uid], + )?; + Ok(()) + } + + pub fn mark_all_as_read(&self, folder: &str) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "UPDATE messages_v2 SET is_read = 1 WHERE folder = ?1", + params![folder], + )?; + Ok(()) + } + + pub fn update_message_flag( + &self, + folder: &str, + uid: u32, + flag: Option, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "UPDATE messages_v2 SET flag = ?1 WHERE folder = ?2 AND uid = ?3", + params![flag, folder, uid], + )?; + Ok(()) + } + + pub fn save_message_raw( + &self, + folder: &str, + uid: u32, + content: &[u8], + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "INSERT OR REPLACE INTO message_bodies (folder, uid, raw_content) VALUES (?1, ?2, ?3)", + params![folder, uid, content], + )?; + Ok(()) + } + + pub fn get_message_raw( + &self, + folder: &str, + uid: u32, + ) -> Result>, Box> { + let conn = self.connect()?; + let mut stmt = + conn.prepare("SELECT raw_content FROM message_bodies WHERE folder = ?1 AND uid = ?2")?; + let result = stmt + .query_row(params![folder, uid], |row| row.get(0)) + .optional()?; + Ok(result) + } + + pub fn save_contact( + &self, + contact: &crate::types::Contact, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "INSERT OR IGNORE INTO contacts (email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + contact.email.to_lowercase(), + contact.display_name, + contact.public_key, + contact.fingerprint, + format!("{:?}", contact.trust_level), + contact.first_seen, + contact.last_seen + ], + )?; + Ok(()) + } + + pub fn get_contacts(&self) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen FROM contacts", + )?; + let rows = stmt.query_map([], |row| { + let trust_level_str: String = row.get(4)?; + let trust_level = match trust_level_str.as_str() { + "Trusted" => crate::types::TrustLevel::Trusted, + "Established" => crate::types::TrustLevel::Established, + "Warning" => crate::types::TrustLevel::Warning, + _ => crate::types::TrustLevel::Unknown, + }; + + Ok(crate::types::Contact { + email: row.get(0)?, + display_name: row.get(1)?, + public_key: row.get(2)?, + fingerprint: row.get(3)?, + trust_level, + first_seen: row.get(5).unwrap_or(0), + last_seen: row.get(6).unwrap_or(0), + }) + })?; + + let mut contacts = Vec::new(); + for contact in rows { + contacts.push(contact?); + } + Ok(contacts) + } + + pub fn get_contact_by_email( + &self, + email: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = + conn.prepare("SELECT email, display_name, public_key, fingerprint, trust_level, first_seen, last_seen FROM contacts WHERE LOWER(email) = LOWER(?1)")?; + let contact = stmt.query_row(params![email.to_lowercase()], |row| { + let trust_level_str: String = row.get(4)?; + let trust_level = match trust_level_str.as_str() { + "Trusted" => crate::types::TrustLevel::Trusted, + "Established" => crate::types::TrustLevel::Established, + "Warning" => crate::types::TrustLevel::Warning, + _ => crate::types::TrustLevel::Unknown, + }; + + Ok(crate::types::Contact { + email: row.get(0)?, + display_name: row.get(1)?, + public_key: row.get(2)?, + fingerprint: row.get(3)?, + trust_level, + first_seen: row.get(5).unwrap_or(0), + last_seen: row.get(6).unwrap_or(0), + }) + }); + + match contact { + Ok(c) => Ok(Some(c)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn delete_contact(&self, email: &str) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "DELETE FROM contacts WHERE LOWER(email) = LOWER(?1)", + params![email.to_lowercase()], + )?; + Ok(()) + } + + pub fn save_identity( + &self, + email: &str, + keypair: &crate::types::PgpKeyPair, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "INSERT OR REPLACE INTO pgp_identities (fingerprint, email, public_key, private_key) VALUES (?1, ?2, ?3, ?4)", + params![keypair.fingerprint, email.to_lowercase(), keypair.public_key, keypair.private_key], + )?; + Ok(()) + } + + pub fn list_identities( + &self, + email: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT public_key, private_key, fingerprint FROM pgp_identities WHERE LOWER(email) = LOWER(?1)", + )?; + let rows = stmt.query_map(params![email.to_lowercase()], |row| { + let pub_key: String = row.get(0)?; + let priv_key: String = row.get(1)?; + let fingerprint: String = row.get(2)?; + + Ok(crate::types::PgpKeyPair { + public_key: pub_key, + private_key: priv_key, + key_info: format!("Key: {}", fingerprint), + fingerprint, + user_ids: Vec::new(), + }) + })?; + + let mut identities = Vec::new(); + for id in rows { + identities.push(id?); + } + Ok(identities) + } + + pub fn get_identity_by_fingerprint( + &self, + fingerprint: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = + conn.prepare("SELECT public_key, private_key, fingerprint FROM pgp_identities WHERE fingerprint = ?1")?; + let keypair = stmt.query_row(params![fingerprint], |row| { + let pub_key: String = row.get(0)?; + let priv_key: String = row.get(1)?; + let fingerprint: String = row.get(2)?; + + Ok(crate::types::PgpKeyPair { + public_key: pub_key, + private_key: priv_key, + key_info: format!("Key: {}", fingerprint), + fingerprint, + user_ids: Vec::new(), + }) + }); + + match keypair { + Ok(k) => Ok(Some(k)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn delete_identity(&self, fingerprint: &str) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "DELETE FROM pgp_identities WHERE fingerprint = ?1", + params![fingerprint], + )?; + Ok(()) + } + + pub fn update_sync_state( + &self, + account: &str, + folder: &str, + uidvalidity: u32, + last_uid: u32, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "INSERT OR REPLACE INTO sync_state (account, folder, uidvalidity, last_uid) VALUES (?1, ?2, ?3, ?4)", + params![account, folder, uidvalidity, last_uid], + )?; + Ok(()) + } + + pub fn get_sync_state( + &self, + account: &str, + folder: &str, + ) -> Result, Box> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT uidvalidity, last_uid FROM sync_state WHERE account = ?1 AND folder = ?2", + )?; + + let mut rows = stmt.query(params![account, folder])?; + + if let Some(row) = rows.next()? { + Ok(Some((row.get(0)?, row.get(1)?))) + } else { + Ok(None) + } + } + + pub fn get_unread_count(&self, folder: &str) -> Result> { + let conn = self.connect()?; + let count: i32 = conn.query_row( + "SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0", + params![folder], + |row| row.get(0), + )?; + Ok(count) + } + + pub fn update_server_total( + &self, + account: &str, + folder: &str, + total: u32, + ) -> Result<(), Box> { + let conn = self.connect()?; + conn.execute( + "INSERT OR IGNORE INTO sync_state (account, folder, uidvalidity, last_uid, server_total) VALUES (?1, ?2, 0, 0, 0)", + params![account, folder], + )?; + conn.execute( + "UPDATE sync_state SET server_total = ?3 WHERE account = ?1 AND folder = ?2", + params![account, folder, total], + )?; + Ok(()) + } + + pub fn get_folder_stats( + &self, + account: &str, + folder: &str, + ) -> Result<(i32, i32), Box> { + let conn = self.connect()?; + let unread: i32 = conn.query_row( + "SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0", + params![folder], + |row| row.get(0), + )?; + let server_total: i32 = conn + .query_row( + "SELECT COALESCE(server_total, 0) FROM sync_state WHERE account = ?1 AND folder = ?2", + params![account, folder], + |row| row.get(0), + ) + .unwrap_or(0); + Ok((unread, server_total)) + } + + pub fn get_total_unread_count(&self) -> Result> { + let conn = self.connect()?; + let count: i32 = conn.query_row( + "SELECT COUNT(*) FROM messages_v2 WHERE is_read = 0 AND LOWER(folder) = 'inbox'", + [], + |row| row.get(0), + )?; + Ok(count) + } +} diff --git a/mailcore/src/types.rs b/mailcore/src/types.rs new file mode 100644 index 0000000..acbe8e1 --- /dev/null +++ b/mailcore/src/types.rs @@ -0,0 +1,154 @@ +use flutter_rust_bridge::frb; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MailServerConfig { + pub imap_host: String, + pub imap_port: u16, + pub smtp_host: String, + pub smtp_port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageHeader { + pub uid: u32, + pub subject: String, + pub from: String, + pub to: Vec, + pub date: i64, + pub is_read: bool, + pub has_attachments: bool, + pub pgp_status: PgpStatus, + pub flag: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PgpStatus { + None, + /// Message was encrypted by the sender (requires recipient private key to read). + Encrypted, + Signed, + SignedAndEncrypted, + InvalidSignature, + /// Message stored at rest encrypted with the user's own public key. + EncryptedAtRest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageBody { + pub uid: u32, + pub from: Option, + pub text_plain: Option, + pub text_html: Option, + pub attachments: Vec, + pub pgp_status: PgpStatus, + pub signature_fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attachment { + pub name: String, + pub content_type: String, + pub size: usize, + pub content: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Contact { + pub email: String, + pub display_name: Option, + pub public_key: Option, + pub fingerprint: Option, + pub trust_level: TrustLevel, + pub first_seen: i64, + pub last_seen: i64, +} + +#[frb] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TrustLevel { + Unknown, + Trusted, + Established, + Warning, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Draft { + pub id: Option, + pub to: String, + pub subject: String, + pub body: String, + pub attachments: Vec, + pub last_updated: i64, + pub last_synced_at: i64, + pub server_uid: Option, +} + +#[derive(Debug, Clone)] +pub struct PgpKeyPair { + pub public_key: String, + pub private_key: String, + pub key_info: String, + pub fingerprint: String, + pub user_ids: Vec, +} + +#[frb] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum MailError { + Generic(String), + Network(String), + Auth(String), + PassphraseRequired, + InvalidPassphrase, + KeyNotFound(String), + Crypto(String), +} + +impl std::fmt::Display for MailError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MailError::Generic(s) => write!(f, "{}", s), + MailError::Network(s) => write!(f, "Network error: {}", s), + MailError::Auth(s) => write!(f, "Auth error: {}", s), + MailError::PassphraseRequired => write!(f, "Passphrase required"), + MailError::InvalidPassphrase => write!(f, "Invalid passphrase"), + MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s), + MailError::Crypto(s) => write!(f, "Crypto error: {}", s), + } + } +} + +impl std::error::Error for MailError {} + +#[frb] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum FolderType { + Inbox, + Sent, + Drafts, + Trash, + Junk, + Archive, + Custom, +} + +#[frb] +#[derive(Debug, Clone)] +pub struct FolderStats { + pub unread: i32, + pub server_total: i32, +} + +#[frb] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderInfo { + pub name: String, + /// Raw server-side folder name in IMAP Modified UTF-7 encoding. + /// Use this when issuing IMAP SELECT/EXAMINE/APPEND commands. + pub imap_name: String, + pub folder_type: FolderType, + pub is_selectable: bool, + pub unread_count: i32, +} diff --git a/mailcore/tests/subject_decoding_test.rs b/mailcore/tests/subject_decoding_test.rs new file mode 100644 index 0000000..bfee900 --- /dev/null +++ b/mailcore/tests/subject_decoding_test.rs @@ -0,0 +1,140 @@ +use mail_parser::MessageParser; + +// Duplicate the word-joiner here since pub(crate) is not visible from integration tests. +fn parse_encoded_word(s: &str) -> Option<(&str, &str, &str, &str)> { + let s = s.strip_prefix("=?")?; + let (charset, rest) = s.split_once('?')?; + let (encoding, rest) = rest.split_once('?')?; + if encoding.len() != 1 { + return None; + } + let enc_char = encoding.chars().next()?.to_ascii_uppercase(); + if enc_char != 'B' && enc_char != 'Q' { + return None; + } + let end = rest.find("?=")?; + let payload = &rest[..end]; + let remaining = &rest[end + 2..]; + Some((charset, encoding, payload, remaining)) +} + +fn join_adjacent_encoded_words(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut pos = 0; + while pos < s.len() { + let remaining = &s[pos..]; + if !remaining.starts_with("=?") { + let ch = remaining.chars().next().unwrap(); + out.push(ch); + pos += ch.len_utf8(); + continue; + } + match parse_encoded_word(remaining) { + None => { + out.push('='); + pos += 1; + } + Some((charset, encoding, payload, after_first)) => { + let mut combined = payload.to_string(); + let mut cursor = after_first; + loop { + let ws = cursor.trim_start_matches(|c: char| c == ' ' || c == '\t'); + if !ws.starts_with("=?") { + break; + } + match parse_encoded_word(ws) { + None => break, + Some((cs2, enc2, payload2, after_next)) => { + if cs2.eq_ignore_ascii_case(charset) + && enc2.eq_ignore_ascii_case(encoding) + { + combined.push_str(payload2); + cursor = after_next; + } else { + break; + } + } + } + } + out.push_str("=?"); + out.push_str(charset); + out.push('?'); + out.push_str(encoding); + out.push('?'); + out.push_str(&combined); + out.push_str("?="); + pos += remaining.len() - cursor.len(); + } + } + } + out +} + +fn preprocess(bytes: &[u8]) -> Vec { + match std::str::from_utf8(bytes) { + Ok(s) => join_adjacent_encoded_words(s).into_bytes(), + Err(_) => bytes.to_vec(), + } +} + +fn parse_subject(raw: &[u8]) -> String { + let preprocessed = preprocess(raw); + MessageParser::default() + .parse(&preprocessed) + .and_then(|m| m.subject().map(|s| s.to_string())) + .unwrap_or_default() +} + +/// ЕИРЦ = D0 95 D0 98 D0 A0 D0 A6 +/// Full base64: 0JXQmNCg0KY= +#[test] +fn verify_full_base64() { + let raw = b"Subject: =?UTF-8?B?0JXQmNCg0KY=?=\n\nBody"; + assert_eq!( + parse_subject(raw), + "ЕИРЦ", + "Full base64 sanity check failed" + ); +} + +/// Plain UTF-8 – always worked. +#[test] +fn test_raw_utf8() { + let raw = "Subject: ЕИРЦ\n\nBody".as_bytes(); + assert_eq!(parse_subject(raw), "ЕИРЦ"); +} + +/// UTF-8 Quoted-Printable – already worked before fix. +#[test] +fn test_utf8_qp() { + let raw = b"Subject: =?UTF-8?Q?=D0=95=D0=98=D0=A0=D0=A6?=\n\nBody"; + assert_eq!(parse_subject(raw), "ЕИРЦ"); +} + +/// Multi-byte char split across two adjacent words (no whitespace). +/// Split: first 6 bytes (D0 95 D0 98 D0 A0) → 0JXQmNCg +/// last 2 bytes (D0 A6) → 0KY= +/// mail-parser sees И split across the boundary. +#[test] +fn test_split_utf8_encoded_words() { + let raw = b"Subject: =?UTF-8?B?0JXQmNCg?==?UTF-8?B?0KY=?=\n\nBody"; + assert_eq!( + parse_subject(raw), + "ЕИРЦ", + "Split encoded-word (no space) failed" + ); +} + +/// Same split with linear whitespace (RFC 2047 §6.2). +#[test] +fn test_split_utf8_with_whitespace() { + let raw = b"Subject: =?UTF-8?B?0JXQmNCg?= =?UTF-8?B?0KY=?=\n\nBody"; + assert_eq!(parse_subject(raw), "ЕИРЦ", "Split with whitespace failed"); +} + +/// Split across multiple lines (CRLF + space), simulating folded headers. +#[test] +fn test_split_utf8_folded_header() { + let raw = b"Subject: =?UTF-8?B?0JXQmNCg?=\r\n =?UTF-8?B?0KY=?=\n\nBody"; + assert_eq!(parse_subject(raw), "ЕИРЦ", "Split folded header failed"); +} diff --git a/mailcore/tests/test_imap_smtp_mock.rs b/mailcore/tests/test_imap_smtp_mock.rs new file mode 100644 index 0000000..9ab9c66 --- /dev/null +++ b/mailcore/tests/test_imap_smtp_mock.rs @@ -0,0 +1,79 @@ +use lettre::message::{MultiPart, SinglePart}; +use lettre::Message; +use mail_parser::MimeHeaders; +use std::error::Error; + +/// Mock integration test validating that our SMTP serialization and IMAP parsing +/// logic functions cleanly without dropping or ignoring attributes, executing fully headless +/// without needing a binding live TCP/TLS external socket constraint. +#[test] +fn test_smtp_builder_and_imap_parser_mock() -> Result<(), Box> { + // 1. SMTP Mock Builder Simulation + let sender = "alice@example.com"; + let receiver = "bob@example.com"; + let subject = "Mock Integration Payload"; + let body_text = "This is a body payload."; + + // Simulate Attachments + let content = b"FILE_CONTENT_MOCK".to_vec(); + let attachment_name = "secret.txt"; + + let email = Message::builder() + .from(sender.parse()?) + .to(receiver.parse()?) + .subject(subject) + .multipart( + MultiPart::mixed() + .singlepart(SinglePart::plain(body_text.to_string())) + .singlepart( + SinglePart::builder() + .header(lettre::message::header::ContentType::parse("text/plain")?) + .header(lettre::message::header::ContentDisposition::attachment( + attachment_name, + )) + .body(content.clone()), + ), + )?; + + let raw_email_bytes = email.formatted(); + + // 2. IMAP Mock Parsing Simulation (What we'd do on receiving bytes from IMAP FETCH) + let parsed_message = mail_parser::MessageParser::default() + .parse(&raw_email_bytes) + .ok_or("Failed to parse raw email bytes")?; + + // Validate Headers + assert_eq!( + parsed_message.subject().unwrap(), + "Mock Integration Payload" + ); + + let from_header = parsed_message.from().unwrap().first(); + assert_eq!(from_header.unwrap().address().unwrap(), "alice@example.com"); + + let to_header = parsed_message.to().unwrap().first(); + assert_eq!(to_header.unwrap().address().unwrap(), "bob@example.com"); + + // Validate Body + assert_eq!( + parsed_message.body_text(0).unwrap().trim(), + "This is a body payload." + ); + + // Validate Attachments + let mut attachment_found = false; + for attachment in parsed_message.attachments() { + if let Some(name) = attachment.attachment_name() { + if name == attachment_name { + attachment_found = true; + assert_eq!(attachment.contents(), content.as_slice()); + } + } + } + assert!( + attachment_found, + "Attachment was dropped during serialization/deserialization mock" + ); + + Ok(()) +}