IMAP: bring OBJECTID+ up to draft rev 08 #219

Merged
jcoffey-dev merged 1 commits from fix/imap-objectid-rev08 into main 2026-10-28 06:35:25 +00:00
6 changed files with 249 additions and 25 deletions

No files matched your search

+16
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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
+10 -2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<Command> {
}
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<Command> {
.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<Command> {
}
}
}
// 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 => {
+47 -2
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<Status>),
SpecialUse,
ObjectId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -85,6 +88,7 @@ pub enum ChildInfo {
pub enum Tag {
ChildInfo(Vec<ChildInfo>),
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 {
+43 -8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<T: SessionStream> Session<T> {
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<T: SessionStream> SessionData<T> {
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<T: SessionStream> SessionData<T> {
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<T: SessionStream> SessionData<T> {
} else {
vec![Attribute::NoSelect]
},
tags: vec![],
tags: no_objectid(),
});
}
added_shared_folder = true;
@@ -204,7 +232,7 @@ impl<T: SessionStream> SessionData<T> {
} else {
vec![Attribute::NoSelect]
},
tags: vec![],
tags: no_objectid(),
});
}
}
@@ -258,14 +286,21 @@ impl<T: SessionStream> SessionData<T> {
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,
});
}
}
+21 -12
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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<T: SessionStream> Session<T> {
.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
+112 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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("[email protected]");
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\" [email protected] 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("[email protected]").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("[email protected]").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\" [email protected]")
.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;