From c48a14d4e8d4301091fc344c7f1639ab2af7a51f Mon Sep 17 00:00:00 2001 From: Eugen Kaparulin Date: Mon, 22 Jun 2026 21:54:48 +0300 Subject: [PATCH] sync: mailcore 0.2.1-beta --- mailcore/Cargo.toml | 6 +- mailcore/examples/test_imap.rs | 11 +- mailcore/src/api.rs | 361 ++++++++++++++++++++++++++------ mailcore/src/imap/client.rs | 142 ++++++++++--- mailcore/src/lib.rs | 1 + mailcore/src/oauth.rs | 334 +++++++++++++++++++++++++++++ mailcore/src/session_manager.rs | 10 +- mailcore/src/smtp/client.rs | 69 +++--- mailcore/src/storage/mod.rs | 9 +- mailcore/src/types.rs | 14 ++ 10 files changed, 827 insertions(+), 130 deletions(-) create mode 100644 mailcore/src/oauth.rs diff --git a/mailcore/Cargo.toml b/mailcore/Cargo.toml index d7ac476..dc74556 100644 --- a/mailcore/Cargo.toml +++ b/mailcore/Cargo.toml @@ -22,7 +22,8 @@ webpki-roots = "0.26" mail-parser = "0.9" mail-builder = "0.3" # HTTP Client -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +base64 = "0.22" sha1 = "0.10" zbase32 = "0.1" @@ -50,7 +51,7 @@ thiserror = "1" # Logging tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } futures = "0.3.31" chrono = "0.4.43" # Removed native-tls dependencies for better cross-platform compatibility @@ -63,6 +64,7 @@ core-foundation = "0.9" [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" +tracing-android = "0.2" [dev-dependencies] tokio-test = "0.4" diff --git a/mailcore/examples/test_imap.rs b/mailcore/examples/test_imap.rs index e56320d..837fbb6 100644 --- a/mailcore/examples/test_imap.rs +++ b/mailcore/examples/test_imap.rs @@ -1,5 +1,6 @@ use mailcore::dns::discover_mail_server; use mailcore::imap::ImapClient; +use mailcore::types::AuthCredential; use std::io::{self, Write}; #[tokio::main] @@ -27,12 +28,16 @@ async fn main() { io::stdout().flush().unwrap(); let mut password = String::new(); io::stdin().read_line(&mut password).unwrap(); - let password = password.trim(); + let password = password.trim().to_string(); - let mut client = ImapClient::new(config, email.to_string()); + let mut client = ImapClient::new( + config, + email.to_string(), + AuthCredential::Password(password), + ); println!("Connecting..."); - match client.connect(password).await { + match client.connect().await { Ok(_) => { println!("✓ Connected and authenticated successfully!"); diff --git a/mailcore/src/api.rs b/mailcore/src/api.rs index 444fb19..c08bf83 100644 --- a/mailcore/src/api.rs +++ b/mailcore/src/api.rs @@ -1,4 +1,4 @@ -use crate::types::{FolderInfo, MailError, MailServerConfig}; +use crate::types::{AuthCredential, FolderInfo, MailError, MailServerConfig}; use flutter_rust_bridge::frb; use mail_parser::MimeHeaders; @@ -13,7 +13,19 @@ lazy_static! { } pub fn init_logger() { - tracing_subscriber::fmt::init(); + #[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. @@ -21,6 +33,170 @@ pub fn init_logger() { 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 → XOAUTH2 → NOOP) 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(); + 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() + )); + } + }; + + // Phase 4: Token fetch + let t3 = Instant::now(); + let token = match tokio::time::timeout( + Duration::from_secs(10), + crate::oauth::get_valid_token(&email), + ) + .await + { + Err(_) => { + out.push_str(&format!("OAuth2 token: TIMEOUT after 10s\n")); + return out; + } + Ok(Err(e)) => { + out.push_str(&format!("OAuth2 token: FAILED ({}) in {:.1}s\n", e, t3.elapsed().as_secs_f32())); + return out; + } + Ok(Ok(t)) => { + out.push_str(&format!( + "OAuth2 token: OK in {:.1}s (len={})\n", + t3.elapsed().as_secs_f32(), + t.len() + )); + t + } + }; + + // Phase 5: XOAUTH2 authenticate (raw IMAP) + use tokio::io::AsyncWriteExt; + let auth_str = format!("user={}\x01auth=Bearer {}\x01\x01", email, token); + use base64::Engine as _; + let encoded = base64::engine::general_purpose::STANDARD.encode(auth_str.as_bytes()); + let cmd = format!("A001 AUTHENTICATE XOAUTH2 {}\r\n", encoded); + + let t4 = Instant::now(); + if let Err(e) = tokio::time::timeout(Duration::from_secs(5), tls_stream.write_all(cmd.as_bytes())).await { + out.push_str(&format!("AUTHENTICATE write: {:?}\n", e)); + return out; + } + + let mut resp = vec![0u8; 2048]; + match tokio::time::timeout(Duration::from_secs(10), tls_stream.read(&mut resp)).await { + Err(_) => { + out.push_str(&format!("AUTHENTICATE response: TIMEOUT after 10s in {:.1}s\n", t4.elapsed().as_secs_f32())); + } + Ok(Err(e)) => { + out.push_str(&format!("AUTHENTICATE response: FAILED ({}) in {:.1}s\n", e, t4.elapsed().as_secs_f32())); + } + Ok(Ok(n)) => { + let line = String::from_utf8_lossy(&resp[..n.min(200)]).to_string(); + out.push_str(&format!( + "AUTHENTICATE response in {:.1}s: {:?}\n", + t4.elapsed().as_secs_f32(), + line.trim() + )); + } + } + + out +} + pub async fn discover_server(email: String) -> Result { crate::dns::discover_mail_server(&email) .await @@ -31,12 +207,12 @@ pub async fn discover_server(email: String) -> Result 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 client_arc = crate::session_manager::get_client(config, email, credential).await?; let mut client = client_arc.lock().await; let mut folders = client @@ -67,7 +243,7 @@ pub fn get_total_unread_count(storage_path: String) -> i32 { pub async fn get_messages( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, ) -> Result, MailError> { let mailbox = if mailbox.to_uppercase() == "INBOX" { @@ -76,7 +252,7 @@ pub async fn get_messages( mailbox }; - let client_arc = crate::session_manager::get_client(config, email, password).await?; + let client_arc = crate::session_manager::get_client(config, email, credential).await?; let mut client = client_arc.lock().await; client @@ -92,7 +268,7 @@ pub async fn get_messages( pub async fn get_message_body( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uid: u32, storage_path: String, @@ -117,9 +293,9 @@ pub async fn get_message_body( body }) } else { - let mut client = crate::imap::ImapClient::new(config.clone(), email.clone()); + let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -161,7 +337,7 @@ pub async fn get_message_body( pub async fn set_message_flag( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uid: u32, flag: Option, @@ -176,9 +352,9 @@ pub async fn set_message_flag( } // Dedicated connection — never blocked by sync_mailbox holding the shared session lock. - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -231,11 +407,11 @@ pub fn get_contact_by_email( pub async fn append_to_sent( config: &MailServerConfig, email: &str, - password: &str, + credential: AuthCredential, message_bytes: &[u8], ) { - let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string()); - if let Err(e) = client.connect(password).await { + 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; } @@ -258,7 +434,7 @@ pub async fn append_to_sent( pub async fn send_email( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, to: String, subject: String, body: String, @@ -272,19 +448,19 @@ pub async fn send_email( 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) + 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, &password, &message_bytes).await; + append_to_sent(&config, &email, credential, &message_bytes).await; Ok(()) } pub async fn prefetch_bodies( config: crate::types::MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uids: Vec, storage_path: String, @@ -309,9 +485,9 @@ pub async fn prefetch_bodies( } // Use a DEDICATED connection so prefetch never blocks user-initiated fetches - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; client @@ -561,7 +737,7 @@ fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, pub async fn sync_mailbox( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, storage_path: String, cache_days: i64, @@ -583,7 +759,7 @@ pub async fn sync_mailbox( // 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()) + crate::session_manager::get_client(config.clone(), email.clone(), credential.clone()) .await?; let mut client = client_arc.lock().await; client @@ -595,8 +771,8 @@ pub async fn sync_mailbox( // 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 { + 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); @@ -618,7 +794,7 @@ pub async fn sync_mailbox( pub async fn listen_for_updates( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, ) -> Result, MailError> { let mailbox = if mailbox.to_uppercase() == "INBOX" { @@ -627,9 +803,9 @@ pub async fn listen_for_updates( mailbox }; - let mut client = crate::imap::ImapClient::new(config.clone(), email.clone()); + let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential.clone()); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; client @@ -637,7 +813,6 @@ pub async fn listen_for_updates( .await .map_err(|e| MailError::Network(e.to_string()))?; - let password_clone = password.clone(); let mailbox_clone = mailbox.clone(); let email_clone = email.clone(); @@ -674,7 +849,7 @@ pub async fn listen_for_updates( 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 { + if let Err(re_err) = client.reconnect().await { tracing::error!("IDLE reconnect failed: {}", re_err); break; } @@ -754,12 +929,12 @@ pub fn delete_draft(path: String, id: i64) -> Result<(), MailError> { pub async fn sync_drafts( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, storage_path: String, ) -> Result<(), MailError> { - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -869,7 +1044,7 @@ pub async fn sync_drafts( pub async fn move_message( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, from_mailbox: String, to_mailbox: String, uid: u32, @@ -885,9 +1060,9 @@ pub async fn move_message( } else { to_mailbox }; - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -913,7 +1088,7 @@ pub async fn move_message( pub async fn move_messages( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, from_mailbox: String, to_mailbox: String, uids: Vec, @@ -933,9 +1108,9 @@ pub async fn move_messages( return Ok(()); } - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -961,7 +1136,7 @@ pub async fn move_messages( pub async fn delete_messages_permanently( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uids: Vec, storage_path: String, @@ -969,9 +1144,9 @@ pub async fn delete_messages_permanently( if uids.is_empty() { return Ok(()); } - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -1009,7 +1184,7 @@ pub fn mark_as_read_local( pub async fn mark_as_read( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uid: u32, storage_path: String, @@ -1020,9 +1195,9 @@ pub async fn mark_as_read( mailbox }; - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -1045,7 +1220,7 @@ pub async fn mark_as_read( pub async fn mark_as_unread( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, uid: u32, storage_path: String, @@ -1056,9 +1231,9 @@ pub async fn mark_as_unread( mailbox }; - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -1081,7 +1256,7 @@ pub async fn mark_as_unread( pub async fn mark_all_as_read( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, storage_path: String, ) -> Result<(), MailError> { @@ -1091,9 +1266,9 @@ pub async fn mark_all_as_read( mailbox }; - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -1135,7 +1310,7 @@ pub fn prepare_delete_all_messages( pub async fn delete_all_messages( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, storage_path: String, wipe_drafts: bool, @@ -1146,9 +1321,9 @@ pub async fn delete_all_messages( mailbox }; - let mut client = crate::imap::ImapClient::new(config, email.clone()); + let mut client = crate::imap::ImapClient::new(config, email.clone(), credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; @@ -1177,7 +1352,7 @@ pub async fn delete_all_messages( pub async fn resume_pending_wipes( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, storage_path: String, ) -> Result<(), MailError> { let storage = crate::storage::Storage::new(&storage_path) @@ -1189,7 +1364,7 @@ pub async fn resume_pending_wipes( let _ = delete_all_messages( config.clone(), email.clone(), - password.clone(), + credential.clone(), folder, storage_path.clone(), wd, @@ -1202,7 +1377,7 @@ pub async fn resume_pending_wipes( pub async fn create_folder( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, folder_name: String, ) -> Result<(), MailError> { let folder_name = if folder_name.to_uppercase() == "INBOX" { @@ -1210,9 +1385,9 @@ pub async fn create_folder( } else { folder_name }; - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; let result = client.create_folder(&folder_name).await.map_err(|e| { @@ -1225,7 +1400,7 @@ pub async fn create_folder( pub async fn delete_folder( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, folder_name: String, storage_path: String, ) -> Result<(), MailError> { @@ -1234,9 +1409,9 @@ pub async fn delete_folder( } else { folder_name }; - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; let result = client.delete_folder(&folder_name).await.map_err(|e| { @@ -1270,7 +1445,7 @@ pub fn delete_credential(account: String, service: String) -> Result<(), MailErr pub async fn load_older_messages( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, mailbox: String, oldest_uid: u32, batch_size: u32, @@ -1284,9 +1459,9 @@ pub async fn load_older_messages( let local_uids = storage.get_uids(&mailbox).unwrap_or_default(); - let mut client = crate::imap::ImapClient::new(config, email); + let mut client = crate::imap::ImapClient::new(config, email, credential); client - .connect(&password) + .connect() .await .map_err(|e| MailError::Network(e.to_string()))?; client @@ -1337,11 +1512,61 @@ pub fn get_folder_stats( ) -> Result { let storage = crate::storage::Storage::new(&storage_path) .map_err(|e| MailError::Generic(e.to_string()))?; - let (unread, server_total) = storage + 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, }) } + +/// Populate the in-memory OAuth2 token store from Flutter secure storage. +/// Call at startup for every OAuth2 account with the persisted refresh token. +/// Rust will perform the first token refresh automatically on the next IMAP connect. +pub fn init_oauth2_session(email: String, refresh_token: String) { + crate::oauth::init_session(&email, &refresh_token); +} + +/// Remove all OAuth2 tokens for an account from the in-memory store. +/// Call when removing an OAuth2 account. +pub fn delete_oauth2_tokens(email: String) -> Result<(), MailError> { + crate::oauth::delete_tokens(&email); + Ok(()) +} + +/// Provide OAuth2 client credentials. Must be called once at startup before +/// any Google sign-in or token refresh. Lives in the private mailmore layer. +pub fn init_google_credentials(client_id: String, client_secret: String) { + crate::oauth::init_credentials(&client_id, &client_secret); +} + +/// Provide Desktop/mobile PKCE client credentials for browser-based OAuth flow. +pub fn init_google_desktop_credentials(client_id: String, client_secret: String) { + crate::oauth::init_desktop_credentials(&client_id, &client_secret); +} + +/// Exchange a Google server-side authorization code for access + refresh tokens. +/// Returns (access_token, refresh_token) — the caller must persist the refresh +/// token via Flutter secure storage for use across app restarts. +pub async fn exchange_google_auth_code( + email: String, + server_auth_code: String, +) -> Result, MailError> { + let (access, refresh) = + crate::oauth::exchange_auth_code(&email, &server_auth_code).await?; + Ok(vec![access, refresh]) +} + +/// Exchange an authorization code from the desktop PKCE flow. +/// Returns (email, access_token, refresh_token). +pub async fn exchange_google_auth_code_desktop( + code: String, + redirect_uri: String, + code_verifier: String, +) -> Result, MailError> { + let (email, access, refresh) = + crate::oauth::exchange_auth_code_desktop(&code, &redirect_uri, &code_verifier).await?; + Ok(vec![email, access, refresh]) +} diff --git a/mailcore/src/imap/client.rs b/mailcore/src/imap/client.rs index d642e08..f3f1a7b 100644 --- a/mailcore/src/imap/client.rs +++ b/mailcore/src/imap/client.rs @@ -1,4 +1,4 @@ -use crate::types::{FolderInfo, FolderType, MailServerConfig}; +use crate::types::{AuthCredential, FolderInfo, FolderType, MailServerConfig}; use async_imap::extensions::idle::IdleResponse; use async_imap::Client; use futures::stream::TryStreamExt; @@ -10,6 +10,28 @@ use tokio::net::TcpStream; use tokio_rustls::client::TlsStream; use tokio_rustls::TlsConnector; +/// XOAUTH2 authenticator for async-imap. +/// The response is the pre-built `user=\x01auth=Bearer \x01\x01` string. +struct XOAuth2 { + response: Vec, +} + +impl async_imap::Authenticator for XOAuth2 { + type Response = Vec; + fn process(&mut self, challenge: &[u8]) -> Self::Response { + // On first call challenge is empty — send the XOAUTH2 payload. + // On subsequent calls Gmail has sent a base64-encoded error; send an + // empty response to abort so the server returns NO instead of hanging. + if challenge.is_empty() { + self.response.clone() + } else { + tracing::warn!("XOAUTH2: received non-empty challenge (auth error): {:?}", + std::str::from_utf8(challenge).unwrap_or("")); + Vec::new() + } + } +} + /// Returned by `ImapClient::sync_mailbox`. Contains data needed for post-sync operations /// that must run outside the shared session lock. pub struct SyncResult { @@ -25,18 +47,20 @@ pub struct ImapClient { session: Option>>, config: MailServerConfig, email: String, + auth: AuthCredential, } impl ImapClient { - pub fn new(config: MailServerConfig, email: String) -> Self { + pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self { Self { session: None, config, email, + auth, } } - pub async fn connect(&mut self, password: &str) -> Result<(), Box> { + pub async fn connect(&mut self) -> Result<(), Box> { tracing::debug!( "ImapClient: Starting connection to {}:{}", self.config.imap_host, @@ -46,16 +70,20 @@ impl ImapClient { 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); + // Skip rustls_native_certs on Android — the cert store path scan blocks + // or returns nothing useful; webpki_roots covers all major CAs. + #[cfg(not(target_os = "android"))] + { + tracing::debug!("ImapClient: Loading 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); } - } - Err(e) => { - tracing::warn!("ImapClient: Failed to load native certs: {}", e); } } @@ -86,33 +114,100 @@ impl ImapClient { .to_owned(); tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name); - let tls_stream = connector.connect(server_name, stream).await?; + let tls_stream = tokio::time::timeout( + Duration::from_secs(10), + connector.connect(server_name, stream), + ) + .await + .map_err(|_| format!("TLS handshake with {} timed out after 10 seconds", dns_name))? + .map_err(|e| format!("TLS handshake with {} failed: {}", dns_name, e))?; + + // For OAuth2: fetch the token BEFORE the IMAP handshake so auth errors + // are clearly separated from connection errors. + let pre_fetched_token = match &self.auth { + AuthCredential::OAuth2 => { + tracing::debug!("ImapClient: Pre-fetching OAuth2 token for {}...", self.email); + let token = tokio::time::timeout( + Duration::from_secs(10), + crate::oauth::get_valid_token(&self.email), + ) + .await + .map_err(|_| "OAuth2 token fetch timed out after 10s")? + .map_err(|e| e.to_string())?; + tracing::debug!("ImapClient: OAuth2 token obtained for {}", self.email); + Some(token) + } + _ => None, + }; // Create IMAP client tracing::debug!("ImapClient: Creating async_imap client..."); - let client = Client::new(tls_stream); + let mut client = Client::new(tls_stream); - // Login + // async_imap does NOT read the server greeting automatically. The greeting must be + // consumed before sending any command; otherwise authenticate()'s do_auth_handshake() + // reads it instead of the "+" continuation and hangs indefinitely. + tracing::debug!("ImapClient: Reading server greeting for {}...", self.config.imap_host); + match tokio::time::timeout(Duration::from_secs(10), client.read_response()).await { + Ok(Some(Ok(greeting))) => { + tracing::debug!("ImapClient: Server greeting: {:?}", greeting.parsed()); + } + Ok(Some(Err(e))) => { + return Err(format!("Failed to read server greeting: {}", e).into()); + } + Ok(None) => { + return Err("Server closed connection before greeting".into()); + } + Err(_) => { + return Err("Timed out waiting for server greeting".into()); + } + } + + // Authenticate — explicit timeout so the caller gets a specific error message + // rather than waiting for the session_manager's global 20s wall-clock. tracing::debug!("ImapClient: Attempting login for {}...", self.email); - let session = client - .login(&self.email, password) + let session = match &self.auth.clone() { + AuthCredential::Password(pw) => tokio::time::timeout( + Duration::from_secs(15), + client.login(&self.email, pw), + ) .await + .map_err(|_| "IMAP login timed out after 15s".to_string())? .map_err(|(e, _client)| { tracing::error!("ImapClient: Login failed: {}", e); e.to_string() - })?; + })?, + AuthCredential::OAuth2 => { + let token = pre_fetched_token.unwrap(); + let raw = format!( + "user={}\x01auth=Bearer {}\x01\x01", + self.email, token + ); + tracing::debug!("ImapClient: Sending XOAUTH2 AUTHENTICATE for {}...", self.email); + tokio::time::timeout( + Duration::from_secs(15), + client.authenticate("XOAUTH2", XOAuth2 { response: raw.into_bytes() }), + ) + .await + .map_err(|_| "XOAUTH2 authenticate timed out after 15s".to_string())? + .map_err(|(e, _client)| { + tracing::error!("ImapClient: XOAUTH2 auth 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> { + pub async fn reconnect(&mut self) -> Result<(), Box> { // Force close existing session if any if let Some(mut session) = self.session.take() { let _ = session.logout().await; } - self.connect(password).await + self.connect().await } pub async fn logout(&mut self) -> Result<(), Box> { @@ -139,13 +234,10 @@ impl ImapClient { } /// Use this before any operation to ensure we have a valid session. - pub async fn ensure_connected( - &mut self, - password: &str, - ) -> Result<(), Box> { + pub async fn ensure_connected(&mut self) -> Result<(), Box> { if !self.keep_alive().await { tracing::debug!("Reconnecting session for {}", self.email); - self.reconnect(password).await?; + self.reconnect().await?; } Ok(()) } diff --git a/mailcore/src/lib.rs b/mailcore/src/lib.rs index 5319651..379fe4b 100644 --- a/mailcore/src/lib.rs +++ b/mailcore/src/lib.rs @@ -3,6 +3,7 @@ pub mod api; pub mod dns; pub mod imap; pub mod mime; +pub mod oauth; pub mod smtp; pub mod storage; pub mod types; diff --git a/mailcore/src/oauth.rs b/mailcore/src/oauth.rs new file mode 100644 index 0000000..2d4e357 --- /dev/null +++ b/mailcore/src/oauth.rs @@ -0,0 +1,334 @@ +use crate::types::MailError; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +struct Credentials { + client_id: String, + client_secret: String, +} + +static CREDENTIALS: OnceLock = OnceLock::new(); +static DESKTOP_CREDENTIALS: OnceLock = OnceLock::new(); + +/// Must be called once at startup (from mailmore) before any OAuth operation. +pub fn init_credentials(client_id: &str, client_secret: &str) { + let _ = CREDENTIALS.set(Credentials { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + }); +} + +/// Desktop/mobile PKCE client credentials (loopback redirect, any port). +pub fn init_desktop_credentials(client_id: &str, client_secret: &str) { + let _ = DESKTOP_CREDENTIALS.set(Credentials { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + }); +} + +fn get_client_id() -> &'static str { + CREDENTIALS + .get() + .map(|c| c.client_id.as_str()) + .unwrap_or("") +} + +fn get_client_secret() -> &'static str { + CREDENTIALS + .get() + .map(|c| c.client_secret.as_str()) + .unwrap_or("") +} + +fn get_desktop_client_id() -> &'static str { + DESKTOP_CREDENTIALS + .get() + .map(|c| c.client_id.as_str()) + .unwrap_or_else(|| get_client_id()) +} + +fn get_desktop_client_secret() -> &'static str { + DESKTOP_CREDENTIALS + .get() + .map(|c| c.client_secret.as_str()) + .unwrap_or_else(|| get_client_secret()) +} + +struct TokenEntry { + access_token: String, + refresh_token: String, + expires_at: i64, +} + +static TOKEN_STORE: OnceLock>> = OnceLock::new(); + +fn get_store() -> &'static Mutex> { + TOKEN_STORE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Populate the in-memory token store from Flutter secure storage. +/// Call once at startup for every OAuth2 account, passing the persisted +/// refresh token (access token and expiry start zeroed; first IMAP connect +/// will refresh automatically). +pub fn init_session(email: &str, refresh_token: &str) { + let mut store = get_store().lock().unwrap(); + let entry = store.entry(email.to_string()).or_insert(TokenEntry { + access_token: String::new(), + refresh_token: String::new(), + expires_at: 0, + }); + // Only update if the refresh token is non-empty (don't clobber a live entry). + if !refresh_token.is_empty() { + entry.refresh_token = refresh_token.to_string(); + } +} + +/// Write all three token fields to the in-memory store. +pub fn store_tokens( + email: &str, + access_token: &str, + refresh_token: &str, + expires_at: i64, +) { + let mut store = get_store().lock().unwrap(); + store.insert( + email.to_string(), + TokenEntry { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at, + }, + ); +} + +/// Remove the token entry for an account. +pub fn delete_tokens(email: &str) { + get_store().lock().unwrap().remove(email); +} + +/// Return a valid access token, refreshing if within 60 seconds of expiry. +pub async fn get_valid_token(email: &str) -> Result { + let (access, expires_at) = { + let store = get_store().lock().unwrap(); + let entry = store.get(email).ok_or_else(|| { + MailError::Auth(format!("No OAuth2 session for {}", email)) + })?; + (entry.access_token.clone(), entry.expires_at) + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + if !access.is_empty() && expires_at - now > 60 { + return Ok(access); + } + + tracing::debug!("OAuth2 token for {} is expired or missing, refreshing", email); + refresh_access_token(email).await +} + +/// Refresh the access token using the stored refresh token. +/// Updates the in-memory store and returns the new access token. +pub async fn refresh_access_token(email: &str) -> Result { + let refresh = { + let store = get_store().lock().unwrap(); + store + .get(email) + .map(|e| e.refresh_token.clone()) + .filter(|r| !r.is_empty()) + .ok_or_else(|| { + MailError::Auth(format!("No OAuth2 refresh token for {}", email)) + })? + }; + + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", &refresh), + ("client_id", get_client_id()), + ("client_secret", get_client_secret()), + ]; + + let resp = reqwest::Client::new() + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let new_access = body["access_token"] + .as_str() + .ok_or_else(|| { + MailError::TokenRefreshFailed(format!( + "No access_token in refresh response: {}", + body + )) + })? + .to_string(); + + let expires_in = body["expires_in"].as_i64().unwrap_or(3600); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + store_tokens(email, &new_access, &refresh, now + expires_in); + + Ok(new_access) +} + +/// Exchange an authorization code from the desktop PKCE flow for tokens. +/// Also resolves the user email from the id_token or userinfo endpoint. +/// Returns (email, access_token, refresh_token). +pub async fn exchange_auth_code_desktop( + code: &str, + redirect_uri: &str, + code_verifier: &str, +) -> Result<(String, String, String), MailError> { + let client = reqwest::Client::new(); + + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("client_id", get_desktop_client_id()), + ("client_secret", get_desktop_client_secret()), + ("redirect_uri", redirect_uri), + ("code_verifier", code_verifier), + ]; + + let resp = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let access_token = body["access_token"] + .as_str() + .ok_or_else(|| { + MailError::TokenRefreshFailed(format!("No access_token in desktop exchange: {}", body)) + })? + .to_string(); + + let refresh_token = body["refresh_token"] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_default(); + + // Try to extract email from the id_token JWT payload (base64url-decode the middle part) + let email = body["id_token"] + .as_str() + .and_then(|jwt| { + let parts: Vec<&str> = jwt.split('.').collect(); + parts.get(1).and_then(|payload| { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|json| json["email"].as_str().map(|s| s.to_string())) + }) + }); + + let email = match email { + Some(e) => e, + None => { + // Fallback: call userinfo endpoint + let userinfo: serde_json::Value = client + .get("https://www.googleapis.com/oauth2/v1/userinfo") + .bearer_auth(&access_token) + .send() + .await + .map_err(|e| MailError::TokenRefreshFailed(format!("userinfo fetch failed: {}", e)))? + .json() + .await + .map_err(|e| MailError::TokenRefreshFailed(format!("userinfo parse failed: {}", e)))?; + userinfo["email"] + .as_str() + .ok_or_else(|| { + MailError::TokenRefreshFailed("No email in userinfo response".to_string()) + })? + .to_string() + } + }; + + let expires_in = body["expires_in"].as_i64().unwrap_or(3600); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + store_tokens(&email, &access_token, &refresh_token, now + expires_in); + + Ok((email, access_token, refresh_token)) +} + +/// Exchange a server-side authorization code for access + refresh tokens. +/// Returns (access_token, refresh_token) so the caller can persist the +/// refresh token via Flutter secure storage. +pub async fn exchange_auth_code( + email: &str, + server_auth_code: &str, +) -> Result<(String, String), MailError> { + let params = [ + ("grant_type", "authorization_code"), + ("code", server_auth_code), + ("client_id", get_client_id()), + ("client_secret", get_client_secret()), + ("redirect_uri", ""), + ]; + + let resp = reqwest::Client::new() + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| MailError::TokenRefreshFailed(e.to_string()))?; + + let access_token = body["access_token"] + .as_str() + .ok_or_else(|| { + MailError::TokenRefreshFailed(format!( + "No access_token in auth code exchange response: {}", + body + )) + })? + .to_string(); + + // Google only returns refresh_token on first consent. Fall back to any + // previously stored refresh token if the exchange omits it. + let refresh_token = body["refresh_token"].as_str().map(|s| s.to_string()) + .or_else(|| { + get_store().lock().unwrap() + .get(email) + .map(|e| e.refresh_token.clone()) + .filter(|r| !r.is_empty()) + }) + .unwrap_or_default(); + + let expires_in = body["expires_in"].as_i64().unwrap_or(3600); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + store_tokens(email, &access_token, &refresh_token, now + expires_in); + + Ok((access_token, refresh_token)) +} diff --git a/mailcore/src/session_manager.rs b/mailcore/src/session_manager.rs index 67e701a..00f2526 100644 --- a/mailcore/src/session_manager.rs +++ b/mailcore/src/session_manager.rs @@ -1,5 +1,5 @@ use crate::imap::ImapClient; -use crate::types::MailServerConfig; +use crate::types::{AuthCredential, MailServerConfig}; use lazy_static::lazy_static; use std::collections::HashMap; use std::sync::Arc; @@ -16,7 +16,7 @@ lazy_static! { pub async fn get_client( config: MailServerConfig, email: String, - password: String, + credential: AuthCredential, ) -> Result>, crate::types::MailError> { // Atomic get-or-insert to avoid race conditions on startup let (client_arc, is_new) = { @@ -24,7 +24,7 @@ pub async fn get_client( if let Some(arc) = sessions.get(&email) { (arc.clone(), false) } else { - let client = ImapClient::new(config, email.clone()); + let client = ImapClient::new(config, email.clone(), credential); let arc = Arc::new(Mutex::new(client)); sessions.insert(email.clone(), arc.clone()); (arc, true) @@ -40,7 +40,7 @@ pub async fn get_client( ); if let Err(e) = tokio::time::timeout( std::time::Duration::from_secs(20), - client.connect(&password), + client.connect(), ) .await .unwrap_or_else(|_| Err("Connection timeout".into())) @@ -72,7 +72,7 @@ pub async fn get_client( ); if let Err(e) = tokio::time::timeout( std::time::Duration::from_secs(15), - client.reconnect(&password), + client.reconnect(), ) .await .unwrap_or_else(|_| Err("Reconnect timeout".into())) diff --git a/mailcore/src/smtp/client.rs b/mailcore/src/smtp/client.rs index 623583c..0260db7 100644 --- a/mailcore/src/smtp/client.rs +++ b/mailcore/src/smtp/client.rs @@ -1,21 +1,22 @@ -use crate::types::MailServerConfig; +use crate::types::{AuthCredential, MailServerConfig}; use lettre::message::{MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Mechanism; use lettre::{Message, SmtpTransport, Transport}; use std::error::Error; pub struct SmtpClient { config: MailServerConfig, email: String, + auth: AuthCredential, } impl SmtpClient { - pub fn new(config: MailServerConfig, email: String) -> Self { - Self { config, email } + pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self { + Self { config, email, auth } } pub async fn send_mail( &self, - password: &str, to: &str, subject: &str, body_text: &str, @@ -51,11 +52,6 @@ impl SmtpClient { 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 @@ -65,11 +61,7 @@ impl SmtpClient { Tls::Opportunistic(tls_parameters) }; - let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host) - .port(self.config.smtp_port) - .tls(tls) - .credentials(creds) - .build(); + let mailer = self.build_transport(tls)?; let raw_bytes = email.formatted(); mailer.send(&email)?; @@ -79,18 +71,12 @@ impl SmtpClient { 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 { @@ -99,11 +85,7 @@ impl SmtpClient { Tls::Opportunistic(tls_parameters) }; - let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host) - .port(self.config.smtp_port) - .tls(tls) - .credentials(creds) - .build(); + let mailer = self.build_transport(tls)?; // Build a manual message from raw bytes; `to` may be comma-separated let recipients: Vec = to @@ -117,4 +99,41 @@ impl SmtpClient { Ok(()) } + + /// Build a configured `SmtpTransport` using the stored auth credential. + fn build_transport( + &self, + tls: lettre::transport::smtp::client::Tls, + ) -> Result> { + use lettre::transport::smtp::authentication::Credentials; + + let (creds, mechanisms) = match &self.auth { + AuthCredential::Password(pw) => ( + Credentials::new(self.email.clone(), pw.clone()), + vec![Mechanism::Plain], + ), + AuthCredential::OAuth2 => { + // Token refresh is async; we call the sync blocking variant here. + // `get_valid_token` is async, so we run it on the current tokio runtime. + // smtp::send_mail is called from async context, so we can use + // tokio::task::block_in_place to avoid blocking the async executor. + let token = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + crate::oauth::get_valid_token(&self.email) + ) + }).map_err(|e| e.to_string())?; + ( + Credentials::new(self.email.clone(), token), + vec![Mechanism::Xoauth2], + ) + } + }; + + Ok(SmtpTransport::builder_dangerous(&self.config.smtp_host) + .port(self.config.smtp_port) + .tls(tls) + .credentials(creds) + .authentication(mechanisms) + .build()) + } } diff --git a/mailcore/src/storage/mod.rs b/mailcore/src/storage/mod.rs index d0fac15..389ed8b 100644 --- a/mailcore/src/storage/mod.rs +++ b/mailcore/src/storage/mod.rs @@ -814,7 +814,7 @@ impl Storage { &self, account: &str, folder: &str, - ) -> Result<(i32, i32), Box> { + ) -> Result<(i32, 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", @@ -828,7 +828,12 @@ impl Storage { |row| row.get(0), ) .unwrap_or(0); - Ok((unread, server_total)) + let local_total: i32 = conn.query_row( + "SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1", + params![folder], + |row| row.get(0), + )?; + Ok((unread, server_total, local_total)) } pub fn get_total_unread_count(&self) -> Result> { diff --git a/mailcore/src/types.rs b/mailcore/src/types.rs index 32c5154..4a4a014 100644 --- a/mailcore/src/types.rs +++ b/mailcore/src/types.rs @@ -1,6 +1,17 @@ use flutter_rust_bridge::frb; use serde::{Deserialize, Serialize}; +/// Authentication credential for IMAP and SMTP connections. +/// `Password` uses plain LOGIN/PLAIN; `OAuth2` fetches a valid access token +/// from the in-process token store (populated by Flutter at startup). +#[frb] +#[derive(Debug, Clone)] +pub enum AuthCredential { + Password(String), + /// OAuth2 / XOAUTH2 — tokens are held in `oauth::TOKEN_STORE`, keyed by email. + OAuth2, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MailServerConfig { pub imap_host: String, @@ -108,6 +119,7 @@ pub enum MailError { InvalidPassphrase, KeyNotFound(String), Crypto(String), + TokenRefreshFailed(String), } impl std::fmt::Display for MailError { @@ -120,6 +132,7 @@ impl std::fmt::Display for MailError { MailError::InvalidPassphrase => write!(f, "Invalid passphrase"), MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s), MailError::Crypto(s) => write!(f, "Crypto error: {}", s), + MailError::TokenRefreshFailed(s) => write!(f, "Token refresh failed: {}", s), } } } @@ -143,6 +156,7 @@ pub enum FolderType { pub struct FolderStats { pub unread: i32, pub server_total: i32, + pub local_total: i32, } #[frb]