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).
This commit is contained in:
2026-09-27 19:33:07 -07:00
parent 39707cd2e8
commit 538ae107d7
4 changed files with 127 additions and 4 deletions
+83
View File
@@ -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<Member> {
let account = self.account(account_id).await.ok()?;
let mut domains = account
.addresses
.iter()
.map(|address| address.domain_id)
.collect::<Vec<_>>();
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<AHashMap<u32, HoldSummary>> {
let data = self.store();
let registry = self.registry();
let holds = hold::active(data).await?;
let mut summaries: AHashMap<u32, HoldSummary> =
holds.iter().map(|h| (h.id, HoldSummary::default())).collect();
if holds.is_empty() {
return Ok(summaries);
}
let mut members: AHashMap<u32, Member> = AHashMap::new();
for id in registry
.query::<Vec<Id>>(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::<ArchivedItem>(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.
@@ -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,
)
}
}
+22 -4
View File
@@ -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(&current, &properties));
response
.list
.push(to_value(&current, &properties, summaries.get(&current.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(&current, &properties)),
Some(current) => response.list.push(to_value(
&current,
&properties,
summaries.get(&current.id),
)),
None => response.push_not_found(id),
},
None => response.push_not_found(id),