Files
korax-public/mailcore/src/api.rs
2026-07-24 20:14:32 +03:00

1468 lines
46 KiB
Rust

use crate::types::{AuthCredential, FolderInfo, MailError, MailServerConfig};
use flutter_rust_bridge::frb;
use mail_parser::MimeHeaders;
use lazy_static::lazy_static;
use std::collections::HashMap;
use tokio::sync::Mutex;
lazy_static! {
// Global tracker for IMAP IDLE cancellation channels, keyed by email
static ref IDLE_CANCELS: std::sync::Arc<Mutex<HashMap<String, tokio::sync::mpsc::Sender<()>>>> = std::sync::Arc::new(Mutex::new(HashMap::new()));
}
pub fn init_logger() {
#[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
// when multiple providers (ring, aws-lc-rs) are present in the dependency graph.
// This is required for Rustls 0.23+
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) 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();
out.push_str(&format!("Diagnosing connection for {}\n", email));
let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
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()
));
}
};
out
}
pub async fn discover_server(email: String) -> Result<MailServerConfig, MailError> {
crate::dns::discover_mail_server(&email)
.await
.map_err(|e| MailError::Network(e.to_string()))
}
/// A simple test function to verify we can connect and list folders.
pub async fn connect_and_list_folders(
config: MailServerConfig,
email: String,
credential: AuthCredential,
show_all_folders: bool,
storage_path: String,
) -> Result<Vec<FolderInfo>, MailError> {
// This call will create/cache the session in the manager
let client_arc = crate::session_manager::get_client(config, email, credential).await?;
let mut client = client_arc.lock().await;
let mut folders = client
.list_folders(show_all_folders)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
// Add unread counts from storage for all folders
if let Ok(storage) = crate::storage::Storage::new(&storage_path) {
for folder in &mut folders {
if let Ok(count) = storage.get_unread_count(&folder.name) {
folder.unread_count = count;
}
}
}
Ok(folders)
}
pub fn get_total_unread_count(storage_path: String) -> i32 {
if let Ok(storage) = crate::storage::Storage::new(&storage_path) {
storage.get_total_unread_count().unwrap_or(0)
} else {
0
}
}
pub async fn get_messages(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let client_arc = crate::session_manager::get_client(config, email, credential).await?;
let mut client = client_arc.lock().await;
client
.select_mailbox(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
client
.fetch_headers("1:*")
.await
.map_err(|e| MailError::Network(e.to_string()))
}
pub async fn get_message_body(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uid: u32,
storage_path: String,
_passphrase: Option<String>,
) -> Result<crate::types::MessageBody, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let cached_raw = storage
.get_message_raw(&mailbox, uid)
.map_err(|e| MailError::Generic(e.to_string()))?;
let body_result = if let Some(raw_bytes) = cached_raw {
tracing::debug!("Cache hit for message UID {} in {}", uid, mailbox);
parse_mime_message(&raw_bytes).await.map(|mut body| {
body.uid = uid;
body
})
} else {
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
client
.select_mailbox(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let fetch_result = client.fetch_body_raw(uid).await;
// Always close the dedicated connection, even on error.
let _ = client.logout().await;
let raw_bytes = match fetch_result {
Ok(bytes) => bytes,
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("not found on server") {
let _ = storage.delete_header(&mailbox, uid);
}
return Err(MailError::Network(err_msg));
}
};
// Save to cache
if let Err(e) = storage.save_message_raw(&mailbox, uid, &raw_bytes) {
tracing::warn!("Failed to save message body to cache: {}", e);
}
parse_mime_message(&raw_bytes).await.map(|mut body| {
body.uid = uid;
body
})
};
body_result
}
pub async fn set_message_flag(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uid: u32,
flag: Option<String>,
storage_path: String,
) -> Result<(), MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Optimistically update local DB
if let Err(e) = storage.update_message_flag(&mailbox, uid, flag.clone()) {
tracing::warn!("Failed to update flag in local storage: {}", e);
}
// Dedicated connection — never blocked by sync_mailbox holding the shared session lock.
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client
.set_message_flag(&mailbox, uid, flag)
.await
.map_err(|e| MailError::Network(e.to_string()));
// Always close the dedicated connection, even on error.
let _ = client.logout().await;
result
}
pub fn save_contact(path: String, contact: crate::types::Contact) -> Result<(), MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.save_contact(&contact)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn get_contacts(path: String) -> Result<Vec<crate::types::Contact>, MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.get_contacts()
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn delete_contact(path: String, email: String) -> Result<(), MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_contact(&email)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn get_contact_by_email(
path: String,
email: String,
) -> Result<Option<crate::types::Contact>, MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.get_contact_by_email(&email)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub async fn append_to_sent(
config: &MailServerConfig,
email: &str,
credential: AuthCredential,
message_bytes: &[u8],
) {
let mut client = crate::imap::ImapClient::new(config.clone(), email.to_string(), credential);
if let Err(e) = client.connect().await {
tracing::warn!("append_to_sent: IMAP connect failed: {}", e);
return;
}
let folders = client.list_folders(false).await.unwrap_or_default();
let sent_folder = folders
.iter()
.find(|f| f.folder_type == crate::types::FolderType::Sent)
.map(|f| f.name.clone())
.unwrap_or_else(|| "Sent".to_string());
if let Err(e) = client
.append_message(&sent_folder, message_bytes, Some(r"\Seen".to_string()))
.await
{
tracing::warn!("append_to_sent: append to {} failed: {}", sent_folder, e);
}
let _ = client.logout().await;
}
#[allow(clippy::too_many_arguments)]
pub async fn send_email(
config: MailServerConfig,
email: String,
credential: AuthCredential,
to: String,
subject: String,
body: String,
attachments: Vec<crate::types::Attachment>,
encrypt: bool,
_signer_key: Option<String>,
_signer_password: Option<String>,
_storage_path: String,
) -> Result<(), MailError> {
if encrypt {
return Err(MailError::Generic("PGP encryption not supported in mailcore. Use mailmore.".to_string()));
}
let smtp_client = crate::smtp::SmtpClient::new(config.clone(), email.clone(), credential.clone());
let message_bytes = smtp_client
.send_mail(&to, &subject, &body, attachments)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
append_to_sent(&config, &email, credential, &message_bytes).await;
Ok(())
}
pub async fn prefetch_bodies(
config: crate::types::MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uids: Vec<u32>,
storage_path: String,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Filter to only uncached UIDs before opening a connection
let uncached: Vec<u32> = uids
.into_iter()
.filter(|&uid| !matches!(storage.get_message_raw(&mailbox, uid), Ok(Some(_))))
.collect();
if uncached.is_empty() {
return Ok(());
}
// Use a DEDICATED connection so prefetch never blocks user-initiated fetches
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
client
.select_mailbox(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
for uid in uncached {
match client.fetch_body_raw(uid).await {
Ok(raw_bytes) => {
if let Err(e) = storage.save_message_raw(&mailbox, uid, &raw_bytes) {
tracing::warn!("Prefetch: Failed to save body for UID {}: {}", uid, e);
}
}
Err(e) => {
tracing::warn!("Prefetch: Failed to fetch body for UID {}: {}", uid, e);
}
}
}
// Always close the dedicated connection when prefetch is done.
let _ = client.logout().await;
Ok(())
}
pub async fn parse_mime_message(bytes: &[u8]) -> Result<crate::types::MessageBody, MailError> {
let bytes_owned = crate::imap::client::preprocess_header_bytes(bytes);
tokio::task::spawn_blocking(move || {
let parsed = mail_parser::MessageParser::default()
.parse(&bytes_owned)
.ok_or_else(|| MailError::Generic("Failed to parse MIME message".to_string()))?;
let mut attachments = Vec::new();
for part in parsed.attachments() {
let content_type = part
.content_type()
.map(|ct| {
let ctype = ct.ctype();
let subtype = ct.subtype();
format!("{}/{}", ctype, subtype.unwrap_or(""))
})
.unwrap_or_else(|| "application/octet-stream".to_string());
attachments.push(crate::types::Attachment {
name: part.attachment_name().unwrap_or("unnamed").to_string(),
content_type,
size: part.contents().len(),
content: part.contents().to_vec(),
});
}
// RFC 3156 PGP/MIME: mail_parser's attachments() does not expose the inner
// parts of a multipart/encrypted envelope. Walk all parsed parts directly to
// find the application/octet-stream ciphertext that attachments() misses.
let mut is_pgp_mime_envelope = false;
if let Some(ct) = parsed.content_type() {
if ct.ctype() == "multipart" && ct.subtype().unwrap_or("") == "encrypted" {
is_pgp_mime_envelope = true;
for part in &parsed.parts {
if let Some(pct) = part.content_type() {
if pct.ctype() == "application"
&& pct.subtype().unwrap_or("") == "octet-stream"
{
let name = part
.attachment_name()
.unwrap_or("encrypted.asc")
.to_string();
let content = part.contents().to_vec();
if !attachments
.iter()
.any(|a: &crate::types::Attachment| a.content == content)
{
attachments.push(crate::types::Attachment {
name,
content_type: "application/octet-stream".to_string(),
size: content.len(),
content,
});
}
}
}
}
}
}
let (pgp_status, signature_fingerprint) = detect_pgp_status(&parsed);
let from_addr = parsed.from().and_then(|f| {
f.iter()
.next()
.map(|a| a.address().unwrap_or("").to_string())
});
// For multipart/encrypted messages, body_text(0) returns raw MIME bytes
// (boundary markers, Content-Type headers, the armored PGP block) rather
// than meaningful body text. Suppress it so the caller never shows protocol
// noise to the user.
let (text_plain, text_html) = if is_pgp_mime_envelope {
(None, None)
} else {
(
parsed.body_text(0).map(|s| s.to_string()),
parsed.body_html(0).map(|s| s.to_string()),
)
};
// Extract sender public key:
// 1. Try Autocrypt header first (RFC 7929)
let sender_public_key = extract_autocrypt_key(&parsed)
// 2. Fall back to application/pgp-keys attachment
.or_else(|| extract_pgp_keys_attachment(&parsed));
Ok(crate::types::MessageBody {
uid: 0, // No UID for raw bytes
from: from_addr,
text_plain,
text_html,
attachments,
pgp_status,
signature_fingerprint,
sender_public_key,
})
})
.await
.unwrap_or_else(|e| Err(MailError::Generic(format!("Task spawn error: {}", e))))
}
/// Parse the Autocrypt header and return the ASCII-armored public key, or None.
///
/// RFC 7929 / Autocrypt Level 1: the `keydata=` attribute holds the raw bytes
/// of the exported OpenPGP certificate, Base64-encoded (no line breaks, no armor).
/// We re-armor them so callers can use the result directly with Sequoia.
fn extract_autocrypt_key(parsed: &mail_parser::Message) -> Option<String> {
let header_value = parsed
.headers()
.iter()
.find(|h| h.name.as_str().eq_ignore_ascii_case("Autocrypt"))
.and_then(|h| match &h.value {
mail_parser::HeaderValue::Text(t) => Some(t.as_ref().to_string()),
mail_parser::HeaderValue::TextList(list) => list.first().map(|s| s.to_string()),
_ => None,
})?;
// Extract keydata= value (may be followed by ; or end of string)
let lower = header_value.to_lowercase();
let keydata_start = lower.find("keydata=")?;
let rest = &header_value[keydata_start + "keydata=".len()..];
let keydata_b64: String = rest
.split(';')
.next()?
.chars()
.filter(|c| !c.is_whitespace())
.collect();
if keydata_b64.is_empty() {
return None;
}
// Re-armor: wrap the raw base64 in PGP armor header/footer
// (The keydata is already the raw exported cert bytes in base64; we split
// into 64-char lines for canonical OpenPGP ASCII Armor format.)
let wrapped: String = keydata_b64
.chars()
.collect::<Vec<_>>()
.chunks(64)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n");
Some(format!(
"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{wrapped}\n-----END PGP PUBLIC KEY BLOCK-----\n"
))
}
/// Look for the first MIME part with Content-Type application/pgp-keys and
/// return its content decoded as UTF-8 (the ASCII-armored public key block).
fn extract_pgp_keys_attachment(parsed: &mail_parser::Message) -> Option<String> {
for part in &parsed.parts {
let is_pgp_keys = part
.content_type()
.map(|ct| {
ct.ctype().eq_ignore_ascii_case("application")
&& ct.subtype().map(|s| s.eq_ignore_ascii_case("pgp-keys")).unwrap_or(false)
})
.unwrap_or(false);
if is_pgp_keys {
return std::str::from_utf8(part.contents()).ok().map(|s| s.to_string());
}
}
None
}
fn detect_pgp_status(parsed: &mail_parser::Message) -> (crate::types::PgpStatus, Option<String>) {
let mut pgp_status = crate::types::PgpStatus::None;
let signature_fingerprint = None;
// Basic PGP detection from headers
if let Some(ct) = parsed.content_type() {
let ctype = ct.ctype();
let subtype = ct.subtype().unwrap_or("");
if ctype == "multipart" && subtype == "signed" {
pgp_status = crate::types::PgpStatus::Signed;
} else if ctype == "multipart" && subtype == "encrypted" {
pgp_status = crate::types::PgpStatus::Encrypted;
}
}
// Check text/plain body for inline PGP MESSAGE block
if pgp_status == crate::types::PgpStatus::None {
if let Some(text) = parsed.body_text(0) {
if text.contains("-----BEGIN PGP MESSAGE-----") {
pgp_status = crate::types::PgpStatus::Encrypted;
}
}
}
// Try to find if signed or if we find a signature part
for part in &parsed.parts {
// Detect unnamed application/octet-stream containing PGP MESSAGE
if pgp_status == crate::types::PgpStatus::None {
let is_octet = part
.content_type()
.map(|ct| ct.ctype() == "application" && ct.subtype() == Some("octet-stream"))
.unwrap_or(false);
// Also match parts that have no content-type at all (servers strip it)
let has_no_ct = part.content_type().is_none();
if is_octet || has_no_ct {
let contents = part.contents();
if contents.starts_with(b"-----BEGIN PGP MESSAGE-----") {
pgp_status = crate::types::PgpStatus::Encrypted;
}
}
}
if let Some(ct) = part.content_type() {
if ct.subtype() == Some("pgp-signature") {
if pgp_status == crate::types::PgpStatus::None {
pgp_status = crate::types::PgpStatus::Signed;
}
}
}
}
(pgp_status, signature_fingerprint)
}
pub async fn sync_mailbox(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
storage_path: String,
cache_days: i64,
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Skip syncing folders pending a full wipe — avoids repopulating messages
// before the IMAP expunge completes.
if storage.is_folder_pending_wipe(&email, &mailbox) {
return Ok(vec![]);
}
// Step 1: Run sync on the shared session.
let sync_result = {
let client_arc =
crate::session_manager::get_client(config.clone(), email.clone(), credential.clone())
.await?;
let mut client = client_arc.lock().await;
client
.sync_mailbox(&mailbox, &storage, cache_days)
.await
.map_err(|e| MailError::Network(e.to_string()))?
};
// Step 2: FLAG refresh on a dedicated connection.
let uids = sync_result.uids_for_flag_refresh;
if !uids.is_empty() {
let mut dedicated = crate::imap::ImapClient::new(config, email, credential);
match dedicated.connect().await {
Ok(()) => {
if let Err(e) = dedicated.refresh_flags(&mailbox, &uids, &storage).await {
tracing::warn!("FLAG refresh via dedicated connection failed: {}", e);
}
let _ = dedicated.logout().await;
}
Err(e) => {
tracing::warn!(
"FLAG refresh: dedicated connection failed to connect: {}",
e
);
}
}
}
Ok(sync_result.new_headers)
}
pub async fn listen_for_updates(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
) -> Result<tokio::sync::mpsc::Receiver<bool>, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let mut client = crate::imap::ImapClient::new(config.clone(), email.clone(), credential.clone());
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
client
.select_mailbox(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mailbox_clone = mailbox.clone();
let email_clone = email.clone();
let (tx, rx) = tokio::sync::mpsc::channel::<bool>(32);
let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
{
let mut cancels = IDLE_CANCELS.lock().await;
if let Some(old_tx) = cancels.remove(&email_clone) {
let _ = old_tx.send(()).await;
}
cancels.insert(email_clone.clone(), cancel_tx);
}
tokio::task::spawn(async move {
loop {
tokio::select! {
_ = cancel_rx.recv() => {
tracing::debug!("IDLE loop canceled for {}", email_clone);
break;
}
res = client.wait_for_update(600) => {
match res {
Ok(true) => {
tracing::debug!("IDLE: New data detected for {}", email_clone);
if tx.send(true).await.is_err() {
break;
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
Ok(false) => {
tracing::debug!("IDLE: Timeout for {}, renewing...", email_clone);
}
Err(e) => {
tracing::error!("IDLE error for {}: {}", email_clone, e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Err(re_err) = client.reconnect().await {
tracing::error!("IDLE reconnect failed: {}", re_err);
break;
}
let _ = client.select_mailbox(&mailbox_clone).await;
}
}
}
}
}
});
Ok(rx)
}
pub fn stop_idle_listener(email: String) {
let mut cancels = IDLE_CANCELS.blocking_lock();
if let Some(tx) = cancels.remove(&email) {
tracing::debug!("Canceling existing IDLE listener for {}", email);
let _ = tx.try_send(());
}
}
pub async fn check_connection(email: String) -> bool {
crate::session_manager::is_connected(&email).await
}
pub fn get_cached_messages(
storage_path: String,
mailbox: String,
account_email: String,
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let mut headers = storage
.get_headers(&mailbox)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Ensure account_email is set on every returned header. Rows migrated from
// older schema versions will have an empty string in the column; overwrite
// them here so callers always get a non-empty value.
for h in &mut headers {
if h.account_email.is_empty() {
h.account_email = account_email.clone();
}
}
Ok(headers)
}
pub fn save_draft(path: String, draft: crate::types::Draft) -> Result<i64, MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.save_draft(&draft)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn get_drafts(path: String) -> Result<Vec<crate::types::Draft>, MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.get_drafts()
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn delete_draft(path: String, id: i64) -> Result<(), MailError> {
let storage =
crate::storage::Storage::new(&path).map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_draft(id)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub async fn sync_drafts(
config: MailServerConfig,
email: String,
credential: AuthCredential,
storage_path: String,
) -> Result<(), MailError> {
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let folders = client
.list_folders(false)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let drafts_folder = folders
.iter()
.find(|f| f.folder_type == crate::types::FolderType::Drafts)
.map(|f| f.name.clone())
.unwrap_or_else(|| "Drafts".to_string());
client
.select_mailbox(&drafts_folder)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let server_headers = client.fetch_headers("1:*").await.unwrap_or_default();
let server_uids: std::collections::HashSet<u32> =
server_headers.iter().map(|h| h.uid).collect();
let local_drafts = storage
.get_drafts()
.map_err(|e| MailError::Generic(e.to_string()))?;
for mut draft in local_drafts {
let mut needs_upload = false;
if let Some(uid) = draft.server_uid {
if !server_uids.contains(&uid) {
draft.server_uid = None;
draft.last_synced_at = 0;
needs_upload = true;
} else if draft.last_updated > draft.last_synced_at {
let _ = client.delete_message(&drafts_folder, uid).await;
draft.server_uid = None;
needs_upload = true;
}
} else {
needs_upload = true;
}
if needs_upload {
let mut builder = mail_builder::MessageBuilder::new();
builder = builder
.from(email.as_str())
.to(draft.to.as_str())
.subject(draft.subject.as_str())
.text_body(draft.body.as_str());
for att in &draft.attachments {
builder = builder.attachment(
att.content_type.as_str(),
att.name.as_str(),
att.content.clone(),
);
}
let message_bytes = builder
.write_to_vec()
.map_err(|e| MailError::Generic(e.to_string()))?;
if let Ok(uid) = client
.append_message(&drafts_folder, &message_bytes, Some(r"\Draft".to_string()))
.await
{
draft.server_uid = Some(uid);
draft.last_synced_at = draft.last_updated;
let _ = storage.save_draft(&draft);
}
}
}
let updated_local_drafts = storage
.get_drafts()
.map_err(|e| MailError::Generic(e.to_string()))?;
let local_uids: std::collections::HashSet<u32> = updated_local_drafts
.iter()
.filter_map(|d| d.server_uid)
.collect();
for header in server_headers {
if !local_uids.contains(&header.uid) {
if let Ok(body) = client.fetch_body(header.uid).await {
let new_draft = crate::types::Draft {
id: None,
to: header.to.join(", "),
subject: header.subject.clone(),
body: body.text_plain.unwrap_or_default(),
attachments: vec![],
last_updated: header.date,
last_synced_at: header.date,
server_uid: Some(header.uid),
};
let _ = storage.save_draft(&new_draft);
}
}
}
let _ = client.logout().await;
Ok(())
}
pub async fn move_message(
config: MailServerConfig,
email: String,
credential: AuthCredential,
from_mailbox: String,
to_mailbox: String,
uid: u32,
storage_path: String,
) -> Result<(), MailError> {
let from_mailbox = if from_mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
from_mailbox
};
let to_mailbox = if to_mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
to_mailbox
};
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let move_result = client
.move_message(uid, &from_mailbox, &to_mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
move_result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_header(&from_mailbox, uid)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_drafts_by_server_uids(&[uid]);
Ok(())
}
pub async fn move_messages(
config: MailServerConfig,
email: String,
credential: AuthCredential,
from_mailbox: String,
to_mailbox: String,
uids: Vec<u32>,
storage_path: String,
) -> Result<(), MailError> {
let from_mailbox = if from_mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
from_mailbox
};
let to_mailbox = if to_mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
to_mailbox
};
if uids.is_empty() {
return Ok(());
}
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let move_result = client
.move_messages(&uids, &from_mailbox, &to_mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
move_result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_headers(&from_mailbox, &uids)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.delete_drafts_by_server_uids(&uids);
Ok(())
}
pub async fn delete_messages_permanently(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uids: Vec<u32>,
storage_path: String,
) -> Result<(), MailError> {
if uids.is_empty() {
return Ok(());
}
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client
.delete_messages_permanently(&mailbox, &uids)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.delete_headers(&mailbox, &uids)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(())
}
#[frb(sync)]
pub fn mark_as_read_local(
storage_path: String,
mailbox: String,
uid: u32,
) -> Result<(), MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.update_read_status(&mailbox, uid, true)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(())
}
pub async fn mark_as_read(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uid: u32,
storage_path: String,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mark_result = client
.mark_as_read(&mailbox, uid)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
mark_result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.update_read_status(&mailbox, uid, true);
Ok(())
}
pub async fn mark_as_unread(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
uid: u32,
storage_path: String,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mark_result = client
.mark_as_unread(&mailbox, uid)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
mark_result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.update_read_status(&mailbox, uid, false);
Ok(())
}
pub async fn mark_all_as_read(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
storage_path: String,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mark_result = client
.mark_all_as_read(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
mark_result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let _ = storage.mark_all_as_read(&mailbox);
Ok(())
}
#[frb(sync)]
pub fn prepare_delete_all_messages(
email: String,
mailbox: String,
storage_path: String,
wipe_drafts: bool,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.set_folder_pending_wipe(&email, &mailbox, wipe_drafts)
.map_err(|e| MailError::Generic(e.to_string()))
}
pub async fn delete_all_messages(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
storage_path: String,
wipe_drafts: bool,
) -> Result<(), MailError> {
let mailbox = if mailbox.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
mailbox
};
let mut client = crate::imap::ImapClient::new(config, email.clone(), credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client
.delete_all_messages(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()));
let _ = client.logout().await;
result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
// Wipe local cache (idempotent — also done by prepare, covers the resume path).
let _ = storage.wipe_folder(&mailbox);
let _ = storage.update_server_total(&email, &mailbox, 0);
if wipe_drafts {
let _ = storage.wipe_drafts();
}
let _ = storage.clear_folder_pending_wipe(&email, &mailbox);
Ok(())
}
pub async fn resume_pending_wipes(
config: MailServerConfig,
email: String,
credential: AuthCredential,
storage_path: String,
) -> Result<(), MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let pending = storage
.get_pending_wipes(&email)
.map_err(|e| MailError::Generic(e.to_string()))?;
for (folder, wd) in pending {
let _ = delete_all_messages(
config.clone(),
email.clone(),
credential.clone(),
folder,
storage_path.clone(),
wd,
)
.await;
}
Ok(())
}
pub async fn create_folder(
config: MailServerConfig,
email: String,
credential: AuthCredential,
folder_name: String,
) -> Result<(), MailError> {
let folder_name = if folder_name.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
folder_name
};
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client.create_folder(&folder_name).await.map_err(|e| {
MailError::Generic(e.to_string())
});
let _ = client.logout().await;
result
}
pub async fn delete_folder(
config: MailServerConfig,
email: String,
credential: AuthCredential,
folder_name: String,
storage_path: String,
) -> Result<(), MailError> {
let folder_name = if folder_name.to_uppercase() == "INBOX" {
"INBOX".to_string()
} else {
folder_name
};
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let result = client.delete_folder(&folder_name).await.map_err(|e| {
MailError::Generic(e.to_string())
});
let _ = client.logout().await;
result?;
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
storage
.wipe_folder(&folder_name)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(())
}
pub fn save_password(account: String, service: String, password: String) -> Result<(), MailError> {
Err(crate::types::MailError::Generic("keyring not available".into()))?; Ok(())
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn get_password(account: String, service: String) -> Result<Option<String>, MailError> {
Ok(None) // keyring not available
.map_err(|e| MailError::Generic(e.to_string()))
}
pub fn delete_credential(account: String, service: String) -> Result<(), MailError> {
Ok(()) // keyring not available
.map_err(|e| MailError::Generic(e.to_string()))
}
pub async fn load_older_messages(
config: MailServerConfig,
email: String,
credential: AuthCredential,
mailbox: String,
oldest_uid: u32,
batch_size: u32,
storage_path: String,
) -> Result<Vec<crate::types::MessageHeader>, MailError> {
if oldest_uid == 0 {
return Ok(vec![]);
}
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let local_uids = storage.get_uids(&mailbox).unwrap_or_default();
let mut client = crate::imap::ImapClient::new(config, email, credential);
client
.connect()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
client
.select_mailbox(&mailbox)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let older_uids: Vec<u32> = client
.search_uids_below(oldest_uid)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mut to_fetch: Vec<u32> = older_uids
.into_iter()
.filter(|uid| !local_uids.contains(uid))
.take(batch_size as usize)
.collect();
if to_fetch.is_empty() {
let _ = client.logout().await;
return Ok(vec![]);
}
to_fetch.sort_unstable();
let range = to_fetch
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
let headers = client
.fetch_headers(&range)
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let mut result = Vec::new();
for header in headers {
let _ = storage.save_header(&mailbox, &header);
result.push(header);
}
let _ = client.logout().await;
Ok(result)
}
pub fn get_folder_stats(
email: String,
mailbox: String,
storage_path: String,
) -> Result<crate::types::FolderStats, MailError> {
let storage = crate::storage::Storage::new(&storage_path)
.map_err(|e| MailError::Generic(e.to_string()))?;
let (unread, server_total, local_total) = storage
.get_folder_stats(&email, &mailbox)
.map_err(|e| MailError::Generic(e.to_string()))?;
Ok(crate::types::FolderStats {
unread,
server_total,
local_total,
})
}