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]}))