From 80c07fe1f87fb61d46a0f67347ca749c7ca68280 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 27 Oct 2026 22:25:48 -0700 Subject: [PATCH] IMAP: bring OBJECTID+ up to draft rev 08 LIST gains the OBJECTID return option: every name carries an "OBJECTID" extended item (the empty one for names that aren't mailboxes), and asking for it, or for STATUS (OBJECTID) inside LIST, activates OBJECTID+. SELECT/EXAMINE by identifier now follows rev 08's matching rules: a MAILBOXID without an ACCOUNTID means the personal account, an identifier that can't be decoded matches nothing, and a mailbox the user may not read is never matched by identifier. Selection falls back to the name instead of answering NO [NOPERM], which told the client the mailbox existed. --- crates/imap-proto/src/parser/list.rs | 16 ++++ crates/imap-proto/src/parser/select.rs | 12 ++- crates/imap-proto/src/protocol/list.rs | 49 ++++++++++- crates/imap/src/op/list.rs | 51 +++++++++-- crates/imap/src/op/select.rs | 33 +++++--- tests/src/imap/objectid.rs | 113 ++++++++++++++++++++++++- 6 files changed, 249 insertions(+), 25 deletions(-) diff --git a/crates/imap-proto/src/parser/list.rs b/crates/imap-proto/src/parser/list.rs index dcd1021f..51c94a5f 100644 --- a/crates/imap-proto/src/parser/list.rs +++ b/crates/imap-proto/src/parser/list.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use compact_str::{CompactString, ToCompactString}; @@ -202,6 +204,7 @@ impl ReturnOption { "CHILDREN" => Self::Children, "STATUS" => Self::Status(Vec::with_capacity(2)), "SPECIAL-USE" => Self::SpecialUse, + "OBJECTID" => Self::ObjectId, ) .ok_or_else(|| format!("Invalid return option {:?}", String::from_utf8_lossy(value)).into()) } @@ -368,6 +371,19 @@ mod tests { ], }, ), + ( + "A03 LIST \"\" * RETURN (OBJECTID STATUS (OBJECTID))\r\n", + list::Arguments::Extended { + tag: "A03".into(), + reference_name: "".into(), + mailbox_name: vec!["*".into()], + selection_options: vec![], + return_options: vec![ + ReturnOption::ObjectId, + ReturnOption::Status(vec![Status::ObjectId]), + ], + }, + ), ] { assert_eq!( receiver diff --git a/crates/imap-proto/src/parser/select.rs b/crates/imap-proto/src/parser/select.rs index 0e64fc78..d4e1bd82 100644 --- a/crates/imap-proto/src/parser/select.rs +++ b/crates/imap-proto/src/parser/select.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use compact_str::{CompactString, ToCompactString, format_compact}; @@ -162,6 +164,7 @@ impl Request { } Token::Argument(param) if param.eq_ignore_ascii_case(b"OBJECTID") => { let mut oid = ObjectId::default(); + let mut unmatchable = false; if matches!(tokens.peek(), Some(Token::ParenthesisOpen)) { tokens.next(); while let Some(token) = tokens.next() { @@ -181,8 +184,8 @@ impl Request { .ok() .and_then(|v| Id::from_str(v).ok()); hashify::fnc_map_ignore_case!(key.as_slice(), - "MAILBOXID" => { oid.mailbox_id = id; }, - "ACCOUNTID" => { oid.account_id = id; }, + "MAILBOXID" => { unmatchable |= id.is_none(); oid.mailbox_id = id; }, + "ACCOUNTID" => { unmatchable |= id.is_none(); oid.account_id = id; }, "EMAILID" => { oid.email_id = id; }, "THREADID" => { oid.thread_id = id; }, _ => {} @@ -200,6 +203,11 @@ impl Request { } } } + // An identifier this server could never have issued matches + // no mailbox, so selection falls back to the name + if unmatchable { + oid.mailbox_id = None; + } objectid = Some(oid); } Token::ParenthesisClose => { diff --git a/crates/imap-proto/src/protocol/list.rs b/crates/imap-proto/src/protocol/list.rs index 4446a793..1ff338a7 100644 --- a/crates/imap-proto/src/protocol/list.rs +++ b/crates/imap-proto/src/protocol/list.rs @@ -2,12 +2,14 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utf7::utf7_encode; use super::{ - ImapResponse, quoted_string, + ImapResponse, ObjectId, quoted_string, status::{Status, StatusItem}, }; @@ -50,6 +52,7 @@ pub enum ReturnOption { Children, Status(Vec), SpecialUse, + ObjectId, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -85,6 +88,7 @@ pub enum ChildInfo { pub enum Tag { ChildInfo(Vec), OldName(String), + ObjectId(ObjectId), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -199,6 +203,10 @@ impl Tag { quoted_string(buf, old_name); buf.push(b')'); } + Tag::ObjectId(object_id) => { + buf.extend_from_slice(b"\"OBJECTID\" "); + object_id.serialize_kvpairs(buf); + } } } } @@ -289,9 +297,10 @@ impl ImapResponse for Response { mod tests { use crate::protocol::{ - ImapResponse, + ImapResponse, ObjectId, status::{Status, StatusItem, StatusItemType}, }; + use types::id::Id; use super::{Attribute, ChildInfo, ListItem, Tag}; @@ -358,6 +367,42 @@ mod tests { } } + #[test] + fn serialize_list_objectid() { + let mut buf = Vec::new(); + ListItem { + mailbox_name: "bar".into(), + attributes: vec![], + tags: vec![Tag::ObjectId(ObjectId { + mailbox_id: Some(Id::from(3u32)), + account_id: Some(Id::from(1u32)), + ..Default::default() + })], + } + .serialize(&mut buf, true, true, false); + assert_eq!( + String::from_utf8(buf).unwrap(), + format!( + "* LIST () \"/\" \"bar\" (\"OBJECTID\" (ACCOUNTID {} MAILBOXID {}))\r\n", + Id::from(1u32), + Id::from(3u32) + ) + ); + + // A name with no identifiers gets the empty item + let mut buf = Vec::new(); + ListItem { + mailbox_name: "Shared Folders".into(), + attributes: vec![Attribute::NoSelect], + tags: vec![Tag::ObjectId(ObjectId::default())], + } + .serialize(&mut buf, true, true, false); + assert_eq!( + String::from_utf8(buf).unwrap(), + "* LIST (\\NoSelect) \"/\" \"Shared Folders\" (\"OBJECTID\" ())\r\n" + ); + } + #[test] fn serialize_list() { let mut response = super::Response { diff --git a/crates/imap/src/op/list.rs b/crates/imap/src/op/list.rs index 6ad81800..4c2274b8 100644 --- a/crates/imap/src/op/list.rs +++ b/crates/imap/src/op/list.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::time::Instant; @@ -15,15 +17,17 @@ use common::network::SessionStream; use imap_proto::{ Command, StatusResponse, protocol::{ - ImapResponse, ProtocolVersion, + ImapResponse, ObjectId, ProtocolVersion, list::{ self, Arguments, Attribute, ChildInfo, ListItem, ReturnOption, SelectionOption, Tag, }, + status::Status, }, receiver::Request, }; use registry::schema::enums::Permission; use trc::StoreEvent; +use types::id::Id; use super::ImapContext; @@ -44,6 +48,18 @@ impl Session { request.parse_lsub(self.is_utf8) }?; + // Activate OBJECTID+ when the OBJECTID return option or STATUS attribute is requested + if let Arguments::Extended { return_options, .. } = &arguments + && return_options.iter().any(|option| match option { + ReturnOption::ObjectId => true, + ReturnOption::Status(status) => status.contains(&Status::ObjectId), + _ => false, + }) + && let Some(enabled) = self.activate_objectid() + { + self.write_bytes(enabled).await?; + } + if !arguments.is_separator_query() { let data = self.state.session_data(); let version = self.version; @@ -128,6 +144,7 @@ impl SessionData { let mut include_subscribed = false; let mut include_children = false; let mut include_status = None; + let mut include_objectid = false; for selection_option in &selection_options { match selection_option { SelectionOption::Subscribed => { @@ -158,8 +175,19 @@ impl SessionData { ReturnOption::SpecialUse => { include_special_use = true; } + ReturnOption::ObjectId => { + include_objectid = true; + } } } + // Names that aren't mailboxes carry the empty OBJECTID item + let no_objectid = || { + if include_objectid { + vec![Tag::ObjectId(ObjectId::default())] + } else { + vec![] + } + }; if recursive_match && !filter_subscribed { return Err(trc::ImapEvent::Error .into_err() @@ -191,7 +219,7 @@ impl SessionData { } else { vec![Attribute::NoSelect] }, - tags: vec![], + tags: no_objectid(), }); } added_shared_folder = true; @@ -204,7 +232,7 @@ impl SessionData { } else { vec![Attribute::NoSelect] }, - tags: vec![], + tags: no_objectid(), }); } } @@ -258,14 +286,21 @@ impl SessionData { continue; } } + let mut tags = Vec::new(); + if has_recursive_match { + tags.push(Tag::ChildInfo(vec![ChildInfo::Subscribed])); + } + if include_objectid { + tags.push(Tag::ObjectId(ObjectId { + mailbox_id: Some(Id::from(*mailbox_id)), + account_id: Some(Id::from(account.account_id)), + ..Default::default() + })); + } list_items.push(ListItem { mailbox_name: mailbox_name.clone(), attributes, - tags: if !has_recursive_match { - vec![] - } else { - vec![Tag::ChildInfo(vec![ChildInfo::Subscribed])] - }, + tags, }); } } diff --git a/crates/imap/src/op/select.rs b/crates/imap/src/op/select.rs index 7ad3db48..5743af41 100644 --- a/crates/imap/src/op/select.rs +++ b/crates/imap/src/op/select.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{ImapContext, ToModSeq}; @@ -50,19 +52,26 @@ impl Session { .await .imap_ctx(&arguments.tag, trc::location!())?; - // Resolve the mailbox by its object identifiers (with fallback to the name) - let mailbox = arguments - .objectid - .as_ref() - .and_then( - |objectid| match (objectid.account_id, objectid.mailbox_id) { - (Some(account_id), Some(mailbox_id)) => { - data.get_mailbox_by_id(account_id.document_id(), mailbox_id.document_id()) - } - _ => None, - }, + // Resolve the mailbox by its object identifiers, falling back to the name. + // A missing ACCOUNTID means the personal account, and a mailbox the user + // may not read is never matched by identifier, so the reply can't reveal it. + let mut mailbox = None; + if let Some(objectid) = &arguments.objectid + && let Some(mailbox_id) = objectid.mailbox_id + && let Some(by_id) = data.get_mailbox_by_id( + objectid + .account_id + .map_or(data.account_id, |account_id| account_id.document_id()), + mailbox_id.document_id(), ) - .or_else(|| data.get_mailbox_by_name(&arguments.mailbox_name)); + && data + .check_mailbox_acl(by_id.account_id, by_id.mailbox_id, Acl::ReadItems) + .await + .imap_ctx(&arguments.tag, trc::location!())? + { + mailbox = Some(by_id); + } + let mailbox = mailbox.or_else(|| data.get_mailbox_by_name(&arguments.mailbox_name)); if let Some(mailbox) = mailbox { if !data diff --git a/tests/src/imap/objectid.rs b/tests/src/imap/objectid.rs index 124b43a0..0cc8de1e 100644 --- a/tests/src/imap/objectid.rs +++ b/tests/src/imap/objectid.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use super::{AssertResult, Type}; @@ -138,7 +140,116 @@ pub async fn test(test: &TestServer) { .await .assert_contains(&format!("MAILBOXID {mailbox_id}")); - // Cleanup + // A MAILBOXID without an ACCOUNTID refers to the personal account + imap.send(&format!( + "SELECT \"DoesNotExist\" (OBJECTID (MAILBOXID {mailbox_id}))" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")); + + // An undecodable ACCOUNTID matches nothing, even beside a valid MAILBOXID + imap.send(&format!( + "SELECT INBOX (OBJECTID (MAILBOXID {mailbox_id} ACCOUNTID 456))" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains(&format!("MAILBOXID {mailbox_id}")); + + // A mailbox the user may see but not read is never selected by identifier: + // the server selects by name, so the reply doesn't reveal that it exists + let other = test.account("popper@example.com"); + let other_account_id = other.id_string().to_string(); + let mut imap_other = other.imap_client().await; + imap_other.send("ENABLE OBJECTID+").await; + imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_other.send("CREATE \"ObjIdPrivate\"").await; + let lines = imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; + let private_id = extract_value(&lines, "MAILBOXID "); + // SETACL reads the session's mailbox list without refreshing it, so list first + imap_other.send("LIST \"\" \"ObjIdPrivate\"").await; + imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_other + .send("SETACL \"ObjIdPrivate\" jdoe@example.com l") + .await; + imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send(&format!( + "SELECT \"ObjIdRenamed\" (OBJECTID (MAILBOXID {private_id} ACCOUNTID {other_account_id}))" + )) + .await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains(&format!("MAILBOXID {mailbox_id}")) + .assert_not_contains(&format!("MAILBOXID {private_id}")); + + // LIST RETURN (OBJECTID) activates OBJECTID+ and gives every name the item: + // identifiers for mailboxes, the empty item for names that aren't mailboxes + let mut imap5 = test.account("jdoe@example.com").imap_client().await; + imap5.send("LIST \"\" \"*\" RETURN (OBJECTID)").await; + let lines = imap5.assert_read(Type::Tagged, ResponseType::Ok).await; + assert!( + lines.iter().any(|l| l.contains("ENABLED OBJECTID+")), + "LIST RETURN (OBJECTID) did not implicitly activate OBJECTID+: {lines:?}" + ); + assert!( + lines.iter().any(|l| l.contains("\"ObjIdRenamed\"") + && l.contains(&format!( + "(\"OBJECTID\" (ACCOUNTID {account_id} MAILBOXID {mailbox_id}))" + ))), + "LIST RETURN (OBJECTID) missing the mailbox's identifiers: {lines:?}" + ); + assert!( + lines + .iter() + .any(|l| l.contains("\"Shared Folders\"") && l.contains("(\"OBJECTID\" ())")), + "LIST RETURN (OBJECTID) missing the empty item on a \\NoSelect name: {lines:?}" + ); + assert!( + lines + .iter() + .filter(|l| l.starts_with("* LIST ")) + .all(|l| l.contains("\"OBJECTID\" (")), + "LIST RETURN (OBJECTID) must give every name the item: {lines:?}" + ); + + // The LIST item and a STATUS OBJECTID for the same mailbox agree + imap5 + .send("LIST \"\" \"ObjIdRenamed\" RETURN (OBJECTID STATUS (OBJECTID))") + .await; + imap5 + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count(&format!("MAILBOXID {mailbox_id}"), 2) + .assert_not_contains("ENABLED OBJECTID+"); + + // Without the return option, LIST carries no identifiers + imap5.send("LIST \"\" \"*\"").await; + imap5 + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_not_contains("OBJECTID"); + + // LIST RETURN (STATUS (OBJECTID)) activates OBJECTID+ too + let mut imap6 = test.account("jdoe@example.com").imap_client().await; + imap6 + .send("LIST \"\" \"INBOX\" RETURN (STATUS (OBJECTID))") + .await; + imap6 + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("ENABLED OBJECTID+") + .assert_contains(&format!("ACCOUNTID {account_id}")); + + // Cleanup: revoke the share first, since deleting a shared mailbox leaves the + // owner's empty prefix in the sharee's list + imap_other + .send("DELETEACL \"ObjIdPrivate\" jdoe@example.com") + .await; + imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_other.send("DELETE \"ObjIdPrivate\"").await; + imap_other.assert_read(Type::Tagged, ResponseType::Ok).await; imap.send("UNSELECT").await; imap.assert_read(Type::Tagged, ResponseType::Ok).await; imap.send("DELETE \"ObjIdRenamed\"").await; -- 2.54.0