IMAP Extension for Object Identifiers (draft-ietf-mailmaint-imap-objectid-bis-04)

This commit is contained in:
Maurus Decimus
2026-06-21 11:29:27 +02:00
parent 097e3ffe94
commit d33c4afbc1
26 changed files with 834 additions and 201 deletions

View File

@@ -19,7 +19,7 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
// Fetch all properties available from JMAP
imap.send(concat!(
"FETCH 10 (FLAGS INTERNALDATE PREVIEW EMAILID THREADID ",
"FETCH 10 (FLAGS INTERNALDATE PREVIEW OBJECTID ",
"RFC822.SIZE UID ENVELOPE BODYSTRUCTURE)"
))
.await;
@@ -29,8 +29,9 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
.assert_contains("RFC822.SIZE 1457")
.assert_contains("UID 10")
.assert_contains("INTERNALDATE")
.assert_contains("THREADID (")
.assert_contains("EMAILID (")
.assert_contains("OBJECTID (")
.assert_contains("EMAILID ")
.assert_contains("THREADID ")
.assert_contains("but then I thought, why not do both?")
.assert_contains(concat!(
"ENVELOPE (\"Sat, 20 Nov 2021 14:22:01 -0800\" ",

View File

@@ -90,9 +90,7 @@ pub async fn test(
// Create missing parent folders
imap.send("CREATE \"/Vegetable/Broccoli\" (USE (\\Important))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("[MAILBOXID (");
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap.send("CREATE \" Cars/Electric /4 doors/ Red/\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;

View File

@@ -15,6 +15,7 @@ pub mod fetch;
pub mod idle;
pub mod mailbox;
pub mod managesieve;
pub mod objectid;
pub mod pop;
pub mod search;
pub mod store;
@@ -249,6 +250,7 @@ pub async fn imap_tests() {
append::test(&mut imap, &mut imap_check, &test).await;
search::test(&mut imap, &mut imap_check, &test).await;
fetch::test(&mut imap, &mut imap_check).await;
objectid::test(&test).await;
store::test(&mut imap, &mut imap_check, &test).await;
copy_move::test(&mut imap, &mut imap_check).await;
thread::test(&mut imap, &mut imap_check, &test).await;

204
tests/src/imap/objectid.rs Normal file
View File

@@ -0,0 +1,204 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{AssertResult, Type};
use crate::utils::server::TestServer;
use imap_proto::ResponseType;
pub async fn test(test: &TestServer) {
println!("Running OBJECTID+ tests...");
let account = test.account("jdoe@example.com");
let account_id = account.id_string().to_string();
let mut imap = account.imap_client().await;
// OBJECTID+ is advertised
imap.send("CAPABILITY").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("OBJECTID+")
.assert_count("OBJECTID ", 0);
// Before activation no object identifiers are leaked
imap.send("SELECT INBOX").await;
let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await;
assert!(
!lines
.iter()
.any(|l| l.contains("OBJECTID") || l.contains("MAILBOXID")),
"Pre-activation SELECT leaked object identifiers: {lines:?}"
);
// Explicit activation via ENABLE
imap.send("ENABLE OBJECTID+").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("ENABLED OBJECTID+");
// SELECT now returns a compound OBJECTID with MAILBOXID and ACCOUNTID
imap.send("SELECT INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("[OBJECTID (")
.assert_contains("MAILBOXID ")
.assert_contains(&format!("ACCOUNTID {account_id}"));
// STATUS OBJECTID returns the compound for the queried mailbox, and an
// already-activated session is not sent a second ENABLED response
imap.send("STATUS INBOX (OBJECTID)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("OBJECTID (")
.assert_contains("MAILBOXID ")
.assert_contains(&format!("ACCOUNTID {account_id}"))
.assert_not_contains("ENABLED OBJECTID+");
// FETCH OBJECTID returns EMAILID and THREADID but never ACCOUNTID
imap.send("UID FETCH 1 (OBJECTID)").await;
let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await;
assert!(
lines
.iter()
.any(|l| l.contains("OBJECTID (") && l.contains("EMAILID ") && l.contains("THREADID ")),
"FETCH OBJECTID must include EMAILID and THREADID: {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("ACCOUNTID")),
"FETCH OBJECTID must not include ACCOUNTID: {lines:?}"
);
// Commands that do not request the OBJECTID item never emit it, even once activated
imap.send("UID FETCH 1 (FLAGS)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_not_contains("OBJECTID");
imap.send("STATUS INBOX (MESSAGES)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_not_contains("OBJECTID");
// EXAMINE returns the compound OBJECTID response code just like SELECT
imap.send("EXAMINE INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("[OBJECTID (")
.assert_contains("MAILBOXID ")
.assert_contains(&format!("ACCOUNTID {account_id}"));
// CREATE returns the compound OBJECTID response code
imap.send("CREATE \"ObjIdTest\"").await;
let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await;
let mailbox_id = extract_value(&lines, "MAILBOXID ");
assert!(
lines
.iter()
.any(|l| l.contains(&format!("ACCOUNTID {account_id}"))),
"CREATE OBJECTID missing ACCOUNTID: {lines:?}"
);
// RENAME returns the compound OBJECTID response code and preserves the MAILBOXID
imap.send("RENAME \"ObjIdTest\" \"ObjIdRenamed\"").await;
let lines = imap.assert_read(Type::Tagged, ResponseType::Ok).await;
let renamed_id = extract_value(&lines, "MAILBOXID ");
assert_eq!(
mailbox_id, renamed_id,
"RENAME must preserve the MAILBOXID: {lines:?}"
);
// Identifier-based selection resolves the mailbox regardless of its current name
imap.send(&format!(
"SELECT \"DoesNotExist\" (OBJECTID (MAILBOXID {mailbox_id} ACCOUNTID {account_id}))"
))
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains(&format!("MAILBOXID {mailbox_id}"));
// An unknown MAILBOXID falls back to selecting by name
imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (MAILBOXID abcdefgh ACCOUNTID abcdefgh))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains(&format!("MAILBOXID {mailbox_id}"));
// An undecodable identifier falls back to selecting by name instead of failing
imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (MAILBOXID 456))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains(&format!("MAILBOXID {mailbox_id}"));
// Unrecognised keys in the OBJECTID parameter are ignored
imap.send("SELECT \"ObjIdRenamed\" (OBJECTID (FOOBAR baz MAILBOXID 456))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains(&format!("MAILBOXID {mailbox_id}"));
// Cleanup
imap.send("UNSELECT").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap.send("DELETE \"ObjIdRenamed\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Implicit activation via the STATUS attribute on a fresh session
let mut imap2 = test.account("jdoe@example.com").imap_client().await;
imap2.send("STATUS INBOX (OBJECTID)").await;
let lines = imap2.assert_read(Type::Tagged, ResponseType::Ok).await;
assert!(
lines.iter().any(|l| l.contains("ENABLED OBJECTID+")),
"STATUS did not implicitly activate OBJECTID+: {lines:?}"
);
// Implicit activation via the SELECT OBJECTID parameter on a fresh session
let mut imap3 = test.account("jdoe@example.com").imap_client().await;
imap3.send("SELECT INBOX (OBJECTID)").await;
let lines = imap3.assert_read(Type::Tagged, ResponseType::Ok).await;
assert!(
lines.iter().any(|l| l.contains("ENABLED OBJECTID+")),
"SELECT (OBJECTID) did not implicitly activate OBJECTID+: {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("[OBJECTID (")),
"SELECT (OBJECTID) did not return a compound OBJECTID: {lines:?}"
);
// Activation via FETCH then a plain SELECT/CREATE must still carry the compound code
let mut imap4 = test.account("jdoe@example.com").imap_client().await;
imap4.send("SELECT INBOX").await;
imap4.assert_read(Type::Tagged, ResponseType::Ok).await;
imap4.send("UID FETCH 1 (OBJECTID)").await;
let lines = imap4.assert_read(Type::Tagged, ResponseType::Ok).await;
assert!(
lines.iter().any(|l| l.contains("ENABLED OBJECTID+")),
"FETCH (OBJECTID) did not implicitly activate OBJECTID+: {lines:?}"
);
imap4.send("SELECT INBOX").await;
imap4
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("[OBJECTID (");
imap4.send("CREATE \"ObjIdTest4\"").await;
imap4
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("[OBJECTID (");
imap4.send("DELETE \"ObjIdTest4\"").await;
imap4.assert_read(Type::Tagged, ResponseType::Ok).await;
}
fn extract_value(lines: &[String], key: &str) -> String {
for line in lines {
if let Some((_, rest)) = line.split_once(key) {
return rest
.split([' ', ')'])
.next()
.expect("Missing value delimiter")
.to_string();
}
}
panic!("Key {key:?} not found in {lines:?}");
}

View File

@@ -52,21 +52,21 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, t
let mut email_id = None;
let mut thread_id = None;
imap.send("UID FETCH 1 (EMAILID THREADID)").await;
imap.send("UID FETCH 1 (OBJECTID)").await;
for line in imap.assert_read(Type::Tagged, ResponseType::Ok).await {
if let Some((_, value)) = line.split_once("EMAILID (") {
if let Some((_, value)) = line.split_once("EMAILID ") {
email_id = value
.split_once(')')
.split([' ', ')'])
.next()
.expect("Missing delimiter")
.0
.to_string()
.into();
}
if let Some((_, value)) = line.split_once("THREADID (") {
if let Some((_, value)) = line.split_once("THREADID ") {
thread_id = value
.split_once(')')
.split([' ', ')'])
.next()
.expect("Missing delimiter")
.0
.to_string()
.into();
}