use crate::types::{AuthCredential, FolderInfo, MailError, MailServerConfig}; use flutter_rust_bridge::frb; 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() { #[cfg(target_os = "android")] { use tracing_subscriber::prelude::*; if let Ok(android_layer) = tracing_android::layer("KoraxRust") { let _ = tracing_subscriber::registry() .with(android_layer) .try_init(); } } #[cfg(not(target_os = "android"))] { 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(); } /// Step-by-step IMAP connection diagnostic. Returns a multi-line string describing /// each phase (TCP → TLS → greeting) with timing and outcome. /// Use this from Flutter to identify exactly where the hang is on Android. pub async fn diagnose_imap_connection(host: String, port: u32, email: String) -> String { use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::io::AsyncReadExt; use tokio::net::TcpStream; use tokio_rustls::TlsConnector; let mut out = String::new(); out.push_str(&format!("Diagnosing connection for {}\n", email)); let mut root_cert_store = rustls::RootCertStore::empty(); root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); let tls_config = match rustls::ClientConfig::builder_with_provider(Arc::new( rustls::crypto::ring::default_provider(), )) .with_safe_default_protocol_versions() .map_err(|e| e.to_string()) { Ok(b) => b.with_root_certificates(root_cert_store).with_no_client_auth(), Err(e) => { out.push_str(&format!("TLS config: FAILED ({})\n", e)); return out; } }; let addr = format!("{}:{}", host, port); // Phase 1: TCP connect let t0 = Instant::now(); let stream = match tokio::time::timeout(Duration::from_secs(10), TcpStream::connect(&addr)) .await { Err(_) => { out.push_str(&format!("TCP: TIMEOUT after 10s\n")); return out; } Ok(Err(e)) => { out.push_str(&format!("TCP: FAILED ({}) in {:.1}s\n", e, t0.elapsed().as_secs_f32())); return out; } Ok(Ok(s)) => { out.push_str(&format!("TCP: OK in {:.1}s\n", t0.elapsed().as_secs_f32())); s } }; // Phase 2: TLS handshake let connector = TlsConnector::from(Arc::new(tls_config)); let server_name = match rustls_pki_types::ServerName::try_from(host.clone()) .map_err(|_| format!("Invalid DNS name: {}", host)) .and_then(|n| Ok(n.to_owned())) { Ok(n) => n, Err(e) => { out.push_str(&format!("TLS name: FAILED ({})\n", e)); return out; } }; let t1 = Instant::now(); let mut tls_stream = match tokio::time::timeout( Duration::from_secs(10), connector.connect(server_name, stream), ) .await { Err(_) => { out.push_str(&format!("TLS: TIMEOUT after 10s\n")); return out; } Ok(Err(e)) => { out.push_str(&format!("TLS: FAILED ({}) in {:.1}s\n", e, t1.elapsed().as_secs_f32())); return out; } Ok(Ok(s)) => { out.push_str(&format!("TLS: OK in {:.1}s\n", t1.elapsed().as_secs_f32())); s } }; // Phase 3: Read IMAP server greeting (first line) let mut greeting = vec![0u8; 512]; let t2 = Instant::now(); match tokio::time::timeout(Duration::from_secs(10), tls_stream.read(&mut greeting)).await { Err(_) => { out.push_str(&format!("Greeting: TIMEOUT after 10s\n")); return out; } Ok(Err(e)) => { out.push_str(&format!("Greeting: READ FAILED ({}) in {:.1}s\n", e, t2.elapsed().as_secs_f32())); return out; } Ok(Ok(n)) => { let line = String::from_utf8_lossy(&greeting[..n.min(120)]).to_string(); out.push_str(&format!( "Greeting: OK in {:.1}s — {:?}\n", t2.elapsed().as_secs_f32(), line.trim() )); } }; out } 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, credential: AuthCredential, 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, credential).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, credential: AuthCredential, 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, credential).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, credential: AuthCredential, 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(), credential); client .connect() .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, credential: AuthCredential, 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, credential); client .connect() .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, credential: AuthCredential, message_bytes: &[u8], ) { let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string(), credential); if let Err(e) = client.connect().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, credential: AuthCredential, 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 smtp_client = crate::smtp::SmtpClient::new(config.clone(), email.clone(), credential.clone()); let message_bytes = smtp_client .send_mail(&to, &subject, &body, attachments) .await .map_err(|e| MailError::Network(e.to_string()))?; append_to_sent(&config, &email, credential, &message_bytes).await; Ok(()) } pub async fn prefetch_bodies( config: crate::types::MailServerConfig, email: String, credential: AuthCredential, 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(), credential); client .connect() .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()), ) }; // Extract sender public key: // 1. Try Autocrypt header first (RFC 7929) let sender_public_key = extract_autocrypt_key(&parsed) // 2. Fall back to application/pgp-keys attachment .or_else(|| extract_pgp_keys_attachment(&parsed)); Ok(crate::types::MessageBody { uid: 0, // No UID for raw bytes from: from_addr, text_plain, text_html, attachments, pgp_status, signature_fingerprint, sender_public_key, }) }) .await .unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e)))) } /// Parse the Autocrypt header and return the ASCII-armored public key, or None. /// /// RFC 7929 / Autocrypt Level 1: the `keydata=` attribute holds the raw bytes /// of the exported OpenPGP certificate, Base64-encoded (no line breaks, no armor). /// We re-armor them so callers can use the result directly with Sequoia. fn extract_autocrypt_key(parsed: &mail_parser::Message) -> Option { let header_value = parsed .headers() .iter() .find(|h| h.name.as_str().eq_ignore_ascii_case("Autocrypt")) .and_then(|h| match &h.value { mail_parser::HeaderValue::Text(t) => Some(t.as_ref().to_string()), mail_parser::HeaderValue::TextList(list) => list.first().map(|s| s.to_string()), _ => None, })?; // Extract keydata= value (may be followed by ; or end of string) let lower = header_value.to_lowercase(); let keydata_start = lower.find("keydata=")?; let rest = &header_value[keydata_start + "keydata=".len()..]; let keydata_b64: String = rest .split(';') .next()? .chars() .filter(|c| !c.is_whitespace()) .collect(); if keydata_b64.is_empty() { return None; } // Re-armor: wrap the raw base64 in PGP armor header/footer // (The keydata is already the raw exported cert bytes in base64; we split // into 64-char lines for canonical OpenPGP ASCII Armor format.) let wrapped: String = keydata_b64 .chars() .collect::>() .chunks(64) .map(|chunk| chunk.iter().collect::()) .collect::>() .join("\n"); Some(format!( "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{wrapped}\n-----END PGP PUBLIC KEY BLOCK-----\n" )) } /// Look for the first MIME part with Content-Type application/pgp-keys and /// return its content decoded as UTF-8 (the ASCII-armored public key block). fn extract_pgp_keys_attachment(parsed: &mail_parser::Message) -> Option { for part in &parsed.parts { let is_pgp_keys = part .content_type() .map(|ct| { ct.ctype().eq_ignore_ascii_case("application") && ct.subtype().map(|s| s.eq_ignore_ascii_case("pgp-keys")).unwrap_or(false) }) .unwrap_or(false); if is_pgp_keys { return std::str::from_utf8(part.contents()).ok().map(|s| s.to_string()); } } None } 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, credential: AuthCredential, 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()))?; // Skip syncing folders pending a full wipe — avoids repopulating messages // before the IMAP expunge completes. if storage.is_folder_pending_wipe(&email, &mailbox) { return Ok(vec![]); } // Step 1: Run sync on the shared session. let sync_result = { let client_arc = crate::session_manager::get_client(config.clone(), email.clone(), credential.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, credential); match dedicated.connect().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, credential: AuthCredential, 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(), credential.clone()); client .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; client .select_mailbox(&mailbox) .await .map_err(|e| MailError::Network(e.to_string()))?; 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().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, account_email: 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()))?; let mut headers = storage .get_headers(&mailbox) .map_err(|e| MailError::Generic(e.to_string()))?; // Ensure account_email is set on every returned header. Rows migrated from // older schema versions will have an empty string in the column; overwrite // them here so callers always get a non-empty value. for h in &mut headers { if h.account_email.is_empty() { h.account_email = account_email.clone(); } } Ok(headers) } 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, credential: AuthCredential, storage_path: String, ) -> Result<(), MailError> { let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client .connect() .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(uid) = client .append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string())) .await { draft.server_uid = Some(uid); 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, credential: AuthCredential, 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, credential); client .connect() .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()))?; storage .delete_header(&from_mailbox, uid) .map_err(|e| MailError::Generic(e.to_string()))?; let _ = storage.delete_drafts_by_server_uids(&[uid]); Ok(()) } pub async fn move_messages( config: MailServerConfig, email: String, credential: AuthCredential, 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, credential); client .connect() .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()))?; storage .delete_headers(&from_mailbox, &uids) .map_err(|e| MailError::Generic(e.to_string()))?; let _ = storage.delete_drafts_by_server_uids(&uids); Ok(()) } pub async fn delete_messages_permanently( config: MailServerConfig, email: String, credential: AuthCredential, mailbox: String, uids: Vec, storage_path: String, ) -> Result<(), MailError> { if uids.is_empty() { return Ok(()); } let mut client = crate::imap::ImapClient::new(config, email, credential); client .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; let result = client .delete_messages_permanently(&mailbox, &uids) .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()))?; storage .delete_headers(&mailbox, &uids) .map_err(|e| MailError::Generic(e.to_string()))?; Ok(()) } #[frb(sync)] pub fn mark_as_read_local( storage_path: String, mailbox: String, uid: u32, ) -> Result<(), MailError> { let storage = crate::storage::Storage::new(&storage_path) .map_err(|e| MailError::Generic(e.to_string()))?; storage .update_read_status(&mailbox, uid, true) .map_err(|e| MailError::Generic(e.to_string()))?; Ok(()) } pub async fn mark_as_read( config: MailServerConfig, email: String, credential: AuthCredential, 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(), credential); client .connect() .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, credential: AuthCredential, 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(), credential); client .connect() .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, credential: AuthCredential, 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(), credential); client .connect() .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(()) } #[frb(sync)] pub fn prepare_delete_all_messages( email: String, mailbox: String, storage_path: String, wipe_drafts: bool, ) -> 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 .set_folder_pending_wipe(&email, &mailbox, wipe_drafts) .map_err(|e| MailError::Generic(e.to_string())) } pub async fn delete_all_messages( config: MailServerConfig, email: String, credential: AuthCredential, mailbox: String, storage_path: String, wipe_drafts: bool, ) -> Result<(), MailError> { let mailbox = if mailbox.to_uppercase() == "INBOX" { "INBOX".to_string() } else { mailbox }; let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client .connect() .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()))?; // Wipe local cache (idempotent — also done by prepare, covers the resume path). let _ = storage.wipe_folder(&mailbox); let _ = storage.update_server_total(&email, &mailbox, 0); if wipe_drafts { let _ = storage.wipe_drafts(); } let _ = storage.clear_folder_pending_wipe(&email, &mailbox); Ok(()) } pub async fn resume_pending_wipes( config: MailServerConfig, email: String, credential: AuthCredential, storage_path: String, ) -> Result<(), MailError> { let storage = crate::storage::Storage::new(&storage_path) .map_err(|e| MailError::Generic(e.to_string()))?; let pending = storage .get_pending_wipes(&email) .map_err(|e| MailError::Generic(e.to_string()))?; for (folder, wd) in pending { let _ = delete_all_messages( config.clone(), email.clone(), credential.clone(), folder, storage_path.clone(), wd, ) .await; } Ok(()) } pub async fn create_folder( config: MailServerConfig, email: String, credential: AuthCredential, 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, credential); client .connect() .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, credential: AuthCredential, 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, credential); client .connect() .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> { Err(crate::types::MailError::Generic("keyring not available".into()))?; Ok(()) .map_err(|e| MailError::Generic(e.to_string())) } pub fn get_password(account: String, service: String) -> Result, MailError> { Ok(None) // keyring not available .map_err(|e| MailError::Generic(e.to_string())) } pub fn delete_credential(account: String, service: String) -> Result<(), MailError> { Ok(()) // keyring not available .map_err(|e| MailError::Generic(e.to_string())) } pub async fn load_older_messages( config: MailServerConfig, email: String, credential: AuthCredential, 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, credential); client .connect() .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, local_total) = storage .get_folder_stats(&email, &mailbox) .map_err(|e| MailError::Generic(e.to_string()))?; Ok(crate::types::FolderStats { unread, server_total, local_total, }) }