sync: mailcore 0.2.1-beta
This commit is contained in:
@@ -22,7 +22,8 @@ webpki-roots = "0.26"
|
|||||||
mail-parser = "0.9"
|
mail-parser = "0.9"
|
||||||
mail-builder = "0.3"
|
mail-builder = "0.3"
|
||||||
# HTTP Client
|
# 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"
|
sha1 = "0.10"
|
||||||
zbase32 = "0.1"
|
zbase32 = "0.1"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ thiserror = "1"
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
chrono = "0.4.43"
|
chrono = "0.4.43"
|
||||||
# Removed native-tls dependencies for better cross-platform compatibility
|
# Removed native-tls dependencies for better cross-platform compatibility
|
||||||
@@ -63,6 +64,7 @@ core-foundation = "0.9"
|
|||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
jni = "0.21"
|
jni = "0.21"
|
||||||
|
tracing-android = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use mailcore::dns::discover_mail_server;
|
use mailcore::dns::discover_mail_server;
|
||||||
use mailcore::imap::ImapClient;
|
use mailcore::imap::ImapClient;
|
||||||
|
use mailcore::types::AuthCredential;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -27,12 +28,16 @@ async fn main() {
|
|||||||
io::stdout().flush().unwrap();
|
io::stdout().flush().unwrap();
|
||||||
let mut password = String::new();
|
let mut password = String::new();
|
||||||
io::stdin().read_line(&mut password).unwrap();
|
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...");
|
println!("Connecting...");
|
||||||
match client.connect(password).await {
|
match client.connect().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
println!("✓ Connected and authenticated successfully!");
|
println!("✓ Connected and authenticated successfully!");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::types::{FolderInfo, MailError, MailServerConfig};
|
use crate::types::{AuthCredential, FolderInfo, MailError, MailServerConfig};
|
||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
|
|
||||||
use mail_parser::MimeHeaders;
|
use mail_parser::MimeHeaders;
|
||||||
@@ -13,7 +13,19 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_logger() {
|
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
|
// Initialize Rustls with 'ring' as the default provider to prevent crashes
|
||||||
// when multiple providers (ring, aws-lc-rs) are present in the dependency graph.
|
// 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();
|
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<MailServerConfig, MailError> {
|
pub async fn discover_server(email: String) -> Result<MailServerConfig, MailError> {
|
||||||
crate::dns::discover_mail_server(&email)
|
crate::dns::discover_mail_server(&email)
|
||||||
.await
|
.await
|
||||||
@@ -31,12 +207,12 @@ pub async fn discover_server(email: String) -> Result<MailServerConfig, MailErro
|
|||||||
pub async fn connect_and_list_folders(
|
pub async fn connect_and_list_folders(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
show_all_folders: bool,
|
show_all_folders: bool,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<Vec<FolderInfo>, MailError> {
|
) -> Result<Vec<FolderInfo>, MailError> {
|
||||||
// This call will create/cache the session in the manager
|
// 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 client = client_arc.lock().await;
|
||||||
let mut folders = client
|
let mut folders = client
|
||||||
@@ -67,7 +243,7 @@ pub fn get_total_unread_count(storage_path: String) -> i32 {
|
|||||||
pub async fn get_messages(
|
pub async fn get_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
@@ -76,7 +252,7 @@ pub async fn get_messages(
|
|||||||
mailbox
|
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;
|
let mut client = client_arc.lock().await;
|
||||||
client
|
client
|
||||||
@@ -92,7 +268,7 @@ pub async fn get_messages(
|
|||||||
pub async fn get_message_body(
|
pub async fn get_message_body(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -117,9 +293,9 @@ pub async fn get_message_body(
|
|||||||
body
|
body
|
||||||
})
|
})
|
||||||
} else {
|
} 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -161,7 +337,7 @@ pub async fn get_message_body(
|
|||||||
pub async fn set_message_flag(
|
pub async fn set_message_flag(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
flag: Option<String>,
|
flag: Option<String>,
|
||||||
@@ -176,9 +352,9 @@ pub async fn set_message_flag(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dedicated connection — never blocked by sync_mailbox holding the shared session lock.
|
// 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -231,11 +407,11 @@ pub fn get_contact_by_email(
|
|||||||
pub async fn append_to_sent(
|
pub async fn append_to_sent(
|
||||||
config: &MailServerConfig,
|
config: &MailServerConfig,
|
||||||
email: &str,
|
email: &str,
|
||||||
password: &str,
|
credential: AuthCredential,
|
||||||
message_bytes: &[u8],
|
message_bytes: &[u8],
|
||||||
) {
|
) {
|
||||||
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string());
|
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string(), credential);
|
||||||
if let Err(e) = client.connect(password).await {
|
if let Err(e) = client.connect().await {
|
||||||
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
|
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,7 +434,7 @@ pub async fn append_to_sent(
|
|||||||
pub async fn send_email(
|
pub async fn send_email(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
to: String,
|
to: String,
|
||||||
subject: String,
|
subject: String,
|
||||||
body: 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()));
|
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 smtp_client = crate::smtp::SmtpClient::new(config.clone(), email.clone(), credential.clone());
|
||||||
let message_bytes = client
|
let message_bytes = smtp_client
|
||||||
.send_mail(&password, &to, &subject, &body, attachments)
|
.send_mail(&to, &subject, &body, attachments)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn prefetch_bodies(
|
pub async fn prefetch_bodies(
|
||||||
config: crate::types::MailServerConfig,
|
config: crate::types::MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uids: Vec<u32>,
|
uids: Vec<u32>,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -309,9 +485,9 @@ pub async fn prefetch_bodies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use a DEDICATED connection so prefetch never blocks user-initiated fetches
|
// 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
client
|
||||||
@@ -561,7 +737,7 @@ fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus,
|
|||||||
pub async fn sync_mailbox(
|
pub async fn sync_mailbox(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
cache_days: i64,
|
cache_days: i64,
|
||||||
@@ -583,7 +759,7 @@ pub async fn sync_mailbox(
|
|||||||
// Step 1: Run sync on the shared session.
|
// Step 1: Run sync on the shared session.
|
||||||
let sync_result = {
|
let sync_result = {
|
||||||
let client_arc =
|
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?;
|
.await?;
|
||||||
let mut client = client_arc.lock().await;
|
let mut client = client_arc.lock().await;
|
||||||
client
|
client
|
||||||
@@ -595,8 +771,8 @@ pub async fn sync_mailbox(
|
|||||||
// Step 2: FLAG refresh on a dedicated connection.
|
// Step 2: FLAG refresh on a dedicated connection.
|
||||||
let uids = sync_result.uids_for_flag_refresh;
|
let uids = sync_result.uids_for_flag_refresh;
|
||||||
if !uids.is_empty() {
|
if !uids.is_empty() {
|
||||||
let mut dedicated = crate::imap::ImapClient::new(config, email);
|
let mut dedicated = crate::imap::ImapClient::new(config, email, credential);
|
||||||
match dedicated.connect(&password).await {
|
match dedicated.connect().await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
|
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
|
||||||
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
|
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
|
||||||
@@ -618,7 +794,7 @@ pub async fn sync_mailbox(
|
|||||||
pub async fn listen_for_updates(
|
pub async fn listen_for_updates(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
|
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
|
||||||
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
let mailbox = if mailbox.to_uppercase() == "INBOX" {
|
||||||
@@ -627,9 +803,9 @@ pub async fn listen_for_updates(
|
|||||||
mailbox
|
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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
client
|
||||||
@@ -637,7 +813,6 @@ pub async fn listen_for_updates(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
let password_clone = password.clone();
|
|
||||||
let mailbox_clone = mailbox.clone();
|
let mailbox_clone = mailbox.clone();
|
||||||
let email_clone = email.clone();
|
let email_clone = email.clone();
|
||||||
|
|
||||||
@@ -674,7 +849,7 @@ pub async fn listen_for_updates(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("IDLE error for {}: {}", email_clone, e);
|
tracing::error!("IDLE error for {}: {}", email_clone, e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
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);
|
tracing::error!("IDLE reconnect failed: {}", re_err);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -754,12 +929,12 @@ pub fn delete_draft(path: String, id: i64) -> Result<(), MailError> {
|
|||||||
pub async fn sync_drafts(
|
pub async fn sync_drafts(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -869,7 +1044,7 @@ pub async fn sync_drafts(
|
|||||||
pub async fn move_message(
|
pub async fn move_message(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
from_mailbox: String,
|
from_mailbox: String,
|
||||||
to_mailbox: String,
|
to_mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
@@ -885,9 +1060,9 @@ pub async fn move_message(
|
|||||||
} else {
|
} else {
|
||||||
to_mailbox
|
to_mailbox
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -913,7 +1088,7 @@ pub async fn move_message(
|
|||||||
pub async fn move_messages(
|
pub async fn move_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
from_mailbox: String,
|
from_mailbox: String,
|
||||||
to_mailbox: String,
|
to_mailbox: String,
|
||||||
uids: Vec<u32>,
|
uids: Vec<u32>,
|
||||||
@@ -933,9 +1108,9 @@ pub async fn move_messages(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -961,7 +1136,7 @@ pub async fn move_messages(
|
|||||||
pub async fn delete_messages_permanently(
|
pub async fn delete_messages_permanently(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uids: Vec<u32>,
|
uids: Vec<u32>,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -969,9 +1144,9 @@ pub async fn delete_messages_permanently(
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -1009,7 +1184,7 @@ pub fn mark_as_read_local(
|
|||||||
pub async fn mark_as_read(
|
pub async fn mark_as_read(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -1020,9 +1195,9 @@ pub async fn mark_as_read(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -1045,7 +1220,7 @@ pub async fn mark_as_read(
|
|||||||
pub async fn mark_as_unread(
|
pub async fn mark_as_unread(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -1056,9 +1231,9 @@ pub async fn mark_as_unread(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.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(
|
pub async fn mark_all_as_read(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
@@ -1091,9 +1266,9 @@ pub async fn mark_all_as_read(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -1135,7 +1310,7 @@ pub fn prepare_delete_all_messages(
|
|||||||
pub async fn delete_all_messages(
|
pub async fn delete_all_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
wipe_drafts: bool,
|
wipe_drafts: bool,
|
||||||
@@ -1146,9 +1321,9 @@ pub async fn delete_all_messages(
|
|||||||
mailbox
|
mailbox
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = crate::imap::ImapClient::new(config, email.clone());
|
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
|
|
||||||
@@ -1177,7 +1352,7 @@ pub async fn delete_all_messages(
|
|||||||
pub async fn resume_pending_wipes(
|
pub async fn resume_pending_wipes(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let storage = crate::storage::Storage::new(&storage_path)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
@@ -1189,7 +1364,7 @@ pub async fn resume_pending_wipes(
|
|||||||
let _ = delete_all_messages(
|
let _ = delete_all_messages(
|
||||||
config.clone(),
|
config.clone(),
|
||||||
email.clone(),
|
email.clone(),
|
||||||
password.clone(),
|
credential.clone(),
|
||||||
folder,
|
folder,
|
||||||
storage_path.clone(),
|
storage_path.clone(),
|
||||||
wd,
|
wd,
|
||||||
@@ -1202,7 +1377,7 @@ pub async fn resume_pending_wipes(
|
|||||||
pub async fn create_folder(
|
pub async fn create_folder(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
folder_name: String,
|
folder_name: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
let folder_name = if folder_name.to_uppercase() == "INBOX" {
|
let folder_name = if folder_name.to_uppercase() == "INBOX" {
|
||||||
@@ -1210,9 +1385,9 @@ pub async fn create_folder(
|
|||||||
} else {
|
} else {
|
||||||
folder_name
|
folder_name
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
let result = client.create_folder(&folder_name).await.map_err(|e| {
|
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(
|
pub async fn delete_folder(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
folder_name: String,
|
folder_name: String,
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
) -> Result<(), MailError> {
|
) -> Result<(), MailError> {
|
||||||
@@ -1234,9 +1409,9 @@ pub async fn delete_folder(
|
|||||||
} else {
|
} else {
|
||||||
folder_name
|
folder_name
|
||||||
};
|
};
|
||||||
let mut client = crate::imap::ImapClient::new(config, email);
|
let mut client = crate::imap::ImapClient::new(config, email, credential);
|
||||||
client
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
let result = client.delete_folder(&folder_name).await.map_err(|e| {
|
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(
|
pub async fn load_older_messages(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
mailbox: String,
|
mailbox: String,
|
||||||
oldest_uid: u32,
|
oldest_uid: u32,
|
||||||
batch_size: 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 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
|
client
|
||||||
.connect(&password)
|
.connect()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MailError::Network(e.to_string()))?;
|
.map_err(|e| MailError::Network(e.to_string()))?;
|
||||||
client
|
client
|
||||||
@@ -1337,11 +1512,61 @@ pub fn get_folder_stats(
|
|||||||
) -> Result<crate::types::FolderStats, MailError> {
|
) -> Result<crate::types::FolderStats, MailError> {
|
||||||
let storage = crate::storage::Storage::new(&storage_path)
|
let storage = crate::storage::Storage::new(&storage_path)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.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)
|
.get_folder_stats(&email, &mailbox)
|
||||||
.map_err(|e| MailError::Generic(e.to_string()))?;
|
.map_err(|e| MailError::Generic(e.to_string()))?;
|
||||||
Ok(crate::types::FolderStats {
|
Ok(crate::types::FolderStats {
|
||||||
unread,
|
unread,
|
||||||
server_total,
|
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<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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::extensions::idle::IdleResponse;
|
||||||
use async_imap::Client;
|
use async_imap::Client;
|
||||||
use futures::stream::TryStreamExt;
|
use futures::stream::TryStreamExt;
|
||||||
@@ -10,6 +10,28 @@ 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 {
|
||||||
@@ -25,18 +47,20 @@ pub struct ImapClient {
|
|||||||
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
|
auth: AuthCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImapClient {
|
impl ImapClient {
|
||||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||||
Self {
|
Self {
|
||||||
session: None,
|
session: None,
|
||||||
config,
|
config,
|
||||||
email,
|
email,
|
||||||
|
auth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
pub async fn connect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"ImapClient: Starting connection to {}:{}",
|
"ImapClient: Starting connection to {}:{}",
|
||||||
self.config.imap_host,
|
self.config.imap_host,
|
||||||
@@ -46,16 +70,20 @@ impl ImapClient {
|
|||||||
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());
|
||||||
|
|
||||||
tracing::debug!("ImapClient: Loading native certs...");
|
// Skip rustls_native_certs on Android — the cert store path scan blocks
|
||||||
// Load native certs
|
// or returns nothing useful; webpki_roots covers all major CAs.
|
||||||
match rustls_native_certs::load_native_certs() {
|
#[cfg(not(target_os = "android"))]
|
||||||
Ok(certs) => {
|
{
|
||||||
for cert in certs {
|
tracing::debug!("ImapClient: Loading native certs...");
|
||||||
let _ = root_cert_store.add(cert);
|
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();
|
.to_owned();
|
||||||
|
|
||||||
tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name);
|
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
|
// Create IMAP client
|
||||||
tracing::debug!("ImapClient: Creating async_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);
|
tracing::debug!("ImapClient: Attempting login for {}...", self.email);
|
||||||
let session = client
|
let session = match &self.auth.clone() {
|
||||||
.login(&self.email, password)
|
AuthCredential::Password(pw) => tokio::time::timeout(
|
||||||
|
Duration::from_secs(15),
|
||||||
|
client.login(&self.email, pw),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| "IMAP login timed out after 15s".to_string())?
|
||||||
.map_err(|(e, _client)| {
|
.map_err(|(e, _client)| {
|
||||||
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);
|
||||||
self.session = Some(session);
|
self.session = Some(session);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reconnect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
pub async fn reconnect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
// Force close existing session if any
|
// Force close existing session if any
|
||||||
if let Some(mut session) = self.session.take() {
|
if let Some(mut session) = self.session.take() {
|
||||||
let _ = session.logout().await;
|
let _ = session.logout().await;
|
||||||
}
|
}
|
||||||
self.connect(password).await
|
self.connect().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn logout(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
pub async fn logout(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
@@ -139,13 +234,10 @@ impl ImapClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Use this before any operation to ensure we have a valid session.
|
/// Use this before any operation to ensure we have a valid session.
|
||||||
pub async fn ensure_connected(
|
pub async fn ensure_connected(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
&mut self,
|
|
||||||
password: &str,
|
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
|
||||||
if !self.keep_alive().await {
|
if !self.keep_alive().await {
|
||||||
tracing::debug!("Reconnecting session for {}", self.email);
|
tracing::debug!("Reconnecting session for {}", self.email);
|
||||||
self.reconnect(password).await?;
|
self.reconnect().await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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;
|
||||||
|
|||||||
334
mailcore/src/oauth.rs
Normal file
334
mailcore/src/oauth.rs
Normal file
@@ -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<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_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::<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))
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::imap::ImapClient;
|
use crate::imap::ImapClient;
|
||||||
use crate::types::MailServerConfig;
|
use crate::types::{AuthCredential, MailServerConfig};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,7 +16,7 @@ lazy_static! {
|
|||||||
pub async fn get_client(
|
pub async fn get_client(
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
credential: AuthCredential,
|
||||||
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
) -> Result<Arc<Mutex<ImapClient>>, crate::types::MailError> {
|
||||||
// Atomic get-or-insert to avoid race conditions on startup
|
// Atomic get-or-insert to avoid race conditions on startup
|
||||||
let (client_arc, is_new) = {
|
let (client_arc, is_new) = {
|
||||||
@@ -24,7 +24,7 @@ pub async fn get_client(
|
|||||||
if let Some(arc) = sessions.get(&email) {
|
if let Some(arc) = sessions.get(&email) {
|
||||||
(arc.clone(), false)
|
(arc.clone(), false)
|
||||||
} else {
|
} else {
|
||||||
let client = ImapClient::new(config, email.clone());
|
let client = ImapClient::new(config, email.clone(), credential);
|
||||||
let arc = Arc::new(Mutex::new(client));
|
let arc = Arc::new(Mutex::new(client));
|
||||||
sessions.insert(email.clone(), arc.clone());
|
sessions.insert(email.clone(), arc.clone());
|
||||||
(arc, true)
|
(arc, true)
|
||||||
@@ -40,7 +40,7 @@ pub async fn get_client(
|
|||||||
);
|
);
|
||||||
if let Err(e) = tokio::time::timeout(
|
if let Err(e) = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(20),
|
std::time::Duration::from_secs(20),
|
||||||
client.connect(&password),
|
client.connect(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
.unwrap_or_else(|_| Err("Connection timeout".into()))
|
||||||
@@ -72,7 +72,7 @@ pub async fn get_client(
|
|||||||
);
|
);
|
||||||
if let Err(e) = tokio::time::timeout(
|
if let Err(e) = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(15),
|
std::time::Duration::from_secs(15),
|
||||||
client.reconnect(&password),
|
client.reconnect(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("Reconnect timeout".into()))
|
.unwrap_or_else(|_| Err("Reconnect timeout".into()))
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
use crate::types::MailServerConfig;
|
use crate::types::{AuthCredential, MailServerConfig};
|
||||||
use lettre::message::{MultiPart, SinglePart};
|
use lettre::message::{MultiPart, SinglePart};
|
||||||
|
use lettre::transport::smtp::authentication::Mechanism;
|
||||||
use lettre::{Message, SmtpTransport, Transport};
|
use lettre::{Message, SmtpTransport, Transport};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
pub struct SmtpClient {
|
pub struct SmtpClient {
|
||||||
config: MailServerConfig,
|
config: MailServerConfig,
|
||||||
email: String,
|
email: String,
|
||||||
|
auth: AuthCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SmtpClient {
|
impl SmtpClient {
|
||||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||||
Self { config, email }
|
Self { config, email, auth }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_mail(
|
pub async fn send_mail(
|
||||||
&self,
|
&self,
|
||||||
password: &str,
|
|
||||||
to: &str,
|
to: &str,
|
||||||
subject: &str,
|
subject: &str,
|
||||||
body_text: &str,
|
body_text: &str,
|
||||||
@@ -51,11 +52,6 @@ impl SmtpClient {
|
|||||||
|
|
||||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
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_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||||
|
|
||||||
// Explicitly choose TLS mode based on port
|
// Explicitly choose TLS mode based on port
|
||||||
@@ -65,11 +61,7 @@ impl SmtpClient {
|
|||||||
Tls::Opportunistic(tls_parameters)
|
Tls::Opportunistic(tls_parameters)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
let mailer = self.build_transport(tls)?;
|
||||||
.port(self.config.smtp_port)
|
|
||||||
.tls(tls)
|
|
||||||
.credentials(creds)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let raw_bytes = email.formatted();
|
let raw_bytes = email.formatted();
|
||||||
mailer.send(&email)?;
|
mailer.send(&email)?;
|
||||||
@@ -79,18 +71,12 @@ impl SmtpClient {
|
|||||||
|
|
||||||
pub async fn send_raw_mail(
|
pub async fn send_raw_mail(
|
||||||
&self,
|
&self,
|
||||||
password: &str,
|
|
||||||
to: &str,
|
to: &str,
|
||||||
_subject: &str,
|
_subject: &str,
|
||||||
raw_message: &[u8],
|
raw_message: &[u8],
|
||||||
) -> Result<(), Box<dyn Error>> {
|
) -> Result<(), Box<dyn Error>> {
|
||||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
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_parameters = TlsParameters::new(self.config.smtp_host.clone())?;
|
||||||
|
|
||||||
let tls = if self.config.smtp_port == 465 {
|
let tls = if self.config.smtp_port == 465 {
|
||||||
@@ -99,11 +85,7 @@ impl SmtpClient {
|
|||||||
Tls::Opportunistic(tls_parameters)
|
Tls::Opportunistic(tls_parameters)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
let mailer = self.build_transport(tls)?;
|
||||||
.port(self.config.smtp_port)
|
|
||||||
.tls(tls)
|
|
||||||
.credentials(creds)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Build a manual message from raw bytes; `to` may be comma-separated
|
// Build a manual message from raw bytes; `to` may be comma-separated
|
||||||
let recipients: Vec<lettre::address::Address> = to
|
let recipients: Vec<lettre::address::Address> = to
|
||||||
@@ -117,4 +99,41 @@ impl SmtpClient {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a configured `SmtpTransport` using the stored auth credential.
|
||||||
|
fn build_transport(
|
||||||
|
&self,
|
||||||
|
tls: lettre::transport::smtp::client::Tls,
|
||||||
|
) -> Result<SmtpTransport, Box<dyn Error>> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -814,7 +814,7 @@ impl Storage {
|
|||||||
&self,
|
&self,
|
||||||
account: &str,
|
account: &str,
|
||||||
folder: &str,
|
folder: &str,
|
||||||
) -> Result<(i32, i32), Box<dyn Error + Send + Sync>> {
|
) -> Result<(i32, i32, i32), Box<dyn Error + Send + Sync>> {
|
||||||
let conn = self.connect()?;
|
let conn = self.connect()?;
|
||||||
let unread: i32 = conn.query_row(
|
let unread: i32 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
"SELECT COUNT(*) FROM messages_v2 WHERE folder = ?1 AND is_read = 0",
|
||||||
@@ -828,7 +828,12 @@ impl Storage {
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(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<i32, Box<dyn Error + Send + Sync>> {
|
pub fn get_total_unread_count(&self) -> Result<i32, Box<dyn Error + Send + Sync>> {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
use flutter_rust_bridge::frb;
|
use flutter_rust_bridge::frb;
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MailServerConfig {
|
pub struct MailServerConfig {
|
||||||
pub imap_host: String,
|
pub imap_host: String,
|
||||||
@@ -108,6 +119,7 @@ pub enum MailError {
|
|||||||
InvalidPassphrase,
|
InvalidPassphrase,
|
||||||
KeyNotFound(String),
|
KeyNotFound(String),
|
||||||
Crypto(String),
|
Crypto(String),
|
||||||
|
TokenRefreshFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MailError {
|
impl std::fmt::Display for MailError {
|
||||||
@@ -120,6 +132,7 @@ impl std::fmt::Display for MailError {
|
|||||||
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
MailError::InvalidPassphrase => write!(f, "Invalid passphrase"),
|
||||||
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
MailError::KeyNotFound(s) => write!(f, "Key not found: {}", s),
|
||||||
MailError::Crypto(s) => write!(f, "Crypto error: {}", 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 struct FolderStats {
|
||||||
pub unread: i32,
|
pub unread: i32,
|
||||||
pub server_total: i32,
|
pub server_total: i32,
|
||||||
|
pub local_total: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[frb]
|
#[frb]
|
||||||
|
|||||||
Reference in New Issue
Block a user