v0.0.5
RustBox CI / rustbox (push) Waiting to run

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+16 -80
View File
@@ -11,8 +11,6 @@ use crate::imap::parse::{decode_rfc2047, flag_to_string, has_attachments, imap_a
use crate::realtime::events::{EventBus, MailEvent};
use crate::{db, imap};
/// Manages long-lived IMAP IDLE connections, one per (user, folder) pair.
/// Each WebSocket holds a subscription; only the last disconnect stops IDLE.
pub struct IdleManager {
tasks: DashMap<(String, String), IdleTask>,
}
@@ -29,7 +27,6 @@ impl IdleManager {
}
}
/// Subscribe to IDLE for a user + folder, sharing an existing task.
pub async fn start_idle(
&self,
user_hash: String,
@@ -56,7 +53,6 @@ impl IdleManager {
}
}
/// Release this socket's subscription, without stopping other sockets.
pub async fn stop_idle(&self, user_hash: &str, folder: &str) {
use dashmap::mapref::entry::Entry;
@@ -70,7 +66,6 @@ impl IdleManager {
}
}
/// Abort all dedicated IDLE tasks during process shutdown.
pub fn stop_all(&self) {
for task in self.tasks.iter() {
task.value().handle.abort();
@@ -93,12 +88,6 @@ impl Default for IdleManager {
}
}
/// The inner IDLE loop with auto-reconnect and exponential backoff.
///
/// This opens a dedicated IMAP connection, SELECTs the folder, and enters
/// IDLE mode. When the server notifies of changes, it publishes an event
/// to the EventBus and re-enters IDLE. If the connection drops, it
/// reconnects with exponential backoff.
async fn idle_loop(
user_hash: &str,
folder: &str,
@@ -112,7 +101,6 @@ async fn idle_loop(
loop {
match run_idle_session(user_hash, folder, creds, event_bus, config).await {
Ok(()) => {
// Session ended normally (shouldn't happen in practice).
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session ended normally");
break;
}
@@ -131,11 +119,6 @@ async fn idle_loop(
}
}
/// Run a single IDLE session. Returns Err on connection/protocol errors.
///
/// The async-imap IDLE API follows an ownership pattern:
/// `Session` → `.idle()` consumes session → `Handle` → `.init()` sends IDLE
/// → `.wait()` listens → `.done()` sends DONE and returns `Session`.
async fn run_idle_session(
user_hash: &str,
folder: &str,
@@ -156,31 +139,22 @@ async fn run_idle_session(
tracing::info!(user_hash = %user_hash, folder = %folder, "IDLE session started");
// Re-enter IDLE every 25 minutes (RFC recommends max 29 min).
let idle_timeout = std::time::Duration::from_secs(25 * 60);
loop {
// `.idle()` consumes the session, wrapping it in a Handle.
let mut idle_handle = session.idle();
// Initialize the IDLE command with the server.
idle_handle
.init()
.await
.map_err(|e| format!("IDLE init failed: {e}"))?;
// Start listening for server notifications.
// IMPORTANT: `interrupt` must be kept alive — dropping it triggers
// ManualInterrupt and ends the wait immediately.
let (idle_wait, interrupt) = idle_handle.wait();
// Wait for the server to send an unsolicited response, or timeout.
let result = tokio::time::timeout(idle_timeout, idle_wait).await;
// Drop the interrupt handle now that we're done waiting.
drop(interrupt);
// Send DONE to end IDLE and get the session back.
session = idle_handle
.done()
.await
@@ -197,14 +171,10 @@ async fn run_idle_session(
"IDLE: new data from server"
);
// CONDSTORE sync is fast (~200-500ms), so run it
// BEFORE publishing. This ensures the DB is up-to-date
// when the frontend refetches.
let result;
(session, result) =
fetch_new_after_idle(session, user_hash, folder, config).await;
// Publish the correct event based on what changed.
if result.new_count > 0 {
event_bus
.publish(
@@ -212,6 +182,7 @@ async fn run_idle_session(
MailEvent::NewMessages {
folder: folder.to_string(),
count: result.new_count,
latest_uid: result.latest_uid,
latest_sender: result.latest_sender,
latest_subject: result.latest_subject,
},
@@ -245,11 +216,9 @@ async fn run_idle_session(
}
}
Ok(Err(e)) => {
// IDLE protocol error.
return Err(format!("IDLE error: {e}"));
}
Err(_) => {
// Our timeout — re-enter IDLE to keep the connection alive.
tracing::debug!(
user_hash = %user_hash,
folder = %folder,
@@ -260,11 +229,11 @@ async fn run_idle_session(
}
}
/// Summary of what changed during a post-IDLE reconciliation.
struct ReconcileResult {
new_count: u32,
flags_updated: u32,
deleted_count: u32,
latest_uid: Option<u32>,
latest_sender: Option<String>,
latest_subject: Option<String>,
}
@@ -275,26 +244,19 @@ impl ReconcileResult {
new_count: 0,
flags_updated: 0,
deleted_count: 0,
latest_uid: None,
latest_sender: None,
latest_subject: None,
}
}
}
/// Reconcile the local cache after IDLE detects new data.
///
/// Uses CONDSTORE when available for fast incremental sync (~200-500ms),
/// falling back to a full UID scan when CONDSTORE is not supported.
///
/// If anything fails, it logs a warning and returns the session unchanged —
/// the periodic sync loop provides a safety net.
async fn fetch_new_after_idle(
mut session: async_imap::Session<ImapStream>,
user_hash: &str,
folder: &str,
config: &AppConfig,
) -> (async_imap::Session<ImapStream>, ReconcileResult) {
// Read cached folder state from DB.
let (cached_modseq, max_cached_uid, cached_count, cached_uid_validity, header_sync) =
match db::pool::open_user_db(&config.data_dir, user_hash) {
Ok(conn) => {
@@ -318,7 +280,6 @@ async fn fetch_new_after_idle(
}
};
// Re-SELECT with CONDSTORE to get fresh mailbox state.
let mailbox = match session.select_condstore(folder).await {
Ok(mb) => mb,
Err(e) => {
@@ -331,7 +292,6 @@ async fn fetch_new_after_idle(
let server_modseq = mailbox.highest_modseq.unwrap_or(0);
let uid_validity = mailbox.uid_validity.unwrap_or(0);
// Let list sync reset the namespace before IDLE writes any reused UIDs.
if cached_uid_validity != 0 && cached_uid_validity != uid_validity {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) {
let _ = db::folders::invalidate_folder_freshness(&conn, folder);
@@ -339,7 +299,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty());
}
// Early exit: nothing changed (CONDSTORE modseq and count both match).
if server_modseq > 0 && server_modseq == cached_modseq && server_exists == cached_count {
tracing::debug!(
user_hash = %user_hash,
@@ -350,7 +309,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty());
}
// ── CONDSTORE fast path ──────────────────────────────────────────
if cached_modseq > 0 && server_modseq > 0 {
let fetch_query = format!("(UID FLAGS) (CHANGEDSINCE {})", cached_modseq);
let changed_items: Option<Vec<(u32, Vec<String>)>> =
@@ -388,7 +346,6 @@ async fn fetch_new_after_idle(
};
if let Some(changed) = changed_items {
// Separate flag changes (uid ≤ max) from new messages (uid > max).
let mut flag_changes: Vec<(u32, Vec<String>)> = Vec::new();
let mut has_new = false;
for (uid, flags) in changed {
@@ -399,7 +356,6 @@ async fn fetch_new_after_idle(
}
}
// Apply flag changes to DB.
let mut flags_updated = 0u32;
if !flag_changes.is_empty()
&& let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
@@ -424,7 +380,6 @@ async fn fetch_new_after_idle(
}
}
// Fetch full headers for new messages.
let mut new_count = 0u32;
if has_new {
let uid_range = format!("{}:*", max_cached_uid + 1);
@@ -440,21 +395,18 @@ async fn fetch_new_after_idle(
.await;
}
// Detect deletions if server count < cached count.
let mut deleted_count = 0u32;
if server_exists < cached_count {
deleted_count =
fetch_uids_and_remove_deleted(&mut session, user_hash, folder, config).await;
}
// Look up the latest message's sender/subject if new messages arrived.
let (latest_sender, latest_subject) = if new_count > 0 {
let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
lookup_latest_message(config, user_hash, folder)
} else {
(None, None)
(None, None, None)
};
// Update folder sync status with new modseq.
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn)
{
@@ -486,15 +438,14 @@ async fn fetch_new_after_idle(
new_count,
flags_updated,
deleted_count,
latest_uid,
latest_sender,
latest_subject,
},
);
}
// CHANGEDSINCE fetch failed — fall through to full scan below.
}
// ── Fallback: full UID scan (no CONDSTORE) ───────────────────────
let cached_uids: std::collections::HashSet<u32> =
match db::pool::open_user_db(&config.data_dir, user_hash) {
Ok(conn) => {
@@ -508,7 +459,6 @@ async fn fetch_new_after_idle(
}
};
// Fetch all UIDs from server.
let imap_uids: Option<std::collections::HashSet<u32>> =
match session.uid_fetch("1:*", "UID").await {
Ok(stream) => {
@@ -539,7 +489,6 @@ async fn fetch_new_after_idle(
return (session, ReconcileResult::empty());
};
// Fetch headers for new messages.
let has_new = imap_uids.iter().any(|&uid| uid > max_cached_uid);
let mut new_count = 0u32;
if has_new {
@@ -556,7 +505,6 @@ async fn fetch_new_after_idle(
.await;
}
// Delete cached UIDs no longer on server.
let mut deleted_count = 0u32;
let deleted_uids: Vec<u32> = cached_uids
.iter()
@@ -573,7 +521,6 @@ async fn fetch_new_after_idle(
}
}
// Update folder sync status.
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash)
&& let Ok(transaction) = header_sync.begin_write(&conn)
{
@@ -588,11 +535,10 @@ async fn fetch_new_after_idle(
let _ = transaction.commit();
}
// Look up the latest message's sender/subject if new messages arrived.
let (latest_sender, latest_subject) = if new_count > 0 {
let (latest_uid, latest_sender, latest_subject) = if new_count > 0 {
lookup_latest_message(config, user_hash, folder)
} else {
(None, None)
(None, None, None)
};
if new_count > 0 || deleted_count > 0 {
@@ -611,24 +557,24 @@ async fn fetch_new_after_idle(
new_count,
flags_updated: 0,
deleted_count,
latest_uid,
latest_sender,
latest_subject,
},
)
}
/// Look up the most recent message in the DB for sender/subject details.
fn lookup_latest_message(
config: &AppConfig,
user_hash: &str,
folder: &str,
) -> (Option<String>, Option<String>) {
) -> (Option<u32>, Option<String>, Option<String>) {
let Ok(conn) = db::pool::open_user_db(&config.data_dir, user_hash) else {
return (None, None);
return (None, None, None);
};
let max_uid = db::messages::max_uid(&conn, folder).unwrap_or(0);
if max_uid == 0 {
return (None, None);
return (None, None, None);
}
match db::messages::get_single_message(&conn, folder, max_uid) {
Ok(Some(msg)) => {
@@ -644,15 +590,12 @@ fn lookup_latest_message(
} else {
None
};
(sender, subject)
(Some(max_uid), sender, subject)
}
_ => (None, None),
_ => (None, None, None),
}
}
/// Fetch full headers for messages in the given UID range and store them in the DB.
/// Skips messages with UID ≤ `min_uid` (handles the `*` wildcard returning the last message).
/// Returns the number of new messages stored.
async fn fetch_and_store_headers(
session: &mut async_imap::Session<ImapStream>,
user_hash: &str,
@@ -706,11 +649,10 @@ async fn fetch_and_store_headers(
for fetch in &fetches {
let uid = match fetch.uid {
Some(u) if u > min_uid => u,
Some(_) => continue, // UID "*" can return the last message
Some(_) => continue,
None => continue,
};
// Parse threading headers.
let raw_header_bytes = fetch.header();
let parsed_threading =
raw_header_bytes.and_then(|raw| mail_parser::MessageParser::default().parse(raw));
@@ -770,7 +712,6 @@ async fn fetch_and_store_headers(
.or_else(|| val.as_text().map(|s| format!("<{s}>")))
});
// Detect Outlook/Exchange reaction emails from headers.
let reaction = raw_header_bytes.and_then(|raw| {
let header_str = std::str::from_utf8(raw).ok()?;
let lower = header_str.to_lowercase();
@@ -828,7 +769,6 @@ async fn fetch_and_store_headers(
) {
tracing::warn!(uid = uid, error = %e, "IDLE fetch: failed to upsert message");
} else {
// Populate denormalized known_addresses table.
if let Err(e) = db::contacts::populate_known_addresses(
&conn,
from_address,
@@ -855,9 +795,6 @@ async fn fetch_and_store_headers(
new_count
}
/// Fetch all UIDs from the server and remove cached messages no longer present.
/// Used for deletion detection when server count < cached count.
/// Returns the number of deleted messages.
async fn fetch_uids_and_remove_deleted(
session: &mut async_imap::Session<ImapStream>,
user_hash: &str,
@@ -943,7 +880,7 @@ mod tests {
)
.await;
let original_task = manager.tasks.get(&key).unwrap().handle.id();
// A reconnect may subscribe before the old socket finishes closing.
manager
.start_idle(
key.0.clone(),
@@ -963,7 +900,6 @@ mod tests {
manager.stop_idle(&key.0, &key.1).await;
assert!(manager.tasks.is_empty());
// A connection arriving after the last disconnect gets a new task.
manager
.start_idle(key.0.clone(), key.1.clone(), creds, event_bus, config)
.await;