sync: mailcore 0.2.1-beta

This commit is contained in:
Eugen Kaparulin
2026-07-24 20:14:32 +03:00
parent 467928d541
commit e6df0d451e
6 changed files with 3 additions and 519 deletions

View File

@@ -34,7 +34,7 @@ pub fn init_logger() {
} }
/// Step-by-step IMAP connection diagnostic. Returns a multi-line string describing /// 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. /// 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 { pub async fn diagnose_imap_connection(host: String, port: u32, email: String) -> String {
use std::sync::Arc; use std::sync::Arc;
@@ -44,6 +44,7 @@ pub async fn diagnose_imap_connection(host: String, port: u32, email: String) ->
use tokio_rustls::TlsConnector; use tokio_rustls::TlsConnector;
let mut out = String::new(); let mut out = String::new();
out.push_str(&format!("Diagnosing connection for {}\n", email));
let mut root_cert_store = rustls::RootCertStore::empty(); let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); 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 out
} }
@@ -1521,52 +1465,3 @@ pub fn get_folder_stats(
local_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<Vec<String>, 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<Vec<String>, MailError> {
let (email, access, refresh) =
crate::oauth::exchange_auth_code_desktop(&code, &redirect_uri, &code_verifier).await?;
Ok(vec![email, access, refresh])
}

View File

@@ -10,28 +10,6 @@ use tokio::net::TcpStream;
use tokio_rustls::client::TlsStream; use tokio_rustls::client::TlsStream;
use tokio_rustls::TlsConnector; use tokio_rustls::TlsConnector;
/// XOAUTH2 authenticator for async-imap.
/// The response is the pre-built `user=<email>\x01auth=Bearer <token>\x01\x01` string.
struct XOAuth2 {
response: Vec<u8>,
}
impl async_imap::Authenticator for XOAuth2 {
type Response = Vec<u8>;
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("<binary>"));
Vec::new()
}
}
}
/// Returned by `ImapClient::sync_mailbox`. Contains data needed for post-sync operations /// Returned by `ImapClient::sync_mailbox`. Contains data needed for post-sync operations
/// that must run outside the shared session lock. /// that must run outside the shared session lock.
pub struct SyncResult { pub struct SyncResult {
@@ -122,24 +100,6 @@ impl ImapClient {
.map_err(|_| format!("TLS handshake with {} timed out after 10 seconds", dns_name))? .map_err(|_| format!("TLS handshake with {} timed out after 10 seconds", dns_name))?
.map_err(|e| format!("TLS handshake with {} failed: {}", dns_name, e))?; .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 // Create IMAP client
tracing::debug!("ImapClient: Creating async_imap client..."); tracing::debug!("ImapClient: Creating async_imap client...");
let mut client = Client::new(tls_stream); let mut client = Client::new(tls_stream);
@@ -177,24 +137,6 @@ impl ImapClient {
tracing::error!("ImapClient: Login failed: {}", e); tracing::error!("ImapClient: Login failed: {}", e);
e.to_string() 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); tracing::debug!("ImapClient: Successfully logged in as {}", self.email);

View File

@@ -3,7 +3,6 @@ pub mod api;
pub mod dns; pub mod dns;
pub mod imap; pub mod imap;
pub mod mime; pub mod mime;
pub mod oauth;
pub mod smtp; pub mod smtp;
pub mod storage; pub mod storage;
pub mod types; pub mod types;

View File

@@ -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<Credentials> = OnceLock::new();
static DESKTOP_CREDENTIALS: OnceLock<Credentials> = 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<Mutex<HashMap<String, TokenEntry>>> = OnceLock::new();
fn get_store() -> &'static Mutex<HashMap<String, TokenEntry>> {
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<String, MailError> {
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<String, MailError> {
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(&params)
.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(&params)
.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::<serde_json::Value>(&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(&params)
.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))
}

View File

@@ -112,21 +112,6 @@ impl SmtpClient {
Credentials::new(self.email.clone(), pw.clone()), Credentials::new(self.email.clone(), pw.clone()),
vec![Mechanism::Plain], 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) Ok(SmtpTransport::builder_dangerous(&self.config.smtp_host)

View File

@@ -2,14 +2,11 @@ use flutter_rust_bridge::frb;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Authentication credential for IMAP and SMTP connections. /// Authentication credential for IMAP and SMTP connections.
/// `Password` uses plain LOGIN/PLAIN; `OAuth2` fetches a valid access token /// `Password` uses plain LOGIN/PLAIN.
/// from the in-process token store (populated by Flutter at startup).
#[frb] #[frb]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AuthCredential { pub enum AuthCredential {
Password(String), Password(String),
/// OAuth2 / XOAUTH2 — tokens are held in `oauth::TOKEN_STORE`, keyed by email.
OAuth2,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]