IMAP: bring OBJECTID+ up to draft rev 08 #219
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user