From e6df0d451ea25a572949c5f0d871677ae9f2985d Mon Sep 17 00:00:00 2001 From: Eugen Kaparulin Date: Fri, 24 Jul 2026 20:14:32 +0300 Subject: [PATCH] sync: mailcore 0.2.1-beta --- mailcore/src/api.rs | 109 +----------- mailcore/src/imap/client.rs | 58 ------- mailcore/src/lib.rs | 1 - mailcore/src/oauth.rs | 334 ------------------------------------ mailcore/src/smtp/client.rs | 15 -- mailcore/src/types.rs | 5 +- 6 files changed, 3 insertions(+), 519 deletions(-) delete mode 100644 mailcore/src/oauth.rs diff --git a/mailcore/src/api.rs b/mailcore/src/api.rs index c08bf83..435a3da 100644 --- a/mailcore/src/api.rs +++ b/mailcore/src/api.rs @@ -34,7 +34,7 @@ pub fn init_logger() { } /// Step-by-step IMAP connection diagnostic. Returns a multi-line string describing -/// each phase (TCP → TLS → greeting → XOAUTH2 → NOOP) with timing and outcome. +/// 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; @@ -44,6 +44,7 @@ pub async fn diagnose_imap_connection(host: String, port: u32, email: String) -> 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()); @@ -137,63 +138,6 @@ pub async fn diagnose_imap_connection(host: String, port: u32, email: String) -> } }; - // 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 } @@ -1521,52 +1465,3 @@ pub fn get_folder_stats( 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 f3f1a7b..00e3b03 100644 --- a/mailcore/src/imap/client.rs +++ b/mailcore/src/imap/client.rs @@ -10,28 +10,6 @@ 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 { @@ -122,24 +100,6 @@ impl ImapClient { .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 mut client = Client::new(tls_stream); @@ -177,24 +137,6 @@ impl ImapClient { 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); diff --git a/mailcore/src/lib.rs b/mailcore/src/lib.rs index 379fe4b..5319651 100644 --- a/mailcore/src/lib.rs +++ b/mailcore/src/lib.rs @@ -3,7 +3,6 @@ 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 deleted file mode 100644 index 54f7495..0000000 --- a/mailcore/src/oauth.rs +++ /dev/null @@ -1,334 +0,0 @@ -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_desktop_client_id()), - ("client_secret", get_desktop_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/smtp/client.rs b/mailcore/src/smtp/client.rs index 0260db7..1647c69 100644 --- a/mailcore/src/smtp/client.rs +++ b/mailcore/src/smtp/client.rs @@ -112,21 +112,6 @@ impl SmtpClient { 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) diff --git a/mailcore/src/types.rs b/mailcore/src/types.rs index 4a4a014..41119c4 100644 --- a/mailcore/src/types.rs +++ b/mailcore/src/types.rs @@ -2,14 +2,11 @@ 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). +/// `Password` uses plain LOGIN/PLAIN. #[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)]