From 538ae107d7a5d6e4113b3f7afb8b81d2bae7f6b3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:39:19 -0700 Subject: [PATCH] Legal holds, step 6: what each hold keeps inbuxa:LegalHold/get answers accountsCovered, itemsHeld and sizeHeld when asked: the accounts a hold reaches now (deleted ones it keeps included) and the archived items it keeps, with their size. Worked out in one pass over accounts and archive, only for requests that name them. Held items stay out of the user's quota, as all archived copies do (LH-9). --- crates/common/src/hold.rs | 83 +++++++++++++++++++ .../src/object/inbuxa_legal_hold.rs | 11 +++ crates/jmap/src/inbuxa/legal_hold.rs | 26 +++++- tests/src/system/legal_hold.rs | 11 +++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/common/src/hold.rs b/crates/common/src/hold.rs index cfa7325..197e7ec 100644 --- a/crates/common/src/hold.rs +++ b/crates/common/src/hold.rs @@ -41,6 +41,14 @@ pub struct Settled { pub accounts_released: usize, } +/// What one hold keeps (LH-9). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct HoldSummary { + pub accounts: u64, + pub items: u64, + pub size: u64, +} + /// A kept account as it was when deleted, for a hold's scope: its record /// still names its domain, groups and tenant. pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member { @@ -54,6 +62,81 @@ pub fn kept_member(account_id: u32, kept: &KeptAccount) -> Member { } impl Server { + /// What decides whether a hold reaches a live account; None if it's gone. + pub async fn member_of(&self, account_id: u32) -> Option { + let account = self.account(account_id).await.ok()?; + let mut domains = account + .addresses + .iter() + .map(|address| address.domain_id) + .collect::>(); + domains.sort_unstable(); + domains.dedup(); + Some(Member { + account: account_id, + domains, + groups: account.id_member_of.iter().copied().collect(), + tenant: account.id_tenant, + }) + } + + /// LH-9, the console's "what's held": per active hold, the accounts it + /// covers now (deleted ones it keeps included), and the archived items + /// it keeps with their size. One pass over accounts and archive. + pub async fn hold_summaries(&self) -> trc::Result> { + let data = self.store(); + let registry = self.registry(); + let holds = hold::active(data).await?; + let mut summaries: AHashMap = + holds.iter().map(|h| (h.id, HoldSummary::default())).collect(); + if holds.is_empty() { + return Ok(summaries); + } + let mut members: AHashMap = AHashMap::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::Account)) + .await + .caused_by(trc::location!())? + { + if let Some(member) = self.member_of(id.document_id()).await { + members.insert(id.document_id(), member); + } + } + for (account_id, kept) in undelete_data::kept_accounts(data).await? { + members.insert(account_id, kept_member(account_id, &kept)); + } + for member in members.values() { + for hold in holds.iter().filter(|h| h.scope.covers(member)) { + summaries.entry(hold.id).or_default().accounts += 1; + } + } + for id in records::all(data, registry).await? { + let Some(item) = registry.object::(id).await? else { + continue; + }; + if !is_held_until(item.archived_until().timestamp().max(0) as u64) { + continue; + } + let Some(member) = members.get(&item.account_id().document_id()) else { + continue; + }; + let size = match &item { + ArchivedItem::Email(email) => email.size, + ArchivedItem::FileNode(_) => match undelete_data::extra(data, id).await? { + Some(inbuxa_features::undelete::data::Extra::FileNode { size, .. }) => size as u64, + _ => 0, + }, + _ => 0, + }; + for hold in holds.iter().filter(|h| h.scope.covers(member)) { + let summary = summaries.entry(hold.id).or_default(); + summary.items += 1; + summary.size += size; + } + } + Ok(summaries) + } + /// The active holds covering `account_id`, through its own name, its /// addresses' domains, its groups or its tenant. Empty for an account /// that no longer exists: a deleted one is kept by LH-8's own check. diff --git a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs index 657a8ef..8f9b9bd 100644 --- a/crates/jmap-proto/src/object/inbuxa_legal_hold.rs +++ b/crates/jmap-proto/src/object/inbuxa_legal_hold.rs @@ -45,6 +45,11 @@ pub enum LegalHoldProperty { ReleasedAt, ReleasedBy, ReleaseReason, + /// LH-9: accounts it covers now, deleted ones it keeps included. + AccountsCovered, + /// LH-9: archived items it keeps, and their size in bytes. + ItemsHeld, + SizeHeld, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -77,6 +82,9 @@ impl Property for LegalHoldProperty { LegalHoldProperty::ReleasedAt => "releasedAt", LegalHoldProperty::ReleasedBy => "releasedBy", LegalHoldProperty::ReleaseReason => "releaseReason", + LegalHoldProperty::AccountsCovered => "accountsCovered", + LegalHoldProperty::ItemsHeld => "itemsHeld", + LegalHoldProperty::SizeHeld => "sizeHeld", } .into() } @@ -99,6 +107,9 @@ impl LegalHoldProperty { b"releasedAt" => LegalHoldProperty::ReleasedAt, b"releasedBy" => LegalHoldProperty::ReleasedBy, b"releaseReason" => LegalHoldProperty::ReleaseReason, + b"accountsCovered" => LegalHoldProperty::AccountsCovered, + b"itemsHeld" => LegalHoldProperty::ItemsHeld, + b"sizeHeld" => LegalHoldProperty::SizeHeld, ) } } diff --git a/crates/jmap/src/inbuxa/legal_hold.rs b/crates/jmap/src/inbuxa/legal_hold.rs index 2d2ccb5..b4fc1bb 100644 --- a/crates/jmap/src/inbuxa/legal_hold.rs +++ b/crates/jmap/src/inbuxa/legal_hold.rs @@ -9,7 +9,7 @@ //! this: the tenant ceiling strips the permissions from everyone in a //! tenant (LH-13). What a hold keeps is the undelete hooks' job. -use common::{Server, auth::AccessToken}; +use common::{Server, auth::AccessToken, hold::HoldSummary}; use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope}; use jmap_proto::{ error::set::SetError, @@ -67,7 +67,7 @@ fn ids(list: &[u32]) -> LValue { ) } -fn to_value(hold: &Hold, properties: &[P]) -> LValue { +fn to_value(hold: &Hold, properties: &[P], summary: Option<&HoldSummary>) -> LValue { let mut out = Map::with_capacity(properties.len()); for property in properties { let value = match property { @@ -99,6 +99,9 @@ fn to_value(hold: &Hold, properties: &[P]) -> LValue { .released .as_ref() .map_or(Value::Null, |r| Value::Str(r.reason.clone().into())), + P::AccountsCovered => Value::Number(summary.map_or(0, |s| s.accounts).into()), + P::ItemsHeld => Value::Number(summary.map_or(0, |s| s.items).into()), + P::SizeHeld => Value::Number(summary.map_or(0, |s| s.size).into()), }; out.insert_unchecked(Key::Property(property.clone()), value); } @@ -119,10 +122,21 @@ pub async fn get( not_found, }; let data = server.store(); + // LH-9: only when asked for, since it walks the archive + let summaries = if properties + .iter() + .any(|p| matches!(p, P::AccountsCovered | P::ItemsHeld | P::SizeHeld)) + { + server.hold_summaries().await? + } else { + Default::default() + }; match ids { None => { for current in hold::all(data).await? { - response.list.push(to_value(¤t, &properties)); + response + .list + .push(to_value(¤t, &properties, summaries.get(¤t.id))); } } Some(ids) => { @@ -132,7 +146,11 @@ pub async fn get( .map(|id| hold::get(data, id)) { Some(found) => match found.await? { - Some(current) => response.list.push(to_value(¤t, &properties)), + Some(current) => response.list.push(to_value( + ¤t, + &properties, + summaries.get(¤t.id), + )), None => response.push_not_found(id), }, None => response.push_not_found(id), diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index 219f115..7e697b1 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -411,6 +411,17 @@ pub async fn test(test: &mut TestServer) { is_held(&archived(frozen.archived_items().await)), "test 6, LH-6: the archived item wasn't frozen" ); + // LH-9: what the hold keeps, for the console + let (_, response) = admin + .hold_call( + "inbuxa:LegalHold/get", + json!({"ids": [first], "properties": ["accountsCovered", "itemsHeld", "sizeHeld"]}), + ) + .await; + let summary = &response["list"][0]; + assert_eq!(summary["accountsCovered"], 1, "LH-9: {response}"); + assert_eq!(summary["itemsHeld"], 1, "LH-9: {response}"); + assert!(summary["sizeHeld"].as_u64().is_some_and(|s| s > 0), "LH-9: {response}"); let (_, response) = frozen .hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))