From 75addc416b90471c2f497cb029d82e1c6aef18bd Mon Sep 17 00:00:00 2001 From: StefanSa Date: Thu, 5 Nov 2026 08:08:17 +0100 Subject: [PATCH 1/2] Fix masked email query pagination Leave pagination parameters on the request for QueryResponseBuilder instead of extracting them for storage-level pagination. Add a focused masked-email query regression covering successive positions and duplicate page results. --- crates/jmap/src/inbuxa/masked_email.rs | 2 +- tests/src/system/masked_email.rs | 52 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/jmap/src/inbuxa/masked_email.rs b/crates/jmap/src/inbuxa/masked_email.rs index 65afa735..5650e1fb 100644 --- a/crates/jmap/src/inbuxa/masked_email.rs +++ b/crates/jmap/src/inbuxa/masked_email.rs @@ -291,7 +291,7 @@ pub(crate) async fn query(mut req: RegistryQueryResponse<'_>) -> trc::Result false, })?; req.request - .extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?; + .extract_parameters(req.server.core.jmap.query_max_results, None)?; // ME-19: one account's masks, or every mask for a server-level // administrator who asks for no account in particular diff --git a/tests/src/system/masked_email.rs b/tests/src/system/masked_email.rs index b307a66d..4d33fe9c 100644 --- a/tests/src/system/masked_email.rs +++ b/tests/src/system/masked_email.rs @@ -492,6 +492,58 @@ pub async fn masked_email_compat() { } } +/// `cargo test -p tests masked_email_query_pagination -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn masked_email_query_pagination() { + let test = TestServerBuilder::new("masked_email_query_pagination") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + admin.find_or_create_domain("example.org").await; + let alice = admin + .create_user_account("alice@example.org", SECRET_ALICE, "Alice", &[], vec![]) + .await; + let mut ids = Vec::new(); + for prefix in ["first", "second", "third"] { + ids.push(alice.x_create(json!({"emailPrefix": prefix})).await); + } + ids.sort_unstable(); + + let mut seen = Vec::new(); + for (position, id) in ids.iter().enumerate() { + // calculateTotal must be false to exercise parameter extraction's + // storage-level pagination path. + let page = alice + .registry_query_paginated( + ObjectType::MaskedEmail, + "id", + true, + Some(position as i32), + Some(1), + None, + None, + false, + ) + .await; + let page_ids = page.object_ids().collect::>(); + assert!( + page_ids.iter().all(|id| !seen.contains(id)), + "a previous page's result was repeated" + ); + assert_eq!(page_ids, vec![*id], "page at position {position}"); + assert_eq!(page.method_response()["position"], json!(position)); + seen.extend(page_ids); + } + + if test.is_reset() { + test.temp_dir.delete(); + } +} + /// Runs the masked email tests alone: /// `cargo test -p tests masked_email_tests -- --ignored`. #[ignore] -- 2.54.0 From b96e2c5948df629f3b5de4c38366cb39014be691 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 5 Nov 2026 23:56:26 -0700 Subject: [PATCH 2/2] Archived item query pagination, as for masked email x:ArchivedItem/query took position, anchor and limit off the request for a storage-level page it never used, so the response builder started from the first item every time: a page past the first repeated the first page and ignored limit. The items are filtered and sorted in memory, so leave the parameters for the builder, as the masked email query now does. The undelete test now pages through the archive one item at a time, and its standalone runner turns on directory queries, as system_tests does: test 14 shares with an account it has no other connection to, which #283 refuses with the setting off. --- crates/jmap/src/inbuxa/undelete.rs | 4 ++- tests/src/system/undelete.rs | 45 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/jmap/src/inbuxa/undelete.rs b/crates/jmap/src/inbuxa/undelete.rs index 97570c3b..4eb57324 100644 --- a/crates/jmap/src/inbuxa/undelete.rs +++ b/crates/jmap/src/inbuxa/undelete.rs @@ -180,8 +180,10 @@ pub(crate) async fn query(mut req: RegistryQueryResponse<'_>) -> trc::Result true, _ => false, })?; + // `None`: the items are filtered and sorted here, so position, anchor + // and limit stay on the request for the response builder req.request - .extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?; + .extract_parameters(req.server.core.jmap.query_max_results, None)?; let mut items = undelete::records::of_account( &req.server.core.storage.data, diff --git a/tests/src/system/undelete.rs b/tests/src/system/undelete.rs index 7fbde1d5..3ec6486f 100644 --- a/tests/src/system/undelete.rs +++ b/tests/src/system/undelete.rs @@ -273,6 +273,44 @@ pub async fn test(test: &mut TestServer) { .await; assert_eq!(found.len(), 1, "query text"); + // /query pages: each position gives the next item, never the first page + // again (calculateTotal off, the path that dropped them) + let all = user + .registry_query_paginated( + ObjectType::ArchivedItem, + "id", + false, + None, + None, + None, + None, + false, + ) + .await + .object_ids() + .collect::>(); + assert!(all.len() >= 3, "query pages: {all:?}"); + for (position, id) in all.iter().enumerate() { + let page = user + .registry_query_paginated( + ObjectType::ArchivedItem, + "id", + false, + Some(position as i32), + Some(1), + None, + None, + false, + ) + .await; + assert_eq!( + page.object_ids().collect::>(), + vec![*id], + "query page at position {position}" + ); + assert_eq!(page.method_response()["position"], json!(position)); + } + // Acceptance test 15: nobody else sees the archive (UD-7) let other = admin .create_user_account("other@example.org", SECRET, "Other", &[], vec![]) @@ -630,6 +668,13 @@ pub async fn undelete_tests() { ..Default::default() }) .await + // test 14 shares with an account it has nothing else to do with, as + // system_tests allows + .with_object(registry::schema::structs::Sharing { + allow_directory_queries: true, + ..Default::default() + }) + .await .build() .await; let admin = test.create_admin_account("admin@example.org").await; -- 2.54.0