sync: mailcore 0.2.1-beta
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use crate::types::{FolderInfo, FolderType, MailServerConfig};
|
||||
use crate::types::{AuthCredential, FolderInfo, FolderType, MailServerConfig};
|
||||
use async_imap::extensions::idle::IdleResponse;
|
||||
use async_imap::Client;
|
||||
use futures::stream::TryStreamExt;
|
||||
@@ -10,6 +10,28 @@ use tokio::net::TcpStream;
|
||||
use tokio_rustls::client::TlsStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
/// XOAUTH2 authenticator for async-imap.
|
||||
/// The response is the pre-built `user=<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
|
||||
/// that must run outside the shared session lock.
|
||||
pub struct SyncResult {
|
||||
@@ -25,18 +47,20 @@ pub struct ImapClient {
|
||||
session: Option<async_imap::Session<TlsStream<TcpStream>>>,
|
||||
config: MailServerConfig,
|
||||
email: String,
|
||||
auth: AuthCredential,
|
||||
}
|
||||
|
||||
impl ImapClient {
|
||||
pub fn new(config: MailServerConfig, email: String) -> Self {
|
||||
pub fn new(config: MailServerConfig, email: String, auth: AuthCredential) -> Self {
|
||||
Self {
|
||||
session: None,
|
||||
config,
|
||||
email,
|
||||
auth,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn connect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
tracing::debug!(
|
||||
"ImapClient: Starting connection to {}:{}",
|
||||
self.config.imap_host,
|
||||
@@ -46,16 +70,20 @@ impl ImapClient {
|
||||
let mut root_cert_store = rustls::RootCertStore::empty();
|
||||
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
tracing::debug!("ImapClient: Loading native certs...");
|
||||
// Load native certs
|
||||
match rustls_native_certs::load_native_certs() {
|
||||
Ok(certs) => {
|
||||
for cert in certs {
|
||||
let _ = root_cert_store.add(cert);
|
||||
// Skip rustls_native_certs on Android — the cert store path scan blocks
|
||||
// or returns nothing useful; webpki_roots covers all major CAs.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
tracing::debug!("ImapClient: Loading native certs...");
|
||||
match rustls_native_certs::load_native_certs() {
|
||||
Ok(certs) => {
|
||||
for cert in certs {
|
||||
let _ = root_cert_store.add(cert);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ImapClient: Failed to load native certs: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ImapClient: Failed to load native certs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,33 +114,100 @@ impl ImapClient {
|
||||
.to_owned();
|
||||
|
||||
tracing::debug!("ImapClient: Performing TLS handshake for {}...", dns_name);
|
||||
let tls_stream = connector.connect(server_name, stream).await?;
|
||||
let tls_stream = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
connector.connect(server_name, stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("TLS handshake with {} timed out after 10 seconds", dns_name))?
|
||||
.map_err(|e| format!("TLS handshake with {} failed: {}", dns_name, e))?;
|
||||
|
||||
// For OAuth2: fetch the token BEFORE the IMAP handshake so auth errors
|
||||
// are clearly separated from connection errors.
|
||||
let pre_fetched_token = match &self.auth {
|
||||
AuthCredential::OAuth2 => {
|
||||
tracing::debug!("ImapClient: Pre-fetching OAuth2 token for {}...", self.email);
|
||||
let token = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
crate::oauth::get_valid_token(&self.email),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "OAuth2 token fetch timed out after 10s")?
|
||||
.map_err(|e| e.to_string())?;
|
||||
tracing::debug!("ImapClient: OAuth2 token obtained for {}", self.email);
|
||||
Some(token)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Create IMAP client
|
||||
tracing::debug!("ImapClient: Creating async_imap client...");
|
||||
let client = Client::new(tls_stream);
|
||||
let mut client = Client::new(tls_stream);
|
||||
|
||||
// Login
|
||||
// async_imap does NOT read the server greeting automatically. The greeting must be
|
||||
// consumed before sending any command; otherwise authenticate()'s do_auth_handshake()
|
||||
// reads it instead of the "+" continuation and hangs indefinitely.
|
||||
tracing::debug!("ImapClient: Reading server greeting for {}...", self.config.imap_host);
|
||||
match tokio::time::timeout(Duration::from_secs(10), client.read_response()).await {
|
||||
Ok(Some(Ok(greeting))) => {
|
||||
tracing::debug!("ImapClient: Server greeting: {:?}", greeting.parsed());
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
return Err(format!("Failed to read server greeting: {}", e).into());
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err("Server closed connection before greeting".into());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("Timed out waiting for server greeting".into());
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate — explicit timeout so the caller gets a specific error message
|
||||
// rather than waiting for the session_manager's global 20s wall-clock.
|
||||
tracing::debug!("ImapClient: Attempting login for {}...", self.email);
|
||||
let session = client
|
||||
.login(&self.email, password)
|
||||
let session = match &self.auth.clone() {
|
||||
AuthCredential::Password(pw) => tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
client.login(&self.email, pw),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "IMAP login timed out after 15s".to_string())?
|
||||
.map_err(|(e, _client)| {
|
||||
tracing::error!("ImapClient: Login failed: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
})?,
|
||||
AuthCredential::OAuth2 => {
|
||||
let token = pre_fetched_token.unwrap();
|
||||
let raw = format!(
|
||||
"user={}\x01auth=Bearer {}\x01\x01",
|
||||
self.email, token
|
||||
);
|
||||
tracing::debug!("ImapClient: Sending XOAUTH2 AUTHENTICATE for {}...", self.email);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
client.authenticate("XOAUTH2", XOAuth2 { response: raw.into_bytes() }),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "XOAUTH2 authenticate timed out after 15s".to_string())?
|
||||
.map_err(|(e, _client)| {
|
||||
tracing::error!("ImapClient: XOAUTH2 auth failed: {}", e);
|
||||
e.to_string()
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
tracing::debug!("ImapClient: Successfully logged in as {}", self.email);
|
||||
self.session = Some(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reconnect(&mut self, password: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn reconnect(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
// Force close existing session if any
|
||||
if let Some(mut session) = self.session.take() {
|
||||
let _ = session.logout().await;
|
||||
}
|
||||
self.connect(password).await
|
||||
self.connect().await
|
||||
}
|
||||
|
||||
pub async fn logout(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
@@ -139,13 +234,10 @@ impl ImapClient {
|
||||
}
|
||||
|
||||
/// Use this before any operation to ensure we have a valid session.
|
||||
pub async fn ensure_connected(
|
||||
&mut self,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
pub async fn ensure_connected(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
if !self.keep_alive().await {
|
||||
tracing::debug!("Reconnecting session for {}", self.email);
|
||||
self.reconnect(password).await?;
|
||||
self.reconnect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user