From d33c4afbc183b1871233e06a05e6d6435f6b71c3 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:29:27 +0200 Subject: [PATCH] IMAP Extension for Object Identifiers (draft-ietf-mailmaint-imap-objectid-bis-04) --- CHANGELOG.md | 4 +- Cargo.lock | 16 +- crates/imap-proto/src/lib.rs | 5 +- crates/imap-proto/src/parser/enable.rs | 52 +++-- crates/imap-proto/src/parser/fetch.rs | 40 ++-- crates/imap-proto/src/parser/select.rs | 150 +++++++++++++- crates/imap-proto/src/parser/status.rs | 58 ++++-- crates/imap-proto/src/protocol/capability.rs | 15 +- crates/imap-proto/src/protocol/fetch.rs | 34 +--- crates/imap-proto/src/protocol/mod.rs | 96 ++++++++- crates/imap-proto/src/protocol/select.rs | 120 ++++++----- crates/imap-proto/src/protocol/status.rs | 29 ++- crates/imap/src/core/mailbox.rs | 15 ++ crates/imap/src/core/mod.rs | 1 + crates/imap/src/core/session.rs | 11 + crates/imap/src/op/create.rs | 26 ++- crates/imap/src/op/enable.rs | 3 + crates/imap/src/op/fetch.rs | 26 ++- crates/imap/src/op/rename.rs | 25 ++- crates/imap/src/op/select.rs | 35 +++- crates/imap/src/op/status.rs | 43 +++- tests/src/imap/fetch.rs | 7 +- tests/src/imap/mailbox.rs | 4 +- tests/src/imap/mod.rs | 2 + tests/src/imap/objectid.rs | 204 +++++++++++++++++++ tests/src/imap/thread.rs | 14 +- 26 files changed, 834 insertions(+), 201 deletions(-) create mode 100644 tests/src/imap/objectid.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b0d0b84..4b4d51e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - Client secret verification for confidential clients. - HTTP: Add `redirectRoot` option to `Http` object to allow redirecting requests to the root path to a different path (e.g. `/account`). - ACME: `reuseKey` option to allow reusing private keys in renewals. -- IMAP: `GETJMAPACCESS` command to discover the JMAP session resource URL (#2736). +- IMAP: + - IMAP Extension for Object Identifiers ([draft-ietf-mailmaint-imap-objectid-bis](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-imap-objectid-bis/)) + - `GETJMAPACCESS` command to discover the JMAP session resource URL (#2736). ## Changed diff --git a/Cargo.lock b/Cargo.lock index 88e21ebe..98538ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,9 +167,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" [[package]] name = "ascii-canvas" @@ -1050,9 +1050,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -4278,9 +4278,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -4376,9 +4376,9 @@ dependencies = [ [[package]] name = "mail-parser" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +checksum = "f2c0e7e0704500930be5b6c629f30d23fd1dde4d1800e138e04b3fa302e64d51" dependencies = [ "encoding_rs", "hashify", diff --git a/crates/imap-proto/src/lib.rs b/crates/imap-proto/src/lib.rs index 6647cc63..16799907 100644 --- a/crates/imap-proto/src/lib.rs +++ b/crates/imap-proto/src/lib.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use protocol::ObjectId; use protocol::capability::Capability; use std::borrow::Cow; @@ -149,9 +150,7 @@ pub enum ResponseCode { }, // ObjectID - MailboxId { - mailbox_id: String, - }, + ObjectId(ObjectId), // USEATTR UseAttr, diff --git a/crates/imap-proto/src/parser/enable.rs b/crates/imap-proto/src/parser/enable.rs index d8d06d8c..e638de48 100644 --- a/crates/imap-proto/src/parser/enable.rs +++ b/crates/imap-proto/src/parser/enable.rs @@ -4,13 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use compact_str::ToCompactString; - use crate::{ Command, protocol::{capability::Capability, enable}, receiver::{Request, bad}, }; +use compact_str::ToCompactString; impl Request { pub fn parse_enable(self) -> trc::Result { @@ -42,6 +41,7 @@ impl Capability { "CONDSTORE" => Self::CondStore, "QRESYNC" => Self::QResync, "UTF8=ACCEPT" => Self::Utf8Accept, + "OBJECTID+" => Self::ObjectIdPlus, ) .ok_or_else(|| { format!( @@ -64,16 +64,42 @@ mod tests { fn parse_enable() { let mut receiver = Receiver::new(); - assert_eq!( - receiver - .parse(&mut "t2 ENABLE IMAP4rev2 CONDSTORE\r\n".as_bytes().iter()) - .unwrap() - .parse_enable() - .unwrap(), - enable::Arguments { - tag: "t2".into(), - capabilities: vec![Capability::IMAP4rev2, Capability::CondStore], - } - ); + for (command, arguments) in [ + ( + "t2 ENABLE IMAP4rev2 CONDSTORE\r\n", + enable::Arguments { + tag: "t2".into(), + capabilities: vec![Capability::IMAP4rev2, Capability::CondStore], + }, + ), + ( + "t3 ENABLE OBJECTID+\r\n", + enable::Arguments { + tag: "t3".into(), + capabilities: vec![Capability::ObjectIdPlus], + }, + ), + ( + "t4 ENABLE CONDSTORE OBJECTID+ UTF8=ACCEPT\r\n", + enable::Arguments { + tag: "t4".into(), + capabilities: vec![ + Capability::CondStore, + Capability::ObjectIdPlus, + Capability::Utf8Accept, + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_enable() + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } } } diff --git a/crates/imap-proto/src/parser/fetch.rs b/crates/imap-proto/src/parser/fetch.rs index e10e7a85..d18986f9 100644 --- a/crates/imap-proto/src/parser/fetch.rs +++ b/crates/imap-proto/src/parser/fetch.rs @@ -4,19 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; -use std::iter::Peekable; -use std::vec::IntoIter; - -use compact_str::{CompactString, ToCompactString, format_compact}; - +use super::{PushUnique, parse_number, parse_sequence_set}; use crate::{ Command, protocol::fetch::{self, Attribute, Section}, receiver::{Request, Token, bad}, }; - -use super::{PushUnique, parse_number, parse_sequence_set}; +use compact_str::{CompactString, ToCompactString, format_compact}; +use std::borrow::Cow; +use std::iter::Peekable; +use std::vec::IntoIter; impl Request { #[allow(clippy::while_let_on_iterator)] @@ -328,11 +325,8 @@ impl Request { "MODSEQ" => { attributes.push_unique(Attribute::ModSeq); }, - "EMAILID" => { - attributes.push_unique(Attribute::EmailId); - }, - "THREADID" => { - attributes.push_unique(Attribute::ThreadId); + "OBJECTID" => { + attributes.push_unique(Attribute::ObjectId); }, _ => { return Err(bad( @@ -798,6 +792,26 @@ mod tests { include_vanished: true, }, ), + ( + "A010 FETCH 1:* (OBJECTID)\r\n", + fetch::Arguments { + tag: "A010".into(), + sequence_set: Sequence::range(1.into(), None), + attributes: vec![Attribute::ObjectId], + changed_since: None, + include_vanished: false, + }, + ), + ( + "A011 FETCH 1 (UID OBJECTID FLAGS)\r\n", + fetch::Arguments { + tag: "A011".into(), + sequence_set: Sequence::number(1), + attributes: vec![Attribute::Uid, Attribute::ObjectId, Attribute::Flags], + changed_since: None, + include_vanished: false, + }, + ), ] { assert_eq!( receiver diff --git a/crates/imap-proto/src/parser/select.rs b/crates/imap-proto/src/parser/select.rs index 783d1453..0e64fc78 100644 --- a/crates/imap-proto/src/parser/select.rs +++ b/crates/imap-proto/src/parser/select.rs @@ -5,10 +5,15 @@ */ use compact_str::{CompactString, ToCompactString, format_compact}; +use std::str::FromStr; +use types::id::Id; use crate::{ Command, - protocol::select::{self, QResync}, + protocol::{ + ObjectId, + select::{self, QResync}, + }, receiver::{Request, Token, bad}, utf7::utf7_maybe_decode, }; @@ -33,6 +38,7 @@ impl Request { // CONDSTORE parameters let mut condstore = false; let mut qresync = None; + let mut objectid = None; match tokens.next() { Some(Token::ParenthesisOpen) => { while let Some(token) = tokens.next() { @@ -154,6 +160,48 @@ impl Request { } .into(); } + Token::Argument(param) if param.eq_ignore_ascii_case(b"OBJECTID") => { + let mut oid = ObjectId::default(); + if matches!(tokens.peek(), Some(Token::ParenthesisOpen)) { + tokens.next(); + while let Some(token) = tokens.next() { + match token { + Token::ParenthesisClose => break, + Token::Argument(key) => { + let value = tokens + .next() + .ok_or_else(|| { + bad( + self.tag.to_compact_string(), + "Expected value after OBJECTID key.", + ) + })? + .unwrap_bytes(); + let id = std::str::from_utf8(&value) + .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; }, + "EMAILID" => { oid.email_id = id; }, + "THREADID" => { oid.thread_id = id; }, + _ => {} + ); + } + _ => { + return Err(bad( + CompactString::from_string_buffer(self.tag), + format_compact!( + "Unexpected value '{}'.", + token + ), + )); + } + } + } + } + objectid = Some(oid); + } Token::ParenthesisClose => { break; } @@ -180,6 +228,7 @@ impl Request { tag: self.tag, condstore, qresync, + objectid, }) } else { Err(self.into_error("Missing mailbox name.")) @@ -191,11 +240,101 @@ impl Request { mod tests { use crate::{ protocol::{ - Sequence, + ObjectId, Sequence, select::{self, QResync}, }, receiver::Receiver, }; + use std::str::FromStr; + use types::id::Id; + + #[test] + fn parse_select_objectid() { + let mut receiver = Receiver::new(); + + for (command, arguments) in [ + ( + "A1 SELECT \"foo\" (OBJECTID)\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A1".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ( + "A2 SELECT \"foo\" (OBJECTID (MAILBOXID abc ACCOUNTID xyz))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A2".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + account_id: Some(Id::from_str("xyz").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A3 EXAMINE \"foo\" (OBJECTID (MAILBOXID abc))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A3".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A4 SELECT \"foo\" (CONDSTORE OBJECTID)\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A4".into(), + condstore: true, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ( + "A5 SELECT \"foo\" (OBJECTID (FOOBAR baz MAILBOXID abc))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A5".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId { + mailbox_id: Some(Id::from_str("abc").unwrap()), + ..Default::default() + }), + }, + ), + ( + "A6 SELECT \"foo\" (OBJECTID (MAILBOXID 456))\r\n", + select::Arguments { + mailbox_name: "foo".into(), + tag: "A6".into(), + condstore: false, + qresync: None, + objectid: Some(ObjectId::default()), + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_select(true) + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } + } #[test] fn parse_select() { @@ -209,6 +348,7 @@ mod tests { tag: "A142".into(), condstore: false, qresync: None, + objectid: None, }, ), ( @@ -218,6 +358,7 @@ mod tests { tag: "A142".into(), condstore: false, qresync: None, + objectid: None, }, ), ( @@ -227,6 +368,7 @@ mod tests { tag: "A142".into(), condstore: true, qresync: None, + objectid: None, }, ), ( @@ -245,6 +387,7 @@ mod tests { seq_match: None, } .into(), + objectid: None, }, ), ( @@ -274,6 +417,7 @@ mod tests { seq_match: None, } .into(), + objectid: None, }, ), ( @@ -326,6 +470,7 @@ mod tests { )), } .into(), + objectid: None, }, ), ( @@ -341,6 +486,7 @@ mod tests { seq_match: None, } .into(), + objectid: None, }, ), ] { diff --git a/crates/imap-proto/src/parser/status.rs b/crates/imap-proto/src/parser/status.rs index 9e9b9924..2f7183c1 100644 --- a/crates/imap-proto/src/parser/status.rs +++ b/crates/imap-proto/src/parser/status.rs @@ -84,7 +84,7 @@ impl Status { "DELETED" => Self::Deleted, "SIZE" => Self::Size, "HIGHESTMODSEQ" => Self::HighestModSeq, - "MAILBOXID" => Self::MailboxId, + "OBJECTID" => Self::ObjectId, "RECENT" => Self::Recent, "DELETED-STORAGE" => Self::DeletedStorage ) @@ -106,21 +106,45 @@ mod tests { fn parse_status() { let mut receiver = Receiver::new(); - assert_eq!( - receiver - .parse( - &mut "A042 STATUS blurdybloop (UIDNEXT MESSAGES)\r\n" - .as_bytes() - .iter() - ) - .unwrap() - .parse_status(true) - .unwrap(), - status::Arguments { - tag: "A042".into(), - mailbox_name: "blurdybloop".into(), - items: vec![status::Status::UidNext, status::Status::Messages], - } - ); + for (command, arguments) in [ + ( + "A042 STATUS blurdybloop (UIDNEXT MESSAGES)\r\n", + status::Arguments { + tag: "A042".into(), + mailbox_name: "blurdybloop".into(), + items: vec![status::Status::UidNext, status::Status::Messages], + }, + ), + ( + "A043 STATUS foo (OBJECTID)\r\n", + status::Arguments { + tag: "A043".into(), + mailbox_name: "foo".into(), + items: vec![status::Status::ObjectId], + }, + ), + ( + "A044 STATUS foo (MESSAGES OBJECTID UIDVALIDITY)\r\n", + status::Arguments { + tag: "A044".into(), + mailbox_name: "foo".into(), + items: vec![ + status::Status::Messages, + status::Status::ObjectId, + status::Status::UidValidity, + ], + }, + ), + ] { + assert_eq!( + receiver + .parse(&mut command.as_bytes().iter()) + .unwrap() + .parse_status(true) + .unwrap(), + arguments, + "Failed to parse {command}" + ); + } } } diff --git a/crates/imap-proto/src/protocol/capability.rs b/crates/imap-proto/src/protocol/capability.rs index 9a97ab79..31f1340c 100644 --- a/crates/imap-proto/src/protocol/capability.rs +++ b/crates/imap-proto/src/protocol/capability.rs @@ -46,7 +46,7 @@ pub enum Capability { LiteralPlus, //LITERAL+ UnAuthenticate, StatusSize, //STATUS=SIZE - ObjectId, + ObjectIdPlus, Preview, Utf8Accept, Auth(Mechanism), @@ -56,15 +56,6 @@ pub enum Capability { JmapAccess, } -/* - -STORAGE The physical space estimate, in units of 1024 octets, of the mailboxes governed by the quota root. DELETED-STORAGE STATUS request data item and response data item N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.1] -MESSAGE The number of messages stored within the mailboxes governed by the quota root. DELETED STATUS request data item and response data item N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.2] -MAILBOX The number of mailboxes governed by the quota root. N/A N/A [Alexey_Melnikov] [IESG] [RFC9208, Section 5.3] -ANNOTATION-STORAGE - -*/ - #[derive(Debug, Clone, PartialEq, Eq)] pub enum QuotaResourceName { Storage, @@ -90,7 +81,7 @@ impl Capability { Capability::LiteralPlus => b"LITERAL+", Capability::UnAuthenticate => b"UNAUTHENTICATE", Capability::StatusSize => b"STATUS=SIZE", - Capability::ObjectId => b"OBJECTID", + Capability::ObjectIdPlus => b"OBJECTID+", Capability::Preview => b"PREVIEW", Capability::Idle => b"IDLE", Capability::Namespace => b"NAMESPACE", @@ -171,7 +162,7 @@ impl Capability { Capability::QResync, Capability::UnAuthenticate, Capability::StatusSize, - Capability::ObjectId, + Capability::ObjectIdPlus, Capability::Preview, Capability::Rights, Capability::Quota, diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs index 8d10b61d..b9648aa3 100644 --- a/crates/imap-proto/src/protocol/fetch.rs +++ b/crates/imap-proto/src/protocol/fetch.rs @@ -4,17 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; - -use mail_parser::DateTime; -use utils::chained_bytes::SliceRange; - -use crate::protocol::literal_string_slice; - use super::{ - Flag, ImapResponse, Sequence, literal_string, quoted_or_literal_string, + Flag, ImapResponse, ObjectId, Sequence, literal_string, quoted_or_literal_string, quoted_or_literal_string_or_nil, quoted_rfc2822_or_nil, quoted_timestamp, }; +use crate::protocol::literal_string_slice; +use mail_parser::DateTime; +use std::borrow::Cow; +use utils::chained_bytes::SliceRange; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Arguments { @@ -65,8 +62,7 @@ pub enum Attribute { lazy: bool, }, ModSeq, - EmailId, - ThreadId, + ObjectId, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -130,12 +126,7 @@ pub enum DataItem<'x> { ModSeq { modseq: u64, }, - EmailId { - email_id: String, - }, - ThreadId { - thread_id: String, - }, + ObjectId(ObjectId), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -837,15 +828,8 @@ impl DataItem<'_> { buf.extend_from_slice(modseq.to_string().as_bytes()); buf.push(b')'); } - DataItem::EmailId { email_id } => { - buf.extend_from_slice(b"EMAILID ("); - buf.extend_from_slice(email_id.as_bytes()); - buf.push(b')'); - } - DataItem::ThreadId { thread_id } => { - buf.extend_from_slice(b"THREADID ("); - buf.extend_from_slice(thread_id.as_bytes()); - buf.push(b')'); + DataItem::ObjectId(object_id) => { + object_id.serialize(buf); } } } diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index 64117f48..c54f9fa8 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -9,6 +9,7 @@ use ahash::AHashSet; use chrono::{DateTime, Utc}; use compact_str::CompactString; use std::{cmp::Ordering, fmt::Display}; +use types::id::Id; use types::keyword::{ArchivedKeyword, Keyword}; use utils::chained_bytes::SliceRange; @@ -40,6 +41,49 @@ pub enum ProtocolVersion { Rev2, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ObjectId { + pub mailbox_id: Option, + pub account_id: Option, + pub email_id: Option, + pub thread_id: Option, +} + +impl ObjectId { + pub fn is_empty(&self) -> bool { + self.mailbox_id.is_none() + && self.account_id.is_none() + && self.email_id.is_none() + && self.thread_id.is_none() + } + + pub fn serialize_kvpairs(&self, buf: &mut Vec) { + buf.push(b'('); + let mut first = true; + for (key, value) in [ + (&b"ACCOUNTID "[..], &self.account_id), + (&b"MAILBOXID "[..], &self.mailbox_id), + (&b"EMAILID "[..], &self.email_id), + (&b"THREADID "[..], &self.thread_id), + ] { + if let Some(value) = value { + if !first { + buf.push(b' '); + } + first = false; + buf.extend_from_slice(key); + buf.extend_from_slice(value.to_string().as_bytes()); + } + } + buf.push(b')'); + } + + pub fn serialize(&self, buf: &mut Vec) { + buf.extend_from_slice(b"OBJECTID "); + self.serialize_kvpairs(buf); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Sequence { Number { @@ -480,10 +524,8 @@ impl ResponseCode { serialize_sequence(buf, ids); return; } - ResponseCode::MailboxId { mailbox_id } => { - buf.extend_from_slice(b"MAILBOXID ("); - buf.extend_from_slice(mailbox_id.as_bytes()); - buf.push(b')'); + ResponseCode::ObjectId(object_id) => { + object_id.serialize(buf); return; } ResponseCode::HighestModseq { modseq } => { @@ -532,7 +574,7 @@ impl ResponseCode { ResponseCode::Unavailable => "UNAVAILABLE", ResponseCode::UnknownCte => "UNKNOWN-CTE", ResponseCode::Modified { .. } => "MODIFIED", - ResponseCode::MailboxId { .. } => "MAILBOXID", + ResponseCode::ObjectId { .. } => "OBJECTID", ResponseCode::HighestModseq { .. } => "HIGHESTMODSEQ", ResponseCode::UseAttr => "USEATTR", } @@ -734,6 +776,50 @@ impl Display for Command { #[cfg(test)] mod tests { use crate::parser::parse_sequence_set; + use crate::protocol::ObjectId; + use types::id::Id; + + #[test] + fn serialize_objectid_compound() { + // Empty compound + let mut buf = Vec::new(); + ObjectId::default().serialize(&mut buf); + assert_eq!(String::from_utf8(buf).unwrap(), "OBJECTID ()"); + + // Mailbox context: MAILBOXID + ACCOUNTID + let mut buf = Vec::new(); + ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + } + .serialize(&mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + format!( + "OBJECTID (ACCOUNTID {} MAILBOXID {})", + Id::from(2u32), + Id::from(1u32) + ) + ); + + // Message context: EMAILID + THREADID only + let mut buf = Vec::new(); + ObjectId { + email_id: Some(Id::from_parts(3, 4)), + thread_id: Some(Id::from(3u32)), + ..Default::default() + } + .serialize(&mut buf); + assert_eq!( + String::from_utf8(buf).unwrap(), + format!( + "OBJECTID (EMAILID {} THREADID {})", + Id::from_parts(3, 4), + Id::from(3u32) + ) + ); + } #[test] fn sequence_set_contains() { diff --git a/crates/imap-proto/src/protocol/select.rs b/crates/imap-proto/src/protocol/select.rs index e4fc3617..077a062a 100644 --- a/crates/imap-proto/src/protocol/select.rs +++ b/crates/imap-proto/src/protocol/select.rs @@ -6,7 +6,7 @@ use crate::{ResponseCode, StatusResponse}; -use super::{ImapResponse, Sequence, list::ListItem}; +use super::{ImapResponse, ObjectId, Sequence, list::ListItem}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Arguments { @@ -14,6 +14,7 @@ pub struct Arguments { pub mailbox_name: String, pub condstore: bool, pub qresync: Option, + pub objectid: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,7 +40,7 @@ pub struct Response { pub is_utf8: bool, pub closed_previous: bool, pub highest_modseq: Option, - pub mailbox_id: String, + pub objectid: Option, } #[derive(Debug, Clone)] @@ -90,9 +91,11 @@ impl ImapResponse for Response { if let Some(highest_modseq) = self.highest_modseq { highest_modseq.serialize(&mut buf); } - buf.extend_from_slice(b"* OK [MAILBOXID ("); - buf.extend_from_slice(self.mailbox_id.as_bytes()); - buf.extend_from_slice(b")] Unique Mailbox ID\r\n"); + if let Some(objectid) = &self.objectid { + buf.extend_from_slice(b"* OK ["); + objectid.serialize(&mut buf); + buf.extend_from_slice(b"] Object identifiers\r\n"); + } buf } } @@ -131,12 +134,23 @@ impl Exists { #[cfg(test)] mod tests { - use crate::protocol::{ImapResponse, list::ListItem}; + use crate::protocol::{ImapResponse, ObjectId, list::ListItem}; + use types::id::Id; use super::HighestModSeq; #[test] fn serialize_select() { + let objectid = ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + }; + let mut objectid_line = b"* OK [".to_vec(); + objectid.serialize(&mut objectid_line); + objectid_line.extend_from_slice(b"] Object identifiers\r\n"); + let objectid_line = String::from_utf8(objectid_line).unwrap(); + for (mut response, _tag, expected_v2, expected_v1) in [ ( super::Response { @@ -150,29 +164,35 @@ mod tests { is_rev2: true, is_utf8: true, highest_modseq: HighestModSeq::new(100).into(), - mailbox_id: "abc".into(), + objectid: Some(objectid.clone()), }, "A142", - concat!( - "* 172 EXISTS\r\n", - "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", - "* LIST () \"/\" \"INBOX\"\r\n", - "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", - "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", - "* OK [UIDNEXT 4392] Next predicted UID\r\n", - "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", - "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + format!( + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"INBOX\"\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "{}" + ), + objectid_line ), - concat!( - "* 172 EXISTS\r\n", - "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", - "* 5 RECENT\r\n", - "* OK [UNSEEN 3] Unseen messages\r\n", - "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", - "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", - "* OK [UIDNEXT 4392] Next predicted UID\r\n", - "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", - "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + format!( + concat!( + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "* OK [HIGHESTMODSEQ 100] Highest Modseq\r\n", + "{}" + ), + objectid_line ), ), ( @@ -187,30 +207,36 @@ mod tests { is_rev2: true, is_utf8: true, highest_modseq: None, - mailbox_id: "abc".into(), + objectid: Some(objectid.clone()), }, "A142", - concat!( - "* OK [CLOSED] Closed previous mailbox\r\n", - "* 172 EXISTS\r\n", - "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", - "* LIST () \"/\" \"~peter/mail/台北/日本語\" (\"OLDNAME\" ", - "(\"~peter/mail/&U,BTFw-/&ZeVnLIqe-\"))\r\n", - "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", - "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", - "* OK [UIDNEXT 4392] Next predicted UID\r\n", - "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + format!( + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n", + "* LIST () \"/\" \"~peter/mail/台北/日本語\" (\"OLDNAME\" ", + "(\"~peter/mail/&U,BTFw-/&ZeVnLIqe-\"))\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "{}" + ), + objectid_line ), - concat!( - "* OK [CLOSED] Closed previous mailbox\r\n", - "* 172 EXISTS\r\n", - "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", - "* 5 RECENT\r\n", - "* OK [UNSEEN 3] Unseen messages\r\n", - "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", - "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", - "* OK [UIDNEXT 4392] Next predicted UID\r\n", - "* OK [MAILBOXID (abc)] Unique Mailbox ID\r\n" + format!( + concat!( + "* OK [CLOSED] Closed previous mailbox\r\n", + "* 172 EXISTS\r\n", + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n", + "* 5 RECENT\r\n", + "* OK [UNSEEN 3] Unseen messages\r\n", + "* OK [PERMANENTFLAGS (\\Deleted \\Seen \\Answered \\Flagged \\Draft \\*)] All allowed\r\n", + "* OK [UIDVALIDITY 3857529045] UIDs valid\r\n", + "* OK [UIDNEXT 4392] Next predicted UID\r\n", + "{}" + ), + objectid_line ), ), ] { diff --git a/crates/imap-proto/src/protocol/status.rs b/crates/imap-proto/src/protocol/status.rs index 18feedb2..971adbfc 100644 --- a/crates/imap-proto/src/protocol/status.rs +++ b/crates/imap-proto/src/protocol/status.rs @@ -6,7 +6,7 @@ use crate::utf7::utf7_encode; -use super::quoted_string; +use super::{ObjectId, quoted_string}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Arguments { @@ -25,7 +25,7 @@ pub enum Status { Size, Recent, HighestModSeq, - MailboxId, + ObjectId, DeletedStorage, } @@ -39,6 +39,7 @@ pub struct StatusItem { pub enum StatusItemType { Number(u64), String(String), + ObjectId(ObjectId), } impl StatusItem { @@ -63,7 +64,7 @@ impl StatusItem { Status::Deleted => b"DELETED ", Status::Size => b"SIZE ", Status::HighestModSeq => b"HIGHESTMODSEQ ", - Status::MailboxId => b"MAILBOXID ", + Status::ObjectId => b"OBJECTID ", Status::Recent => b"RECENT ", Status::DeletedStorage => b"DELETED-STORAGE ", }); @@ -77,6 +78,9 @@ impl StatusItem { buf.extend_from_slice(str.as_bytes()); buf.push(b')'); } + StatusItemType::ObjectId(object_id) => { + object_id.serialize_kvpairs(buf); + } } } buf.extend_from_slice(b")\r\n"); @@ -85,24 +89,37 @@ impl StatusItem { #[cfg(test)] mod tests { - use crate::protocol::status::{Status, StatusItem, StatusItemType}; + use crate::protocol::{ + ObjectId, + status::{Status, StatusItem, StatusItemType}, + }; + use types::id::Id; #[test] fn serialize_status() { + let objectid = ObjectId { + mailbox_id: Some(Id::from(1u32)), + account_id: Some(Id::from(2u32)), + ..Default::default() + }; + let mut kvpairs = Vec::new(); + objectid.serialize_kvpairs(&mut kvpairs); + let kvpairs = String::from_utf8(kvpairs).unwrap(); + let mut buf = Vec::new(); StatusItem { mailbox_name: "blurdybloop".into(), items: vec![ (Status::Messages, StatusItemType::Number(231)), (Status::UidNext, StatusItemType::Number(44292)), - (Status::MailboxId, StatusItemType::String("abc-123".into())), + (Status::ObjectId, StatusItemType::ObjectId(objectid.clone())), ], } .serialize(&mut buf, true); assert_eq!( String::from_utf8(buf).unwrap(), - "* STATUS \"blurdybloop\" (MESSAGES 231 UIDNEXT 44292 MAILBOXID (abc-123))\r\n" + format!("* STATUS \"blurdybloop\" (MESSAGES 231 UIDNEXT 44292 OBJECTID {kvpairs})\r\n") ); } } diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index 0096b0a8..60836b90 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.rs @@ -384,6 +384,21 @@ impl SessionData { None } + pub fn get_mailbox_by_id(&self, account_id: u32, mailbox_id: u32) -> Option { + for account in self.mailboxes.lock().iter() { + if account.account_id == account_id + && account.mailbox_names.values().any(|id| *id == mailbox_id) + { + return MailboxId { + account_id, + mailbox_id, + } + .into(); + } + } + None + } + pub async fn check_mailbox_acl( &self, account_id: u32, diff --git a/crates/imap/src/core/mod.rs b/crates/imap/src/core/mod.rs index 077aa501..16d4c3d2 100644 --- a/crates/imap/src/core/mod.rs +++ b/crates/imap/src/core/mod.rs @@ -52,6 +52,7 @@ pub struct Session { pub is_condstore: bool, pub is_qresync: bool, pub is_utf8: bool, + pub is_objectid: bool, pub stream_rx: ReadHalf, pub stream_tx: Arc>>, pub in_flight: InFlight, diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs index 108c288b..6d80ee4c 100644 --- a/crates/imap/src/core/session.rs +++ b/crates/imap/src/core/session.rs @@ -147,6 +147,7 @@ impl Session { is_condstore: false, is_qresync: false, is_utf8: false, + is_objectid: false, server, instance: session.instance, session_id: session.session_id, @@ -204,6 +205,7 @@ impl Session { is_condstore: self.is_condstore, is_qresync: self.is_qresync, is_utf8: self.is_utf8, + is_objectid: self.is_objectid, session_id: self.session_id, in_flight: self.in_flight, remote_addr: self.remote_addr, @@ -214,6 +216,15 @@ impl Session { } impl Session { + pub fn activate_objectid(&mut self) -> Option<&'static [u8]> { + if self.is_objectid { + None + } else { + self.is_objectid = true; + Some(b"* ENABLED OBJECTID+\r\n") + } + } + pub async fn write_bytes(&self, bytes: impl AsRef<[u8]>) -> trc::Result<()> { let bytes = bytes.as_ref(); diff --git a/crates/imap/src/op/create.rs b/crates/imap/src/op/create.rs index 4c6a4723..94b4b530 100644 --- a/crates/imap/src/op/create.rs +++ b/crates/imap/src/op/create.rs @@ -13,7 +13,7 @@ use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; use email::cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}; use imap_proto::{ Command, ResponseCode, StatusResponse, - protocol::{create::Arguments, list::Attribute}, + protocol::{ObjectId, create::Arguments, list::Attribute}, receiver::Request, }; use registry::schema::enums::Permission; @@ -29,11 +29,12 @@ impl Session { let data = self.state.session_data(); let is_utf8 = self.is_utf8; + let is_objectid = self.is_objectid; spawn_op!(data, { for request in requests { match request.parse_create(is_utf8) { - Ok(argument) => match data.create_folder(argument).await { + Ok(argument) => match data.create_folder(argument, is_objectid).await { Ok(response) => { data.write_bytes(response.into_bytes()).await?; } @@ -51,7 +52,11 @@ impl Session { } impl SessionData { - pub async fn create_folder(&self, arguments: Arguments) -> trc::Result { + pub async fn create_folder( + &self, + arguments: Arguments, + is_objectid: bool, + ) -> trc::Result { let op_start = Instant::now(); // Refresh mailboxes @@ -119,11 +124,16 @@ impl SessionData { ); // Build response - Ok(StatusResponse::ok("Mailbox created.") - .with_code(ResponseCode::MailboxId { - mailbox_id: Id::from_parts(params.account_id, parent_id - 1).to_string(), - }) - .with_tag(arguments.tag)) + let response = StatusResponse::ok("Mailbox created.").with_tag(arguments.tag); + Ok(if is_objectid { + response.with_code(ResponseCode::ObjectId(ObjectId { + mailbox_id: Some(Id::from(parent_id - 1)), + account_id: Some(Id::from(params.account_id)), + ..Default::default() + })) + } else { + response + }) } pub async fn validate_mailbox_create<'x>( diff --git a/crates/imap/src/op/enable.rs b/crates/imap/src/op/enable.rs index a6cd8966..aca48494 100644 --- a/crates/imap/src/op/enable.rs +++ b/crates/imap/src/op/enable.rs @@ -45,6 +45,9 @@ impl Session { Capability::Utf8Accept => { self.is_utf8 = true; } + Capability::ObjectIdPlus => { + self.is_objectid = true; + } _ => { continue; } diff --git a/crates/imap/src/op/fetch.rs b/crates/imap/src/op/fetch.rs index f0ce65ce..a48063dc 100644 --- a/crates/imap/src/op/fetch.rs +++ b/crates/imap/src/op/fetch.rs @@ -23,7 +23,7 @@ use imap_proto::{ Command, ResponseCode, ResponseType, StatusResponse, parser::PushUnique, protocol::{ - Flag, + Flag, ObjectId, expunge::Vanished, fetch::{ self, Arguments, Attribute, BodyContents, BodyPart, BodyPartExtension, BodyPartFields, @@ -61,6 +61,7 @@ impl Session { let is_qresync = self.is_qresync; let mut ops = Vec::with_capacity(requests.len()); + let mut activate_objectid = false; for request in requests { let is_uid = matches!(request.command, Command::Fetch(true)); @@ -76,6 +77,10 @@ impl Session { false }; + if arguments.attributes.contains(&Attribute::ObjectId) { + activate_objectid = true; + } + ops.push(Ok((is_uid, enabled_condstore, arguments))); } Err(err) => { @@ -84,6 +89,10 @@ impl Session { } } + if activate_objectid && let Some(enabled) = self.activate_objectid() { + self.write_bytes(enabled).await?; + } + spawn_op!(data, { for op in ops { match op { @@ -518,15 +527,12 @@ impl SessionData { modseq: data.change_id + 1, }); } - Attribute::EmailId => { - items.push(DataItem::EmailId { - email_id: Id::from_parts(account_id, id).to_string(), - }); - } - Attribute::ThreadId => { - items.push(DataItem::ThreadId { - thread_id: Id::from_parts(account_id, data.thread_id).to_string(), - }); + Attribute::ObjectId => { + items.push(DataItem::ObjectId(ObjectId { + email_id: Some(Id::from_parts(data.thread_id, id)), + thread_id: Some(Id::from(data.thread_id)), + ..Default::default() + })); } } } diff --git a/crates/imap/src/op/rename.rs b/crates/imap/src/op/rename.rs index dc821a36..224379ff 100644 --- a/crates/imap/src/op/rename.rs +++ b/crates/imap/src/op/rename.rs @@ -10,7 +10,9 @@ use crate::{ }; use common::{network::SessionStream, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder}; use imap_proto::{ - Command, ResponseCode, StatusResponse, protocol::rename::Arguments, receiver::Request, + Command, ResponseCode, StatusResponse, + protocol::{ObjectId, rename::Arguments}, + receiver::Request, }; use registry::schema::enums::Permission; use std::time::Instant; @@ -19,7 +21,7 @@ use store::{ write::{AlignedBytes, Archive, BatchBuilder}, }; use trc::AddContext; -use types::{acl::Acl, collection::Collection}; +use types::{acl::Acl, collection::Collection, id::Id}; use super::ImapContext; @@ -31,9 +33,10 @@ impl Session { let op_start = Instant::now(); let arguments = request.parse_rename(self.is_utf8)?; let data = self.state.session_data(); + let is_objectid = self.is_objectid; spawn_op!(data, { - let response = data.rename_folder(arguments, op_start).await?; + let response = data.rename_folder(arguments, is_objectid, op_start).await?; data.write_bytes(response.into_bytes()).await }) } @@ -43,6 +46,7 @@ impl SessionData { pub async fn rename_folder( &self, arguments: Arguments, + is_objectid: bool, op_start: Instant, ) -> trc::Result { // Refresh mailboxes @@ -184,15 +188,26 @@ impl SessionData { .await .imap_ctx(&arguments.tag, trc::location!())?; + let account_id = params.account_id; + trc::event!( Imap(trc::ImapEvent::RenameMailbox), SpanId = self.session_id, - AccountId = params.account_id, + AccountId = account_id, MailboxName = arguments.new_mailbox_name, MailboxId = mailbox_id, Elapsed = op_start.elapsed() ); - Ok(StatusResponse::completed(Command::Rename).with_tag(arguments.tag)) + let response = StatusResponse::completed(Command::Rename).with_tag(arguments.tag); + Ok(if is_objectid { + response.with_code(ResponseCode::ObjectId(ObjectId { + mailbox_id: Some(Id::from(mailbox_id)), + account_id: Some(Id::from(account_id)), + ..Default::default() + })) + } else { + response + }) } } diff --git a/crates/imap/src/op/select.rs b/crates/imap/src/op/select.rs index 2d02e1ff..3e923f89 100644 --- a/crates/imap/src/op/select.rs +++ b/crates/imap/src/op/select.rs @@ -10,7 +10,7 @@ use common::network::SessionStream; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::{ - ImapResponse, Sequence, fetch, + ImapResponse, ObjectId, Sequence, fetch, list::ListItem, select::{HighestModSeq, Response}, }, @@ -35,12 +35,36 @@ impl Session { let arguments = request.parse_select(self.is_utf8)?; let data = self.state.session_data(); + // Activate OBJECTID+ when the OBJECTID parameter is supplied + if arguments.objectid.is_some() + && let Some(enabled) = self.activate_objectid() + { + self.write_bytes(enabled).await?; + } + + // Once activated, every SELECT/EXAMINE returns the compound OBJECTID response code + let want_objectid = self.is_objectid; + // Refresh mailboxes data.synchronize_mailboxes(false) .await .imap_ctx(&arguments.tag, trc::location!())?; - if let Some(mailbox) = data.get_mailbox_by_name(&arguments.mailbox_name) { + // 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, + }, + ) + .or_else(|| data.get_mailbox_by_name(&arguments.mailbox_name)); + + if let Some(mailbox) = mailbox { // Try obtaining the mailbox from the cache let state = data .fetch_messages(&mailbox, None) @@ -129,8 +153,11 @@ impl Session { is_rev2, is_utf8, highest_modseq, - mailbox_id: Id::from_parts(mailbox.id.account_id, mailbox.id.mailbox_id) - .to_string(), + objectid: want_objectid.then(|| ObjectId { + mailbox_id: Some(Id::from(mailbox.id.mailbox_id)), + account_id: Some(Id::from(mailbox.id.account_id)), + ..Default::default() + }), }; // Update state diff --git a/crates/imap/src/op/status.rs b/crates/imap/src/op/status.rs index cb65403c..f9e335ab 100644 --- a/crates/imap/src/op/status.rs +++ b/crates/imap/src/op/status.rs @@ -15,7 +15,10 @@ use email::cache::{MessageCacheFetch, email::MessageCacheAccess}; use imap_proto::{ Command, ResponseCode, StatusResponse, parser::PushUnique, - protocol::status::{Status, StatusItem, StatusItemType}, + protocol::{ + ObjectId, + status::{Status, StatusItem, StatusItemType}, + }, receiver::Request, }; use registry::schema::enums::Permission; @@ -29,13 +32,32 @@ impl Session { self.assert_has_permission(Permission::ImapStatus)?; let is_utf8 = self.is_utf8; + + // Parse requests and activate OBJECTID+ if the OBJECTID attribute is requested + let mut parsed = Vec::with_capacity(requests.len()); + let mut activate = false; + for request in requests { + match request.parse_status(is_utf8) { + Ok(arguments) => { + if arguments.items.contains(&Status::ObjectId) { + activate = true; + } + parsed.push(Ok(arguments)); + } + Err(err) => parsed.push(Err(err)), + } + } + if activate && let Some(enabled) = self.activate_objectid() { + self.write_bytes(enabled).await?; + } + let data = self.state.session_data(); spawn_op!(data, { let mut did_sync = false; - for request in requests.into_iter() { - match request.parse_status(is_utf8) { + for request in parsed { + match request { Ok(arguments) => { let op_start = Instant::now(); if !did_sync { @@ -114,7 +136,9 @@ impl SessionData { Status::UidNext | Status::UidValidity => { StatusItemType::Number(1) } - Status::MailboxId => StatusItemType::String("none".into()), + Status::ObjectId => { + StatusItemType::ObjectId(ObjectId::default()) + } }, ) }) @@ -184,13 +208,14 @@ impl SessionData { StatusItemType::Number(account.last_change_id.to_modseq()), )); } - Status::MailboxId => { + Status::ObjectId => { items_response.push(( *item, - StatusItemType::String( - Id::from_parts(mailbox.account_id, mailbox.mailbox_id) - .to_string(), - ), + StatusItemType::ObjectId(ObjectId { + mailbox_id: Some(Id::from(mailbox.mailbox_id)), + account_id: Some(Id::from(mailbox.account_id)), + ..Default::default() + }), )); } Status::Recent => { diff --git a/tests/src/imap/fetch.rs b/tests/src/imap/fetch.rs index 9310e01b..822a491e 100644 --- a/tests/src/imap/fetch.rs +++ b/tests/src/imap/fetch.rs @@ -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\" ", diff --git a/tests/src/imap/mailbox.rs b/tests/src/imap/mailbox.rs index 5880e8d2..abebe3ea 100644 --- a/tests/src/imap/mailbox.rs +++ b/tests/src/imap/mailbox.rs @@ -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; diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 37212f8e..9d2d95ba 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -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; diff --git a/tests/src/imap/objectid.rs b/tests/src/imap/objectid.rs new file mode 100644 index 00000000..124b43a0 --- /dev/null +++ b/tests/src/imap/objectid.rs @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * 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:?}"); +} diff --git a/tests/src/imap/thread.rs b/tests/src/imap/thread.rs index fd8fec3c..ddf306b4 100644 --- a/tests/src/imap/thread.rs +++ b/tests/src/imap/thread.rs @@ -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(); }