sync: mailcore 0.2.1-beta
This commit is contained in:
@@ -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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¶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::<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(¶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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user