From 77042480ae0673e8200fe5fc7a1675282e0b371f Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 14 May 2026 16:04:19 +0200 Subject: [PATCH] Fix IMAP: `UID FETCH N:*` could miss messages moved into a SELECTed mailbox by another connection --- CHANGELOG.md | 1 + crates/imap/src/core/message.rs | 25 +++++++++++++++---------- tests/src/imap/copy_move.rs | 25 ++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6cdf3e..5e40b49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - DAV: `acl-principal-prop-set` REPORT enforced the wrong privilege. - JMAP: `Thread/get` did not filter by per-mailbox ACLs on shared accounts. - RFC2136 TSIG: regression related to multiplexer. +- IMAP: `UID FETCH N:*` could miss messages moved into a SELECTed mailbox by another connection. ## [0.16.5] - 2026-05-11 diff --git a/crates/imap/src/core/message.rs b/crates/imap/src/core/message.rs index 44d7fe66..fa5c3be3 100644 --- a/crates/imap/src/core/message.rs +++ b/crates/imap/src/core/message.rs @@ -194,23 +194,28 @@ impl SelectedMailbox { if !sequence.is_saved_search() { let mut ids = AHashMap::new(); let state = self.state.lock(); + let (id_to_imap, uid_max, total_messages) = + if let Some(next) = state.next_state.as_ref() { + ( + &next.next_state.id_to_imap, + next.next_state.uid_max, + next.next_state.total_messages, + ) + } else { + (&state.id_to_imap, state.uid_max, state.total_messages) + }; if is_uid { - let id_to_imap = state - .next_state - .as_ref() - .map(|s| &s.next_state.id_to_imap) - .unwrap_or(&state.id_to_imap); - if !state.id_to_imap.is_empty() { + if !id_to_imap.is_empty() { for (id, imap_id) in id_to_imap { - if sequence.contains(imap_id.uid, state.uid_max) { + if sequence.contains(imap_id.uid, uid_max) { ids.insert(*id, *imap_id); } } } - } else if !state.id_to_imap.is_empty() { - for (id, imap_id) in &state.id_to_imap { - if sequence.contains(imap_id.seqnum, state.total_messages as u32) { + } else if !id_to_imap.is_empty() { + for (id, imap_id) in id_to_imap { + if sequence.contains(imap_id.seqnum, total_messages as u32) { ids.insert(*id, *imap_id); } } diff --git a/tests/src/imap/copy_move.rs b/tests/src/imap/copy_move.rs index 2c52f568..a0ed38c5 100644 --- a/tests/src/imap/copy_move.rs +++ b/tests/src/imap/copy_move.rs @@ -7,7 +7,7 @@ use super::{AssertResult, ImapConnection, Type}; use imap_proto::ResponseType; -pub async fn test(_imap: &mut ImapConnection, imap_check: &mut ImapConnection) { +pub async fn test(imap: &mut ImapConnection, imap_check: &mut ImapConnection) { println!("Running COPY/MOVE tests..."); // Check status @@ -151,4 +151,27 @@ pub async fn test(_imap: &mut ImapConnection, imap_check: &mut ImapConnection) { .assert_contains("\"Burrata al Tartufo\" (UIDNEXT 5 MESSAGES 0 UNSEEN 0 SIZE 0)") .assert_contains("\"Scamorza Affumicata\" (UIDNEXT 9 MESSAGES 4 UNSEEN 4 SIZE 5851)") .assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193)"); + + imap_check.send("SELECT \"Burrata al Tartufo\"").await; + imap_check.assert_read(Type::Tagged, ResponseType::Ok).await; + + imap.send("SELECT \"Scamorza Affumicata\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID MOVE 5 \"Burrata al Tartufo\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COPYUID"); + + imap_check.send("UID FETCH 1:* (UID)").await; + imap_check + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("UID 5"); + + imap.send("SELECT \"Burrata al Tartufo\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("UID MOVE 5 \"Scamorza Affumicata\"").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("COPYUID"); }