diff --git a/crates/bridge/src/imap/session.rs b/crates/bridge/src/imap/session.rs index 28f3e31..6aacce6 100644 --- a/crates/bridge/src/imap/session.rs +++ b/crates/bridge/src/imap/session.rs @@ -1640,6 +1640,7 @@ mod tests { unread_calls: Mutex, bool)>>, sent: Mutex>, moved: Mutex, String)>>, + fail_details: Mutex, } impl MockBackend { @@ -1651,9 +1652,14 @@ mod tests { unread_calls: Mutex::new(Vec::new()), sent: Mutex::new(Vec::new()), moved: Mutex::new(Vec::new()), + fail_details: Mutex::new(false), } } + fn set_fail_details(&self, v: bool) { + *self.fail_details.lock().unwrap() = v; + } + fn with_mails(mails: Vec) -> Self { let m = Self::new(); *m.mails.lock().unwrap() = mails; @@ -1736,6 +1742,48 @@ mod tests { assert_eq!(moved[0].0.len(), 1); } + #[tokio::test] + async fn on_demand_body_fetch_failure_returns_no_and_cooldowns() { + let mail = make_mail("ed", "subj", false); + let backend = Arc::new(MockBackend::with_mails(vec![mail.clone()])); + backend.set_fail_details(true); + let store = MailStore::new(); + store.set_folder_list(vec![inbox_folder()]).await; + store + .set_folder( + "inbox", + vec![StoredMail { + mail, + details: None, + rfc2822: None, + uid: 1, + attachments_pending: false, + }], + ) + .await; + let mut session = ImapSession::new(store.clone(), backend.clone(), None, None); + session.handle_command("a LOGIN u p").await; + session.handle_command("b SELECT INBOX").await; + + // A failed on-demand body fetch returns NO [UNAVAILABLE] (not a fake + // body) and arms the shared cooldown. + let resp = session.handle_command("c UID FETCH 1 (BODY[])").await; + assert!( + resp.last().unwrap().contains("NO [UNAVAILABLE]"), + "expected NO on failure, got {resp:?}" + ); + assert!(store.body_fetch_on_cooldown("ed")); + + // While cooling down, a repeat fetch is short-circuited to NO without + // touching the backend, even though it would now succeed. + backend.set_fail_details(false); + let resp2 = session.handle_command("d UID FETCH 1 (BODY[])").await; + assert!( + resp2.last().unwrap().contains("NO [UNAVAILABLE]"), + "cooldown should keep returning NO, got {resp2:?}" + ); + } + #[tokio::test] async fn copy_is_rejected() { let backend = Arc::new(MockBackend::with_mails(vec![])); @@ -1860,6 +1908,9 @@ mod tests { Ok(None) } async fn load_mail_details(&self, mail: &Mail) -> Result, String> { + if *self.fail_details.lock().unwrap() { + return Err("simulated Too Many Requests".to_string()); + } let key = mail ._id .as_ref() diff --git a/crates/bridge/src/sync.rs b/crates/bridge/src/sync.rs index ae0b4a0..eaae518 100644 --- a/crates/bridge/src/sync.rs +++ b/crates/bridge/src/sync.rs @@ -74,8 +74,12 @@ impl MailStore { /// Arm a cooldown after a failed on-demand body fetch so the bridge does not /// immediately re-hit a throttled or erroring server for the same mail. pub(crate) fn mark_body_fetch_failed(&self, element_id: &str) { - crate::util::lock_recover(&self.body_fetch_cooldown) - .insert(element_id.to_string(), Instant::now() + BODY_FETCH_COOLDOWN); + let mut map = crate::util::lock_recover(&self.body_fetch_cooldown); + let now = Instant::now(); + // Drop elapsed entries so the map stays bounded by the number of mails + // currently cooling down, not every mail that has ever failed. + map.retain(|_, until| *until > now); + map.insert(element_id.to_string(), now + BODY_FETCH_COOLDOWN); } pub fn subscribe(&self) -> watch::Receiver {