Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3978cf5785 | ||
|
|
815a642cc4 | ||
|
|
1d5f4a2cd3 | ||
|
|
68dd749291 | ||
|
|
b074c73219 | ||
|
|
3046c418cd | ||
|
|
faeb1fed86 | ||
|
|
c1b5bf956c | ||
|
|
3217aae4e8 | ||
|
|
538ae107d7 | ||
|
|
39707cd2e8 | ||
|
|
8d3e99bc00 | ||
|
|
7b97efbb7f | ||
|
|
318783f444 | ||
|
|
5d2e35b2dc | ||
|
|
621ebdff74 | ||
|
|
355bd3a40e | ||
|
|
f7a63b9ed0 | ||
|
|
9f6761c9dd | ||
|
|
d4d127fa7d |
Generated
+2
@@ -4258,6 +4258,7 @@ dependencies = [
|
||||
"tungstenite 0.30.0",
|
||||
"types",
|
||||
"utils",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8624,6 +8625,7 @@ dependencies = [
|
||||
"types",
|
||||
"utils",
|
||||
"x509-parser",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
auth::{AccessToken, AuthRequest, permissions::DefaultPermissions},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use inbuxa_features::hold::{self, Member};
|
||||
use inbuxa_features::audit::{
|
||||
Action, Actor, AuditLog, EntryId, Outcome, Record, Target, Via, diff, log, scope,
|
||||
};
|
||||
@@ -448,7 +449,16 @@ impl Server {
|
||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||
let settings = log::settings(self.store()).await?;
|
||||
let cutoff = ms().saturating_sub(settings.keep_for_secs.saturating_mul(1000));
|
||||
log::purge(self.store(), cutoff, |_| false).await
|
||||
// LH-6, AU-7: a record about a held account stays while it's held.
|
||||
// Worked out before the purge, which can't wait on lookups.
|
||||
let held = self.held_accounts().await?;
|
||||
log::purge(self.store(), cutoff, |record| {
|
||||
record
|
||||
.target
|
||||
.account_id
|
||||
.is_some_and(|account_id| held.contains(&account_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +505,20 @@ impl RegistryWriteHook for SystemWrites {
|
||||
change: RegistryChange<'a>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
// LH-2: every change to an account, whoever makes it: one that
|
||||
// leaves a held domain, group or tenant stays held by name
|
||||
if change.object_type == ObjectType::Account
|
||||
&& let (Some(before), Some(after)) = (change.before, change.after)
|
||||
&& let (Some(before), Some(after)) = (
|
||||
Member::of(change.id.document_id(), &before.inner),
|
||||
Member::of(change.id.document_id(), &after.inner),
|
||||
)
|
||||
&& let Err(err) = hold::keep_moved(&self.data, &before, &after).await
|
||||
{
|
||||
trc::error!(err
|
||||
.account_id(after.account)
|
||||
.details("Failed to keep a moved account under its legal hold"));
|
||||
}
|
||||
let subsystem = match scope::current() {
|
||||
Some(scope::Scope::Request | scope::Scope::Quiet) => return,
|
||||
Some(scope::Scope::System(subsystem)) => subsystem,
|
||||
|
||||
@@ -143,6 +143,29 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
// inbuxa: AL-7: a delegate reaches the whole locked account,
|
||||
// mail, calendars, contacts and files, even a kind it holds
|
||||
// none of yet, so an empty one reads as empty rather than
|
||||
// refused. What it may see or change there is still each
|
||||
// container's grant.
|
||||
for delegation in delegations.iter() {
|
||||
let whole: Bitmap<Collection> = Bitmap::from_iter([
|
||||
Collection::Mailbox,
|
||||
Collection::Email,
|
||||
Collection::Calendar,
|
||||
Collection::CalendarEvent,
|
||||
Collection::AddressBook,
|
||||
Collection::ContactCard,
|
||||
Collection::FileNode,
|
||||
]);
|
||||
match access_to.iter_mut().find(|a| a.account_id == delegation.account_id) {
|
||||
Some(entry) => entry.collections.union(&whole),
|
||||
None => access_to.push(AccessTo {
|
||||
account_id: delegation.account_id,
|
||||
collections: whole,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
let now = now();
|
||||
let mut credential_version = 0;
|
||||
@@ -817,6 +840,13 @@ impl AccessToken {
|
||||
|
||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||
/// has one that hasn't ended.
|
||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||
/// the locked account as its owner could, top-level folders included.
|
||||
pub fn delegate_may_write(&self, account_id: u32) -> bool {
|
||||
self.delegation(account_id)
|
||||
.is_some_and(|d| d.access != inbuxa_features::lock::Access::Read)
|
||||
}
|
||||
|
||||
pub fn delegation(&self, account_id: u32) -> Option<&super::Delegation> {
|
||||
let now = now();
|
||||
self.inner
|
||||
|
||||
@@ -104,6 +104,16 @@ impl Server {
|
||||
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
||||
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
|
||||
permissions.disabled.set(Permission::Impersonate as usize);
|
||||
// inbuxa: LH-13: only server-level administrators see or place
|
||||
// holds, and a hold may concern the tenant's own administrator
|
||||
for permission in [
|
||||
Permission::SysLegalHoldGet,
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
] {
|
||||
permissions.disabled.set(permission as usize);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -254,6 +264,11 @@ impl Default for DefaultPermissions {
|
||||
default.tenant.push(permission);
|
||||
}
|
||||
Permission::Impersonate
|
||||
// inbuxa: LH-13: holds are the server administrator's alone
|
||||
| Permission::SysLegalHoldGet
|
||||
| Permission::SysLegalHoldCreate
|
||||
| Permission::SysLegalHoldUpdate
|
||||
| Permission::SysLegalHoldExport
|
||||
| Permission::UnlimitedRequests
|
||||
| Permission::UnlimitedUploads
|
||||
| Permission::LiveMetrics
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: which legal holds cover an account (audit-hold-lock spec, LH-2,
|
||||
//! LH-11), for the paths that destroy data. Read from the store every time,
|
||||
//! not cached: a hold placed on one node must bind every node at once, and
|
||||
//! there are few holds.
|
||||
|
||||
use crate::Server;
|
||||
use ahash::AHashMap;
|
||||
use inbuxa_features::{
|
||||
hold::{self, HELD_UNTIL, Hold, Keeping, Member, is_held_until},
|
||||
undelete::records,
|
||||
};
|
||||
use inbuxa_features::undelete::data::{self as undelete_data, KeptAccount};
|
||||
use registry::{
|
||||
pickle::PickledStream,
|
||||
schema::{
|
||||
prelude::{ObjectInner, ObjectType},
|
||||
structs::ArchivedItem,
|
||||
},
|
||||
};
|
||||
use store::{registry::RegistryQuery, write::now};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
/// The grace a released item gets at least (LH-10): a release made in error
|
||||
/// can be undone by placing a new hold within it.
|
||||
const RELEASE_GRACE: u64 = 30 * 86_400;
|
||||
|
||||
/// What a settle pass changed.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Settled {
|
||||
pub frozen: usize,
|
||||
pub released: usize,
|
||||
/// Deleted accounts kept by a hold, or let go by a release (LH-8, LH-10).
|
||||
pub accounts_frozen: usize,
|
||||
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 {
|
||||
PickledStream::new(&kept.record)
|
||||
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Account, &mut stream))
|
||||
.and_then(|inner| Member::of(account_id, &inner))
|
||||
.unwrap_or(Member {
|
||||
account: account_id,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
pub async fn holds_on(&self, account_id: u32) -> trc::Result<Vec<Hold>> {
|
||||
let Ok(account) = self.account(account_id).await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut domains = account
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|address| address.domain_id)
|
||||
.collect::<Vec<_>>();
|
||||
domains.sort_unstable();
|
||||
domains.dedup();
|
||||
let member = Member {
|
||||
account: account_id,
|
||||
domains,
|
||||
groups: account.id_member_of.iter().copied().collect(),
|
||||
tenant: account.id_tenant,
|
||||
};
|
||||
hold::covering(self.store(), &member).await
|
||||
}
|
||||
|
||||
/// How `account_id`'s deleted items are kept: its holds' ranges and the
|
||||
/// undelete period in force now (LH-4, UD-6a).
|
||||
pub async fn keeping(&self, account_id: u32) -> trc::Result<Keeping> {
|
||||
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
||||
.await?
|
||||
.items;
|
||||
Ok(Keeping::new(retention, &self.holds_on(account_id).await?))
|
||||
}
|
||||
|
||||
/// LH-6, LH-10, LH-11: brings the whole archive in line with the active
|
||||
/// holds. An archived item a hold covers is frozen (no deadline), its
|
||||
/// old deadline noted; a frozen one no hold covers any more gets that
|
||||
/// deadline back, or release plus 30 days if later. Run after every
|
||||
/// change to a hold; it changes nothing twice.
|
||||
pub async fn settle_archive(&self) -> trc::Result<Settled> {
|
||||
let data = self.store();
|
||||
let registry = self.registry();
|
||||
let any_active = !hold::active(data).await?.is_empty();
|
||||
let now = now();
|
||||
let mut keeping: AHashMap<u32, Option<Keeping>> = AHashMap::new();
|
||||
let mut settled = Settled::default();
|
||||
for id in records::all(data, registry).await? {
|
||||
let Some(item) = registry.object::<ArchivedItem>(id).await? else {
|
||||
continue;
|
||||
};
|
||||
let account_id = item.account_id().document_id();
|
||||
if !keeping.contains_key(&account_id) {
|
||||
// An account that's gone can't be placed in a domain or
|
||||
// tenant any more: None, and its items are left as they are
|
||||
let known = self.account(account_id).await.is_ok();
|
||||
let value = if known { Some(self.keeping(account_id).await?) } else { None };
|
||||
keeping.insert(account_id, value);
|
||||
}
|
||||
let until = item.archived_until().timestamp().max(0) as u64;
|
||||
let held = is_held_until(until);
|
||||
let covered = match keeping.get(&account_id).and_then(Option::as_ref) {
|
||||
Some(keeping) => match &item {
|
||||
ArchivedItem::Email(email) => {
|
||||
keeping.covers(Some(email.received_at.timestamp().max(0) as u64))
|
||||
}
|
||||
ArchivedItem::CalendarEvent(event) => keeping
|
||||
.covers_event(event.start_time.map(|t| t.timestamp().max(0) as u64)),
|
||||
_ => keeping.covers(None),
|
||||
},
|
||||
// Gone: release only once no hold is active anywhere
|
||||
None => held && any_active,
|
||||
};
|
||||
if covered && !held {
|
||||
hold::set_original_deadline(data, id.id(), Some(until)).await?;
|
||||
records::set_deadline(data, registry, id, &item, HELD_UNTIL).await?;
|
||||
settled.frozen += 1;
|
||||
} else if !covered && held {
|
||||
let original = hold::original_deadline(data, id.id()).await?.unwrap_or(0);
|
||||
records::set_deadline(data, registry, id, &item, original.max(now + RELEASE_GRACE))
|
||||
.await?;
|
||||
hold::set_original_deadline(data, id.id(), None).await?;
|
||||
settled.released += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// LH-8, LH-10: deleted accounts kept by undelete follow the holds
|
||||
// too. Their DestroyAccount task defers itself while they're kept.
|
||||
let retention = inbuxa_features::undelete::settings::retention(registry)
|
||||
.await?
|
||||
.accounts;
|
||||
for (account_id, mut kept) in undelete_data::kept_accounts(data).await? {
|
||||
let covered = !hold::covering(data, &kept_member(account_id, &kept)).await?.is_empty();
|
||||
let held = is_held_until(kept.kept_until);
|
||||
let until = if covered && !held {
|
||||
settled.accounts_frozen += 1;
|
||||
HELD_UNTIL
|
||||
} else if !covered && held {
|
||||
settled.accounts_released += 1;
|
||||
(kept.deleted_at + retention.unwrap_or(0)).max(now + RELEASE_GRACE)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
kept.kept_until = until;
|
||||
let mut batch = store::write::BatchBuilder::new();
|
||||
undelete_data::set_kept_account(&mut batch, account_id, &kept)?;
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
Ok(settled)
|
||||
}
|
||||
|
||||
/// LH-8: whether a hold covers a deleted account undelete keeps.
|
||||
pub async fn is_kept_held(&self, account_id: u32, kept: &KeptAccount) -> trc::Result<bool> {
|
||||
Ok(!hold::covering(self.store(), &kept_member(account_id, kept))
|
||||
.await?
|
||||
.is_empty())
|
||||
}
|
||||
|
||||
/// Every account an active hold covers now. Empty, without looking at
|
||||
/// accounts, when nothing is held.
|
||||
pub async fn held_accounts(&self) -> trc::Result<ahash::AHashSet<u32>> {
|
||||
let mut held = ahash::AHashSet::new();
|
||||
if hold::active(self.store()).await?.is_empty() {
|
||||
return Ok(held);
|
||||
}
|
||||
for id in self
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let account_id = id.document_id();
|
||||
if self.is_held(account_id).await? {
|
||||
held.insert(account_id);
|
||||
}
|
||||
}
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// Whether any active hold covers `account_id` at all.
|
||||
pub async fn is_held(&self, account_id: u32) -> trc::Result<bool> {
|
||||
Ok(!self.holds_on(account_id).await?.is_empty())
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ use utils::{
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
||||
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||
pub mod config;
|
||||
pub mod expr;
|
||||
pub mod i18n;
|
||||
|
||||
@@ -29,8 +29,8 @@ use trc::AddContext;
|
||||
use types::id::Id;
|
||||
|
||||
/// Granted to the default administrator roles: "Explain this"
|
||||
/// (ai-explain spec, EX-4: superuser by default), and the audit log
|
||||
/// (audit-hold-lock spec, AU-9).
|
||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13).
|
||||
const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysAiExplain,
|
||||
Permission::SysAuditGet,
|
||||
@@ -40,6 +40,10 @@ const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysAccountLockCreate,
|
||||
Permission::SysAccountLockUpdate,
|
||||
Permission::SysAccountLockDestroy,
|
||||
Permission::SysLegalHoldGet,
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldExport,
|
||||
];
|
||||
|
||||
/// Granted to the default tenant administrator roles: reading and exporting
|
||||
|
||||
@@ -92,10 +92,8 @@ impl MailboxDestroy for Server {
|
||||
|
||||
let mut deleted_ids = RoaringBitmap::new();
|
||||
let mut thread_ids = RoaringBitmap::new();
|
||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
||||
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
||||
.await?
|
||||
.items;
|
||||
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||
let keeping = self.keeping(account_id).await?;
|
||||
self.archives(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
@@ -125,10 +123,10 @@ impl MailboxDestroy for Server {
|
||||
deleted_ids.insert(message_id);
|
||||
thread_ids.insert(prev_message_data.inner.thread_id.to_native());
|
||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||
if let Some(retention) = retention {
|
||||
if keeping.keeps_anything() {
|
||||
inbuxa_features::undelete::email::note(
|
||||
&mut batch,
|
||||
retention,
|
||||
&keeping,
|
||||
account_id,
|
||||
message_id,
|
||||
prev_message_data.inner.size.to_native() as u64,
|
||||
|
||||
@@ -69,10 +69,8 @@ impl EmailDeletion for Server {
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.with_collection(Collection::Email);
|
||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
||||
let retention = inbuxa_features::undelete::settings::retention(self.registry())
|
||||
.await?
|
||||
.items;
|
||||
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||
let keeping = self.keeping(account_id).await?;
|
||||
self.archives(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
@@ -90,10 +88,10 @@ impl EmailDeletion for Server {
|
||||
}
|
||||
thread_ids.insert(metadata.inner.thread_id.to_native());
|
||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||
if let Some(retention) = retention {
|
||||
if keeping.keeps_anything() {
|
||||
inbuxa_features::undelete::email::note(
|
||||
batch,
|
||||
retention,
|
||||
&keeping,
|
||||
account_id,
|
||||
document_id,
|
||||
metadata.inner.size.to_native() as u64,
|
||||
|
||||
@@ -44,12 +44,12 @@ impl SieveScriptDelete for Server {
|
||||
))
|
||||
.await?
|
||||
{
|
||||
// inbuxa: UD-1: a deleted script is kept, when archiving is on
|
||||
if let Some(retention) =
|
||||
inbuxa_features::undelete::settings::retention(self.registry())
|
||||
.await?
|
||||
.items
|
||||
{
|
||||
// inbuxa: UD-1, LH-4: a deleted script is kept, when archiving
|
||||
// is on or a hold covers the account (whole: scripts have no date)
|
||||
let keeping = self.keeping(account_id).await?;
|
||||
let now = store::write::now();
|
||||
if let Some(until) = keeping.until(now, keeping.is_held()) {
|
||||
let retention = until.saturating_sub(now);
|
||||
let script = obj_
|
||||
.deserialize::<SieveScript>()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Legal holds (audit-hold-lock spec, LH-1 to LH-14).
|
||||
//!
|
||||
//! A hold names a case and what it covers: accounts, groups, domains,
|
||||
//! tenants or the whole server, optionally only items dated inside a range.
|
||||
//! While any active hold covers an item, nothing may destroy it. A hold is
|
||||
//! never deleted: releasing it keeps it, read-only, for the audit trail.
|
||||
//!
|
||||
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||
//! with `H`, then one byte for the kind:
|
||||
//!
|
||||
//! - `h` + hold id (u32): the hold, as JSON.
|
||||
//!
|
||||
//! Numbers are big-endian. There are few holds, so they're read whole.
|
||||
|
||||
use registry::schema::{prelude::ObjectInner, structs::Account};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
/// The deadline a held archived item carries: the last second of 9999. It
|
||||
/// never passes, so every expiry check keeps the item without knowing about
|
||||
/// holds (LH-4, LH-5); releasing a hold gives it a real deadline (LH-10).
|
||||
pub const HELD_UNTIL: u64 = 253_402_300_799;
|
||||
|
||||
/// Whether an archived item's deadline marks it as held. Anything past the
|
||||
/// year 9000 counts, so a deadline computed from a hold a moment earlier or
|
||||
/// later still reads as held.
|
||||
pub fn is_held_until(until: u64) -> bool {
|
||||
until >= 221_845_392_000
|
||||
}
|
||||
|
||||
/// A day, in seconds: the slack either side of a range for an event's start,
|
||||
/// whose time zone isn't known here.
|
||||
const DAY: u64 = 86_400;
|
||||
|
||||
/// How an account's deleted items are kept: its holds' ranges, and the
|
||||
/// undelete period for whatever no hold covers (LH-3, LH-4).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Keeping {
|
||||
/// `archiveDeletedItemsFor`, in seconds, if undelete is on.
|
||||
pub retention: Option<u64>,
|
||||
/// Each active hold's range on this account; `(None, None)` is a whole
|
||||
/// account. Empty when nothing holds it.
|
||||
pub ranges: Vec<(Option<u64>, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl Keeping {
|
||||
pub fn new(retention: Option<u64>, holds: &[Hold]) -> Keeping {
|
||||
Keeping {
|
||||
retention,
|
||||
ranges: holds.iter().map(|h| (h.from, h.to)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hold reaches the account at all.
|
||||
pub fn is_held(&self) -> bool {
|
||||
!self.ranges.is_empty()
|
||||
}
|
||||
|
||||
/// Whether deleted items need noting: something may keep them.
|
||||
pub fn keeps_anything(&self) -> bool {
|
||||
self.is_held() || self.retention.is_some()
|
||||
}
|
||||
|
||||
/// Whether a hold covers an item dated `date`. No date means the item is
|
||||
/// held whole, whatever the range (LH-3).
|
||||
pub fn covers(&self, date: Option<u64>) -> bool {
|
||||
self.ranges.iter().any(|(from, to)| match date {
|
||||
None => true,
|
||||
Some(at) => {
|
||||
from.is_none_or(|from| at >= from) && to.is_none_or(|to| at <= to)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `covers`, for an event's start: a day of slack either side, since
|
||||
/// its time zone isn't known here.
|
||||
pub fn covers_event(&self, start: Option<u64>) -> bool {
|
||||
self.ranges.iter().any(|(from, to)| match start {
|
||||
None => true,
|
||||
Some(at) => {
|
||||
from.is_none_or(|from| at + DAY >= from)
|
||||
&& to.is_none_or(|to| at <= to.saturating_add(DAY))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Until when an item deleted at `now` is kept: held, the undelete
|
||||
/// period, or not at all.
|
||||
pub fn until(&self, now: u64, held: bool) -> Option<u64> {
|
||||
if held {
|
||||
Some(HELD_UNTIL)
|
||||
} else {
|
||||
self.retention.map(|retention| now + retention)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FEATURE: u8 = b'H';
|
||||
const KIND_HOLD: u8 = b'h';
|
||||
const KIND_ORIGINAL: u8 = b'o';
|
||||
const KIND_EXPORT: u8 = b'e';
|
||||
|
||||
/// How far a hold export has got (LH-12).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExportStatus {
|
||||
Running,
|
||||
Ready,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// A collection of what a hold keeps, as a ZIP (LH-12).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Export {
|
||||
pub id: u32,
|
||||
pub hold_id: u32,
|
||||
/// The accounts asked for; empty for every account the hold covers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub accounts: Vec<u32>,
|
||||
pub reason: String,
|
||||
pub created_at: u64,
|
||||
pub created_by: String,
|
||||
/// Whose blob the ZIP is, so only they download it.
|
||||
pub created_by_id: u32,
|
||||
pub status: ExportStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub items: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sha256: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// How many times creating a hold retries when another node took its id.
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
|
||||
/// What a hold covers (LH-1, LH-2). Domains and tenants are resolved live,
|
||||
/// so an account added to one later is held too.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scope {
|
||||
/// Every account on the server.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub server: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub accounts: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub groups: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub domains: Vec<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tenants: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.server
|
||||
&& self.accounts.is_empty()
|
||||
&& self.groups.is_empty()
|
||||
&& self.domains.is_empty()
|
||||
&& self.tenants.is_empty()
|
||||
}
|
||||
|
||||
/// Whether this scope covers everything `other` does, entry by entry.
|
||||
/// A scope may only grow (LH-3's rule for ranges, applied to scope):
|
||||
/// taking something out would free what it held.
|
||||
pub fn contains(&self, other: &Scope) -> bool {
|
||||
let all = |mine: &[u32], theirs: &[u32]| theirs.iter().all(|id| mine.contains(id));
|
||||
(self.server || !other.server)
|
||||
&& all(&self.accounts, &other.accounts)
|
||||
&& all(&self.groups, &other.groups)
|
||||
&& all(&self.domains, &other.domains)
|
||||
&& all(&self.tenants, &other.tenants)
|
||||
}
|
||||
|
||||
fn normalize(&mut self) {
|
||||
for list in [
|
||||
&mut self.accounts,
|
||||
&mut self.groups,
|
||||
&mut self.domains,
|
||||
&mut self.tenants,
|
||||
] {
|
||||
list.sort_unstable();
|
||||
list.dedup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What decides whether a hold's scope reaches an account: the domains of
|
||||
/// its addresses, its groups and its tenant (LH-2).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Member {
|
||||
pub account: u32,
|
||||
pub domains: Vec<u32>,
|
||||
pub groups: Vec<u32>,
|
||||
pub tenant: Option<u32>,
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// A person's account as the registry stores it; `None` for a group,
|
||||
/// whose own data is held through its members.
|
||||
pub fn of(account_id: u32, object: &ObjectInner) -> Option<Member> {
|
||||
let ObjectInner::Account(Account::User(user)) = object else {
|
||||
return None;
|
||||
};
|
||||
let mut domains = vec![user.domain_id.document_id()];
|
||||
domains.extend(user.aliases.iter().map(|alias| alias.domain_id.document_id()));
|
||||
domains.sort_unstable();
|
||||
domains.dedup();
|
||||
Some(Member {
|
||||
account: account_id,
|
||||
domains,
|
||||
groups: user.member_group_ids.iter().map(|id| id.document_id()).collect(),
|
||||
tenant: user.member_tenant_id.map(|id| id.document_id()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// Whether this scope reaches `member`, directly or through its domains,
|
||||
/// groups or tenant, as they are now (LH-2).
|
||||
pub fn covers(&self, member: &Member) -> bool {
|
||||
self.server
|
||||
|| self.accounts.contains(&member.account)
|
||||
|| member.domains.iter().any(|d| self.domains.contains(d))
|
||||
|| member.groups.iter().any(|g| self.groups.contains(g))
|
||||
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
|
||||
}
|
||||
}
|
||||
|
||||
/// When and why a hold was released (LH-10).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Release {
|
||||
pub at: u64,
|
||||
pub by: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by_id: Option<u32>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// A legal hold (LH-1).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hold {
|
||||
pub id: u32,
|
||||
/// The case name.
|
||||
pub name: String,
|
||||
/// A matter or ticket number.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub scope: Scope,
|
||||
/// Seconds since the epoch. Items dated before aren't held (LH-3).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub from: Option<u64>,
|
||||
/// Seconds since the epoch. Items dated after aren't held (LH-3).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub to: Option<u64>,
|
||||
pub placed_at: u64,
|
||||
pub placed_by: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub placed_by_id: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub released: Option<Release>,
|
||||
}
|
||||
|
||||
/// Why a change to a hold is refused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// A released hold is read-only (LH-1).
|
||||
Released,
|
||||
/// The range may only widen (LH-3).
|
||||
Narrowed,
|
||||
/// The scope may only grow.
|
||||
ScopeShrunk,
|
||||
/// A hold has to cover something.
|
||||
EmptyScope,
|
||||
/// `from` after `to`.
|
||||
Backwards,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
Refusal::Released => "A released hold can't be changed; place a new one instead.",
|
||||
Refusal::Narrowed => {
|
||||
"A hold's date range can only be widened. To hold less, release it and place a new hold."
|
||||
}
|
||||
Refusal::ScopeShrunk => {
|
||||
"Nothing can be taken out of a hold's scope. To hold less, release it and place a new hold."
|
||||
}
|
||||
Refusal::EmptyScope => "A hold has to cover at least one account, group, domain or tenant, or the whole server.",
|
||||
Refusal::Backwards => "The range starts after it ends.",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hold {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.released.is_none()
|
||||
}
|
||||
|
||||
/// Whether an item dated `at` (seconds) falls in the hold's range. With
|
||||
/// no range, everything does (LH-3).
|
||||
pub fn covers_date(&self, at: u64) -> bool {
|
||||
self.from.is_none_or(|from| at >= from) && self.to.is_none_or(|to| at <= to)
|
||||
}
|
||||
|
||||
/// Checks a new hold, and tidies its scope.
|
||||
pub fn check_new(&mut self) -> Result<(), Refusal> {
|
||||
self.scope.normalize();
|
||||
if self.scope.is_empty() {
|
||||
return Err(Refusal::EmptyScope);
|
||||
}
|
||||
if let (Some(from), Some(to)) = (self.from, self.to)
|
||||
&& from > to
|
||||
{
|
||||
return Err(Refusal::Backwards);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that `next` is an allowed change of `self`: names and notes
|
||||
/// may change, the range may only widen, the scope may only grow, and a
|
||||
/// released hold may not change at all.
|
||||
pub fn check_update(&self, next: &mut Hold) -> Result<(), Refusal> {
|
||||
if !self.is_active() {
|
||||
return Err(Refusal::Released);
|
||||
}
|
||||
next.check_new()?;
|
||||
// An open end can't be closed, and a set end can only move outward
|
||||
let from_ok = match (self.from, next.from) {
|
||||
(None, Some(_)) => false,
|
||||
(Some(old), Some(new)) => new <= old,
|
||||
(_, None) => true,
|
||||
};
|
||||
let to_ok = match (self.to, next.to) {
|
||||
(None, Some(_)) => false,
|
||||
(Some(old), Some(new)) => new >= old,
|
||||
(_, None) => true,
|
||||
};
|
||||
if !from_ok || !to_ok {
|
||||
return Err(Refusal::Narrowed);
|
||||
}
|
||||
if !next.scope.contains(&self.scope) {
|
||||
return Err(Refusal::ScopeShrunk);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Json<T>(T);
|
||||
|
||||
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||
serde_json::to_vec(&self.0).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to serialize legal hold")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: serde::de::DeserializeOwned + Sync + Send> Deserialize for Json<T> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.details("Invalid legal hold")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_HOLD);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(id))
|
||||
}
|
||||
|
||||
fn original_class(item_id: u64) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(10);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_ORIGINAL);
|
||||
key.extend_from_slice(&item_id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// LH-10: an archived item's deadline from before a hold froze it, so a
|
||||
/// release can give it back (or a later one). None for an item held from
|
||||
/// its deletion, which never had one.
|
||||
pub async fn original_deadline(data: &Store, item_id: u64) -> trc::Result<Option<u64>> {
|
||||
data.get_value::<u64>(ValueKey::from(original_class(item_id)))
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
/// Notes (`Some`) or forgets (`None`) an item's deadline from before it
|
||||
/// was frozen.
|
||||
pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option<u64>) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
match until {
|
||||
Some(until) => batch.set(original_class(item_id), until.to_be_bytes().to_vec()),
|
||||
None => batch.clear(original_class(item_id)),
|
||||
};
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn export_class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_EXPORT);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every hold export, oldest first.
|
||||
pub async fn exports(data: &Store) -> trc::Result<Vec<Export>> {
|
||||
let mut exports = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(ValueKey::from(export_class(0)), ValueKey::from(export_class(u32::MAX))),
|
||||
|_, value| {
|
||||
if let Ok(Json(export)) = Json::<Export>::deserialize(value) {
|
||||
exports.push(export);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
/// Writes a new export under the next free id, which it returns.
|
||||
pub async fn create_export(data: &Store, export: &Export) -> trc::Result<u32> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let id = exports(data).await?.iter().map(|e| e.id).max().unwrap_or(0) + 1;
|
||||
let stored = Export {
|
||||
id,
|
||||
..export.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(export_class(id), AssertValue::None);
|
||||
batch.set(export_class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(id),
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves an export's progress.
|
||||
pub async fn update_export(data: &Store, export: &Export) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(export_class(export.id), Json(export).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// One hold, released or not.
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Hold>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Hold>>(key(id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(hold)| hold))
|
||||
}
|
||||
|
||||
/// Every hold, released ones included, oldest first.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Hold>> {
|
||||
let mut holds = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||
if let Ok(Json(hold)) = Json::<Hold>::deserialize(value) {
|
||||
holds.push(hold);
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(holds)
|
||||
}
|
||||
|
||||
/// The holds still in force.
|
||||
pub async fn active(data: &Store) -> trc::Result<Vec<Hold>> {
|
||||
Ok(all(data).await?.into_iter().filter(Hold::is_active).collect())
|
||||
}
|
||||
|
||||
/// Writes a new hold under the next free id, which it returns. Two nodes
|
||||
/// placing holds at once can't take the same id: the key must be absent.
|
||||
pub async fn create(data: &Store, hold: &Hold) -> trc::Result<u32> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let id = all(data).await?.iter().map(|h| h.id).max().unwrap_or(0) + 1;
|
||||
let stored = Hold {
|
||||
id,
|
||||
..hold.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(class(id), AssertValue::None);
|
||||
batch.set(class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(id),
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active holds that reach `member` (LH-2, LH-11).
|
||||
pub async fn covering(data: &Store, member: &Member) -> trc::Result<Vec<Hold>> {
|
||||
Ok(active(data)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|hold| hold.scope.covers(member))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// LH-2: an account a hold reached through its domain, group or tenant stays
|
||||
/// held when it leaves them: it is added to the hold by name. Called for
|
||||
/// every change to an account, so no move escapes a hold.
|
||||
pub async fn keep_moved(data: &Store, before: &Member, after: &Member) -> trc::Result<()> {
|
||||
if before == after {
|
||||
return Ok(());
|
||||
}
|
||||
for mut hold in active(data).await? {
|
||||
if hold.scope.covers(before) && !hold.scope.covers(after) {
|
||||
hold.scope.accounts.push(after.account);
|
||||
hold.scope.accounts.sort_unstable();
|
||||
hold.scope.accounts.dedup();
|
||||
update(data, &hold).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LH-8: names `account_id` in every hold that reaches it, so a deleted
|
||||
/// account, no longer in any domain or tenant, stays held.
|
||||
pub async fn pin_account(data: &Store, member: &Member) -> trc::Result<()> {
|
||||
for mut hold in covering(data, member).await? {
|
||||
if !hold.scope.accounts.contains(&member.account) {
|
||||
hold.scope.accounts.push(member.account);
|
||||
hold.scope.accounts.sort_unstable();
|
||||
update(data, &hold).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces a hold that `check_update` allowed.
|
||||
pub async fn update(data: &Store, hold: &Hold) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(class(hold.id), Json(hold).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hold(scope: Scope, from: Option<u64>, to: Option<u64>) -> Hold {
|
||||
Hold {
|
||||
id: 1,
|
||||
name: "Matter 4411".into(),
|
||||
reference: Some("4411".into()),
|
||||
description: None,
|
||||
scope,
|
||||
from,
|
||||
to,
|
||||
placed_at: 10,
|
||||
placed_by: "admin".into(),
|
||||
placed_by_id: None,
|
||||
released: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn accounts(ids: &[u32]) -> Scope {
|
||||
Scope {
|
||||
accounts: ids.to_vec(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_needs_a_scope_and_a_forward_range() {
|
||||
assert_eq!(hold(Scope::default(), None, None).check_new(), Err(Refusal::EmptyScope));
|
||||
assert_eq!(hold(accounts(&[2]), Some(20), Some(10)).check_new(), Err(Refusal::Backwards));
|
||||
let mut ok = hold(accounts(&[3, 2, 3]), None, None);
|
||||
assert_eq!(ok.check_new(), Ok(()));
|
||||
assert_eq!(ok.scope.accounts, vec![2, 3], "sorted, once each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_only_widens() {
|
||||
let current = hold(accounts(&[2]), Some(100), Some(200));
|
||||
let widened = |from, to| {
|
||||
let mut next = hold(accounts(&[2]), from, to);
|
||||
current.check_update(&mut next)
|
||||
};
|
||||
assert_eq!(widened(Some(50), Some(300)), Ok(()));
|
||||
assert_eq!(widened(None, None), Ok(()), "opening both ends widens");
|
||||
assert_eq!(widened(Some(150), Some(200)), Err(Refusal::Narrowed));
|
||||
assert_eq!(widened(Some(100), Some(150)), Err(Refusal::Narrowed));
|
||||
|
||||
let open = hold(accounts(&[2]), None, None);
|
||||
let mut closed = hold(accounts(&[2]), Some(1), None);
|
||||
assert_eq!(open.check_update(&mut closed), Err(Refusal::Narrowed), "an open end stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scope_only_grows() {
|
||||
let current = hold(
|
||||
Scope {
|
||||
accounts: vec![2],
|
||||
domains: vec![7],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut grown = hold(
|
||||
Scope {
|
||||
accounts: vec![2, 3],
|
||||
domains: vec![7],
|
||||
tenants: vec![1],
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(current.check_update(&mut grown), Ok(()));
|
||||
let mut shrunk = hold(accounts(&[2, 3]), None, None);
|
||||
assert_eq!(current.check_update(&mut shrunk), Err(Refusal::ScopeShrunk));
|
||||
|
||||
let server = hold(Scope { server: true, ..Default::default() }, None, None);
|
||||
let mut less = hold(accounts(&[2]), None, None);
|
||||
assert_eq!(server.check_update(&mut less), Err(Refusal::ScopeShrunk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_released_hold_is_read_only() {
|
||||
let mut released = hold(accounts(&[2]), None, None);
|
||||
released.released = Some(Release {
|
||||
at: 50,
|
||||
by: "admin".into(),
|
||||
by_id: None,
|
||||
reason: "Settled".into(),
|
||||
});
|
||||
let mut next = released.clone();
|
||||
next.name = "Renamed".into();
|
||||
assert_eq!(released.check_update(&mut next), Err(Refusal::Released));
|
||||
assert!(!released.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_in_range() {
|
||||
let whole = hold(accounts(&[2]), None, None);
|
||||
assert!(whole.covers_date(0) && whole.covers_date(u64::MAX));
|
||||
let ranged = hold(accounts(&[2]), Some(100), Some(200));
|
||||
assert!(ranged.covers_date(100) && ranged.covers_date(200));
|
||||
assert!(!ranged.covers_date(99) && !ranged.covers_date(201));
|
||||
let open_ended = hold(accounts(&[2]), Some(100), None);
|
||||
assert!(open_ended.covers_date(u64::MAX), "no `to` also catches mail still to come");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scope_reaches_members_through_domain_group_and_tenant() {
|
||||
let member = Member {
|
||||
account: 9,
|
||||
domains: vec![3, 4],
|
||||
groups: vec![20],
|
||||
tenant: Some(7),
|
||||
};
|
||||
let reaches = |scope: Scope| scope.covers(&member);
|
||||
assert!(reaches(accounts(&[9])));
|
||||
assert!(reaches(Scope { domains: vec![4], ..Default::default() }), "an alias's domain counts");
|
||||
assert!(reaches(Scope { groups: vec![20], ..Default::default() }));
|
||||
assert!(reaches(Scope { tenants: vec![7], ..Default::default() }));
|
||||
assert!(reaches(Scope { server: true, ..Default::default() }));
|
||||
assert!(!reaches(Scope { domains: vec![5], tenants: vec![8], ..Default::default() }));
|
||||
|
||||
// LH-2: leaving the held domain would free it, so the hold must name it
|
||||
let held = hold(Scope { domains: vec![3], ..Default::default() }, None, None);
|
||||
let moved = Member { domains: vec![6], ..member.clone() };
|
||||
assert!(held.scope.covers(&member) && !held.scope.covers(&moved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeping_deleted_items() {
|
||||
let whole = Keeping::new(None, &[hold(accounts(&[2]), None, None)]);
|
||||
assert!(whole.covers(Some(5)) && whole.covers(None));
|
||||
assert_eq!(whole.until(100, whole.covers(Some(5))), Some(HELD_UNTIL));
|
||||
assert!(is_held_until(whole.until(100, true).unwrap()));
|
||||
|
||||
// LH-3: a range holds only what's inside it; outside, undelete's rules
|
||||
let ranged = Keeping::new(Some(30), &[hold(accounts(&[2]), Some(1_000), Some(2_000))]);
|
||||
assert!(ranged.covers(Some(1_500)) && !ranged.covers(Some(2_500)));
|
||||
assert!(ranged.covers(None), "contacts, files and scripts are held whole");
|
||||
assert_eq!(ranged.until(100, ranged.covers(Some(2_500))), Some(130));
|
||||
assert!(ranged.covers_event(Some(2_000 + 3_600)), "a day of slack for an event");
|
||||
|
||||
// Neither held nor undelete: nothing is kept
|
||||
let none = Keeping::new(None, &[]);
|
||||
assert!(!none.keeps_anything());
|
||||
assert_eq!(none.until(100, false), None);
|
||||
assert!(!is_held_until(100 + 30 * 365 * 86_400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_as_json() {
|
||||
let current = hold(accounts(&[2]), Some(100), None);
|
||||
let json = serde_json::to_string(¤t).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Hold>(&json).unwrap(), current);
|
||||
assert!(json.contains("\"scope\":{\"accounts\":[2]}"), "{json}");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
pub mod ai;
|
||||
pub mod audit;
|
||||
pub mod branding;
|
||||
pub mod hold;
|
||||
pub mod lock;
|
||||
pub mod masked_email;
|
||||
pub mod security;
|
||||
|
||||
@@ -27,6 +27,11 @@ use store::{
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::Collection,
|
||||
};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
/// Rung when a lock is written, so this node's expiry timer re-reads the
|
||||
/// `until` dates (AL-5): a delegation ends at its time, not at a sweep.
|
||||
@@ -53,11 +58,6 @@ pub fn ended_between(locks: &[Lock], after: u64, now: u64) -> impl Iterator<Item
|
||||
})
|
||||
.map(|lock| lock.account_id)
|
||||
}
|
||||
use types::{
|
||||
acl::{Acl, AclGrant},
|
||||
collection::Collection,
|
||||
};
|
||||
use utils::map::bitmap::Bitmap;
|
||||
|
||||
const FEATURE: u8 = b'K';
|
||||
const KIND_LOCK: u8 = b'l';
|
||||
|
||||
@@ -123,6 +123,14 @@ pub struct EmailNote {
|
||||
pub size: u64,
|
||||
pub mailboxes: Vec<u32>,
|
||||
pub keywords: Vec<String>,
|
||||
/// LH-3: the ranges of the holds on the account when it was deleted.
|
||||
/// Its received date is only known when it's archived, which decides
|
||||
/// whether a hold keeps it after all.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub held_ranges: Vec<(Option<u64>, Option<u64>)>,
|
||||
/// The undelete deadline for when no range covers it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub otherwise_until: Option<u64>,
|
||||
}
|
||||
|
||||
/// What restore needs beyond the kept copy (UD-4, UD-8).
|
||||
@@ -462,8 +470,15 @@ mod tests {
|
||||
size: 3,
|
||||
mailboxes: vec![1],
|
||||
keywords: vec![],
|
||||
held_ranges: vec![(Some(10), None)],
|
||||
otherwise_until: Some(20),
|
||||
};
|
||||
let bytes = Json(¬e).serialize().unwrap();
|
||||
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
|
||||
|
||||
// A note written before legal holds still reads, as not held
|
||||
let old = br#"{"archived_at":1,"archived_until":2,"size":3,"mailboxes":[1],"keywords":[]}"#;
|
||||
let read = Json::<EmailNote>::deserialize(old).unwrap().0;
|
||||
assert!(read.held_ranges.is_empty() && read.otherwise_until.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
//! is made if archiving is on, fixing the deadline then. When the data is
|
||||
//! finally removed, a noted message becomes an archived item.
|
||||
|
||||
use crate::undelete::{
|
||||
data::{self, EmailNote, Extra},
|
||||
records,
|
||||
use crate::{
|
||||
hold::Keeping,
|
||||
undelete::{
|
||||
data::{self, EmailNote, Extra},
|
||||
records,
|
||||
},
|
||||
};
|
||||
use registry::{
|
||||
schema::structs::{ArchivedEmail, ArchivedItem},
|
||||
@@ -26,10 +29,12 @@ use store::{
|
||||
};
|
||||
use types::{blob::BlobId, blob_hash::BlobHash};
|
||||
|
||||
/// Notes a deleted message, when archiving is on (`retention` seconds).
|
||||
/// Notes a deleted message, when anything keeps it: undelete, or a legal
|
||||
/// hold on the account (LH-4). A held note keeps it until it's archived,
|
||||
/// when its received date says whether the hold's range covers it.
|
||||
pub fn note(
|
||||
batch: &mut BatchBuilder,
|
||||
retention: u64,
|
||||
keeping: &Keeping,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
size: u64,
|
||||
@@ -37,16 +42,23 @@ pub fn note(
|
||||
keywords: Vec<String>,
|
||||
) -> trc::Result<()> {
|
||||
let archived_at = now();
|
||||
// Held until the date is known; the undelete deadline otherwise
|
||||
let otherwise_until = keeping.until(archived_at, false);
|
||||
let Some(archived_until) = keeping.until(archived_at, keeping.is_held()) else {
|
||||
return Ok(());
|
||||
};
|
||||
data::note_email(
|
||||
batch,
|
||||
account_id,
|
||||
document_id,
|
||||
&EmailNote {
|
||||
archived_at,
|
||||
archived_until: archived_at + retention,
|
||||
archived_until,
|
||||
size,
|
||||
mailboxes,
|
||||
keywords,
|
||||
held_ranges: keeping.ranges.clone(),
|
||||
otherwise_until: if keeping.is_held() { otherwise_until } else { None },
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -78,9 +90,27 @@ pub async fn archive(
|
||||
document_id: u32,
|
||||
summary: Summary<'_>,
|
||||
) -> trc::Result<bool> {
|
||||
let Some(note) = data::email_note(data, account_id, document_id).await? else {
|
||||
let Some(mut note) = data::email_note(data, account_id, document_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
// LH-3: a held note's range decides now that the date is known; outside
|
||||
// it, undelete's deadline, or nothing kept at all
|
||||
if !note.held_ranges.is_empty() {
|
||||
let keeping = Keeping {
|
||||
retention: None,
|
||||
ranges: std::mem::take(&mut note.held_ranges),
|
||||
};
|
||||
if !keeping.covers(Some(summary.received_at)) {
|
||||
match note.otherwise_until {
|
||||
Some(until) => note.archived_until = until,
|
||||
None => {
|
||||
let mut batch = BatchBuilder::new();
|
||||
data::clear_email_note(&mut batch, account_id, document_id);
|
||||
return data.write(batch.build_all()).await.map(|_| false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let item = ArchivedItem::Email(ArchivedEmail {
|
||||
from: summary.from.unwrap_or_default().to_string(),
|
||||
subject: summary.subject.unwrap_or_default().to_string(),
|
||||
|
||||
@@ -97,6 +97,39 @@ pub async fn take(
|
||||
Ok(Some(note))
|
||||
}
|
||||
|
||||
/// A note, left in place: for a held account it's cleared only once its item
|
||||
/// is archived, so a failure leaves it for the retry (LH-5).
|
||||
pub async fn peek(
|
||||
data: &Store,
|
||||
kind: Kind,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
) -> trc::Result<Option<Note>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Note>>(ValueKey::from(note_class(kind, account_id, document_id)))
|
||||
.await?
|
||||
.map(|Json(note)| note))
|
||||
}
|
||||
|
||||
/// Removes a note once its item is archived or needn't be.
|
||||
pub async fn clear(data: &Store, kind: Kind, account_id: u32, document_id: u32) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(note_class(kind, account_id, document_id));
|
||||
data.write(batch.build_all()).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// An event's start, for a hold's range (LH-3). None for a recurring event,
|
||||
/// which may have an occurrence anywhere, so a hold keeps it whole.
|
||||
pub fn event_start(note: &Note) -> Option<u64> {
|
||||
let text = note.content.as_deref()?;
|
||||
if property(text, "RRULE").is_some() || property(text, "RDATE").is_some() {
|
||||
return None;
|
||||
}
|
||||
property(text, "DTSTART")
|
||||
.and_then(|v| ical_time(&v))
|
||||
.map(|t| t.max(0) as u64)
|
||||
}
|
||||
|
||||
/// The value of the first line starting with `name` (as `NAME:` or
|
||||
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
|
||||
fn property(text: &str, name: &str) -> Option<String> {
|
||||
|
||||
@@ -89,6 +89,54 @@ pub async fn insert(
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Moves an archived item's deadline, and its kept copy's with it: frozen
|
||||
/// by a hold (LH-6) or given a real one on release (LH-10). Returns the
|
||||
/// item as it now is.
|
||||
pub async fn set_deadline(
|
||||
data: &Store,
|
||||
registry: &RegistryStore,
|
||||
id: Id,
|
||||
item: &ArchivedItem,
|
||||
until: u64,
|
||||
) -> trc::Result<ArchivedItem> {
|
||||
let account_id = item.account_id().document_id();
|
||||
let blob_hash = item.blob_id().hash.clone();
|
||||
let before = item.archived_until().timestamp() as u64;
|
||||
let mut updated = item.clone();
|
||||
updated.set_archived_until(registry::types::datetime::UTCDateTime::from_timestamp(until as i64));
|
||||
|
||||
// The new link first, so the kept copy is never unlinked in between
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch
|
||||
.with_account_id(account_id)
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
hash: blob_hash.clone(),
|
||||
to: BlobLink::Temporary { until },
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
if before != until {
|
||||
batch.clear(BlobOp::Link {
|
||||
hash: blob_hash,
|
||||
to: BlobLink::Temporary { until: before },
|
||||
});
|
||||
}
|
||||
data::log_change(&mut batch, account_id, registry.assign_id(), id, Change::Updated);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(item_class(id.id()), updated.to_pickled_vec());
|
||||
registry
|
||||
.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Removes an archived item and releases its kept copy: on restore (UD-9),
|
||||
/// on destroy (UD-12) and past its deadline (UD-13).
|
||||
pub async fn remove(
|
||||
@@ -184,15 +232,38 @@ pub async fn get(
|
||||
}
|
||||
}
|
||||
|
||||
/// Every archived item on the server, account by account. Items are
|
||||
/// indexed by account only, so the registry's query without a filter,
|
||||
/// which reads its all-ids index, finds none of them.
|
||||
pub async fn all(data: &Store, registry: &RegistryStore) -> trc::Result<Vec<Id>> {
|
||||
let mut accounts = registry
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.into_iter()
|
||||
.map(|id| id.document_id())
|
||||
.collect::<Vec<_>>();
|
||||
// Deleted accounts still kept have archived items too
|
||||
accounts.extend(data::kept_accounts(data).await?.into_iter().map(|(id, _)| id));
|
||||
accounts.sort_unstable();
|
||||
accounts.dedup();
|
||||
let mut items = Vec::new();
|
||||
for account_id in accounts {
|
||||
items.extend(
|
||||
registry
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id))
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Removes every expired archived item on the server (UD-13), for the
|
||||
/// scheduled clean-up.
|
||||
pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result<usize> {
|
||||
let mut removed = 0;
|
||||
for id in registry
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
for id in all(data, registry).await? {
|
||||
if let Some(item) = registry.object::<ArchivedItem>(id).await?
|
||||
&& is_expired(&item)
|
||||
{
|
||||
|
||||
@@ -243,10 +243,8 @@ impl<T: SessionStream> SessionData<T> {
|
||||
|
||||
let mut fully_deleted = RoaringBitmap::new();
|
||||
let mut thread_ids = RoaringBitmap::new();
|
||||
// inbuxa: UD-1, UD-6a: the retention in force now
|
||||
let retention = inbuxa_features::undelete::settings::retention(self.server.registry())
|
||||
.await?
|
||||
.items;
|
||||
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
|
||||
let keeping = self.server.keeping(account_id).await?;
|
||||
self.server
|
||||
.archives(
|
||||
account_id,
|
||||
@@ -270,10 +268,10 @@ impl<T: SessionStream> SessionData<T> {
|
||||
fully_deleted.insert(document_id);
|
||||
thread_ids.insert(metadata.inner.thread_id.to_native());
|
||||
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
|
||||
if let Some(retention) = retention {
|
||||
if keeping.keeps_anything() {
|
||||
inbuxa_features::undelete::email::note(
|
||||
batch,
|
||||
retention,
|
||||
&keeping,
|
||||
account_id,
|
||||
document_id,
|
||||
metadata.inner.size.to_native() as u64,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HoldExport/get` and `/set` under `urn:inbuxa:jmap`: collecting
|
||||
//! what a legal hold keeps as a ZIP (audit-hold-lock spec, LH-12). Creating
|
||||
//! one starts it; it runs in the background, and `get` says when it's ready
|
||||
//! and which blob to download. The set call's `reason` says why (AU-12).
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HoldExport;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HoldExportProperty {
|
||||
Id,
|
||||
HoldId,
|
||||
AccountIds,
|
||||
Reason,
|
||||
Status,
|
||||
CreatedAt,
|
||||
CreatedBy,
|
||||
FinishedAt,
|
||||
BlobId,
|
||||
Size,
|
||||
Items,
|
||||
Sha256,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HoldExportValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for HoldExportProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
match parent {
|
||||
None => HoldExportProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HoldExportProperty::Id => "id",
|
||||
HoldExportProperty::HoldId => "holdId",
|
||||
HoldExportProperty::AccountIds => "accountIds",
|
||||
HoldExportProperty::Reason => "reason",
|
||||
HoldExportProperty::Status => "status",
|
||||
HoldExportProperty::CreatedAt => "createdAt",
|
||||
HoldExportProperty::CreatedBy => "createdBy",
|
||||
HoldExportProperty::FinishedAt => "finishedAt",
|
||||
HoldExportProperty::BlobId => "blobId",
|
||||
HoldExportProperty::Size => "size",
|
||||
HoldExportProperty::Items => "items",
|
||||
HoldExportProperty::Sha256 => "sha256",
|
||||
HoldExportProperty::Error => "error",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl HoldExportProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => HoldExportProperty::Id,
|
||||
b"holdId" => HoldExportProperty::HoldId,
|
||||
b"accountIds" => HoldExportProperty::AccountIds,
|
||||
b"reason" => HoldExportProperty::Reason,
|
||||
b"status" => HoldExportProperty::Status,
|
||||
b"createdAt" => HoldExportProperty::CreatedAt,
|
||||
b"createdBy" => HoldExportProperty::CreatedBy,
|
||||
b"finishedAt" => HoldExportProperty::FinishedAt,
|
||||
b"blobId" => HoldExportProperty::BlobId,
|
||||
b"size" => HoldExportProperty::Size,
|
||||
b"items" => HoldExportProperty::Items,
|
||||
b"sha256" => HoldExportProperty::Sha256,
|
||||
b"error" => HoldExportProperty::Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for HoldExportProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
HoldExportProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for HoldExportValue {
|
||||
type Property = HoldExportProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(HoldExportProperty::Id) => Id::from_str(value).ok().map(HoldExportValue::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HoldExportValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own arguments: why (AU-12).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HoldExportSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for HoldExportSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for HoldExport {
|
||||
type Property = HoldExportProperty;
|
||||
|
||||
type Element = HoldExportValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = HoldExportSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = HoldExportProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for HoldExportValue {
|
||||
fn from(id: Id) -> Self {
|
||||
HoldExportValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HoldExportValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
HoldExportValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
HoldExportValue::Id(id) => Some(AnyId::Id(*id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||
if let AnyId::Id(id) = new_id {
|
||||
*self = HoldExportValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HoldExportProperty {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LegalHold/get` and `/set` under `urn:inbuxa:jmap`: legal holds
|
||||
//! (audit-hold-lock spec, LH-1 to LH-14). Creating one places the hold;
|
||||
//! updating renames it, widens its range or scope, or releases it with
|
||||
//! `released: true`. There is no destroy: a released hold stays listed. The
|
||||
//! set call's `reason` argument says why, for the audit log (AU-12);
|
||||
//! creating takes it as a property too.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LegalHold;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum LegalHoldProperty {
|
||||
Id,
|
||||
/// The case name.
|
||||
Name,
|
||||
/// A matter or ticket number.
|
||||
Reference,
|
||||
Description,
|
||||
/// `{server, accounts, groups, domains, tenants}`.
|
||||
Scope,
|
||||
/// The range's start, a UTC date, or null.
|
||||
From,
|
||||
/// The range's end, a UTC date, or null.
|
||||
To,
|
||||
/// Why it was placed (create only; later reasons are the audit log's).
|
||||
Reason,
|
||||
PlacedAt,
|
||||
PlacedBy,
|
||||
/// Set to true to release it.
|
||||
Released,
|
||||
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)]
|
||||
pub enum LegalHoldValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for LegalHoldProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside the scope stay plain keys
|
||||
match parent {
|
||||
None => LegalHoldProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
LegalHoldProperty::Id => "id",
|
||||
LegalHoldProperty::Name => "name",
|
||||
LegalHoldProperty::Reference => "reference",
|
||||
LegalHoldProperty::Description => "description",
|
||||
LegalHoldProperty::Scope => "scope",
|
||||
LegalHoldProperty::From => "from",
|
||||
LegalHoldProperty::To => "to",
|
||||
LegalHoldProperty::Reason => "reason",
|
||||
LegalHoldProperty::PlacedAt => "placedAt",
|
||||
LegalHoldProperty::PlacedBy => "placedBy",
|
||||
LegalHoldProperty::Released => "released",
|
||||
LegalHoldProperty::ReleasedAt => "releasedAt",
|
||||
LegalHoldProperty::ReleasedBy => "releasedBy",
|
||||
LegalHoldProperty::ReleaseReason => "releaseReason",
|
||||
LegalHoldProperty::AccountsCovered => "accountsCovered",
|
||||
LegalHoldProperty::ItemsHeld => "itemsHeld",
|
||||
LegalHoldProperty::SizeHeld => "sizeHeld",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl LegalHoldProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => LegalHoldProperty::Id,
|
||||
b"name" => LegalHoldProperty::Name,
|
||||
b"reference" => LegalHoldProperty::Reference,
|
||||
b"description" => LegalHoldProperty::Description,
|
||||
b"scope" => LegalHoldProperty::Scope,
|
||||
b"from" => LegalHoldProperty::From,
|
||||
b"to" => LegalHoldProperty::To,
|
||||
b"reason" => LegalHoldProperty::Reason,
|
||||
b"placedAt" => LegalHoldProperty::PlacedAt,
|
||||
b"placedBy" => LegalHoldProperty::PlacedBy,
|
||||
b"released" => LegalHoldProperty::Released,
|
||||
b"releasedAt" => LegalHoldProperty::ReleasedAt,
|
||||
b"releasedBy" => LegalHoldProperty::ReleasedBy,
|
||||
b"releaseReason" => LegalHoldProperty::ReleaseReason,
|
||||
b"accountsCovered" => LegalHoldProperty::AccountsCovered,
|
||||
b"itemsHeld" => LegalHoldProperty::ItemsHeld,
|
||||
b"sizeHeld" => LegalHoldProperty::SizeHeld,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for LegalHoldProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
LegalHoldProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for LegalHoldValue {
|
||||
type Property = LegalHoldProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(LegalHoldProperty::Id) => Id::from_str(value).ok().map(LegalHoldValue::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
LegalHoldValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The get call's own argument: only the active holds covering an account,
|
||||
/// through any route (LH-2), for the console's Held badge (LH-14).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LegalHoldGetArguments {
|
||||
pub covering_account: Option<Id>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for LegalHoldGetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "coveringAccount" {
|
||||
self.covering_account = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own arguments: why (AU-12).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LegalHoldSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for LegalHoldSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for LegalHold {
|
||||
type Property = LegalHoldProperty;
|
||||
|
||||
type Element = LegalHoldValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = LegalHoldGetArguments;
|
||||
|
||||
type SetArguments<'de> = LegalHoldSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = LegalHoldProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for LegalHoldValue {
|
||||
fn from(id: Id) -> Self {
|
||||
LegalHoldValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for LegalHoldValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
LegalHoldValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
LegalHoldValue::Id(id) => Some(AnyId::Id(*id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||
if let AnyId::Id(id) = new_id {
|
||||
*self = LegalHoldValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for LegalHoldProperty {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub mod fastmail_masked_email; // inbuxa: masked email
|
||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||
|
||||
@@ -70,6 +70,12 @@ impl Response<'_> {
|
||||
GetResponseMethod::AccountLock(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::LegalHold(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HoldExport(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ impl Response<'_> {
|
||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self)?
|
||||
@@ -111,6 +113,12 @@ impl Response<'_> {
|
||||
SetRequestMethod::AccountLock(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::LegalHold(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HoldExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ pub enum MethodObject {
|
||||
AuditVerification,
|
||||
// inbuxa: account lock with delegation
|
||||
AccountLock,
|
||||
// inbuxa: legal hold
|
||||
LegalHold,
|
||||
HoldExport,
|
||||
ProtocolPolicy,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
@@ -91,7 +94,9 @@ impl MethodObject {
|
||||
| MethodObject::AuditSettings
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock => Capability::Inbuxa,
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -279,6 +284,10 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::AuditExport) => "inbuxa:AuditExport/set",
|
||||
(MethodFunction::Get, MethodObject::AccountLock) => "inbuxa:AccountLock/get",
|
||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||
"inbuxa:AuditVerification/set"
|
||||
}
|
||||
@@ -423,6 +432,10 @@ impl MethodName {
|
||||
"inbuxa:AuditExport/set" => (MethodObject::AuditExport, MethodFunction::Set),
|
||||
"inbuxa:AccountLock/get" => (MethodObject::AccountLock, MethodFunction::Get),
|
||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||
@@ -487,6 +500,8 @@ impl Display for MethodObject {
|
||||
MethodObject::AuditExport => "inbuxa:AuditExport",
|
||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
|
||||
@@ -119,6 +119,8 @@ pub enum GetRequestMethod {
|
||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
@@ -151,6 +153,8 @@ pub enum SetRequestMethod<'x> {
|
||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
|
||||
@@ -566,6 +566,36 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold exports
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HoldExport(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HoldExport(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::LegalHold(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: the audit log
|
||||
(MethodFunction::Get, MethodObject::AuditEvent) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AuditEvent(value)),
|
||||
|
||||
@@ -106,6 +106,8 @@ pub enum GetResponseMethod {
|
||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
@@ -138,6 +140,8 @@ pub enum SetResponseMethod {
|
||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -765,3 +769,29 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
||||
ResponseMethod::Set(SetResponseMethod::AccountLock(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: legal hold
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: legal hold exports
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::HoldExport(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_hold_export::HoldExport>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_hold_export::HoldExport>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ base64 = "0.23"
|
||||
p256 = { version = "0.13", features = ["ecdh"] }
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
zip = "8.6" # inbuxa: legal hold exports (LH-12)
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"]}
|
||||
tokio-tungstenite = "0.30"
|
||||
tungstenite = "0.30"
|
||||
|
||||
@@ -96,6 +96,8 @@ impl JmapAuthorization for AccessToken {
|
||||
}
|
||||
// inbuxa: account lock (AL-12)
|
||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -222,6 +224,23 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysAccountLockUpdate,
|
||||
Permission::SysAccountLockDestroy,
|
||||
),
|
||||
// inbuxa: legal hold (LH-13); holds are never destroyed,
|
||||
// and the handler refuses a destroy outright
|
||||
SetRequestMethod::LegalHold(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysLegalHoldCreate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
),
|
||||
// inbuxa: LH-12, exporting held data
|
||||
SetRequestMethod::HoldExport(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysLegalHoldExport,
|
||||
Permission::SysLegalHoldExport,
|
||||
),
|
||||
SetRequestMethod::AuditVerification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -369,6 +388,8 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
|
||||
@@ -273,6 +273,12 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::AccountLock(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::LegalHold(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HoldExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Explanation(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -446,6 +452,16 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: legal hold (LH-1)
|
||||
// inbuxa: legal hold exports (LH-12)
|
||||
GetRequestMethod::HoldExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::hold_export_api::get(self, *req).await?.into()
|
||||
}
|
||||
GetRequestMethod::LegalHold(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-9)
|
||||
GetRequestMethod::AuditEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -797,6 +813,62 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: legal hold (LH-1), each change recorded with its
|
||||
// reason (AU-12)
|
||||
// inbuxa: legal hold exports, recorded with their reason
|
||||
// (AU-1.9, AU-12)
|
||||
SetRequestMethod::HoldExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
req.create.as_ref().and_then(|create| {
|
||||
create.values().find_map(|value| {
|
||||
serde_json::to_value(value)
|
||||
.ok()?
|
||||
.get("reason")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
});
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::hold_export_api::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::LegalHold(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone().or_else(|| {
|
||||
req.create.as_ref().and_then(|create| {
|
||||
create.values().find_map(|value| {
|
||||
serde_json::to_value(value)
|
||||
.ok()?
|
||||
.get("reason")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
});
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::legal_hold::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AuditExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||
|
||||
@@ -424,6 +424,8 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::AuditExport
|
||||
| MethodObject::AuditVerification
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -226,9 +226,14 @@ impl FileNodeCopy for Server {
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) =
|
||||
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
|
||||
{
|
||||
// inbuxa: AL-7: a writing delegate may add at the top
|
||||
if let Err(err) = validate_file_node_hierarchy(
|
||||
None,
|
||||
&file_node,
|
||||
is_shared && !access_token.delegate_may_write(account_id),
|
||||
&cache,
|
||||
&created_folders,
|
||||
) {
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
@@ -362,7 +367,7 @@ impl FileNodeCopy for Server {
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
} else if is_shared {
|
||||
} else if is_shared && !access_token.delegate_may_write(account_id) {
|
||||
response.not_created.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
|
||||
@@ -149,9 +149,15 @@ impl FileNodeSet for Server {
|
||||
};
|
||||
|
||||
// Validate hierarchy
|
||||
if let Err(err) =
|
||||
validate_file_node_hierarchy(None, &file_node, is_shared, &cache, &created_folders)
|
||||
{
|
||||
// inbuxa: AL-7: a writing delegate may add at the top of a
|
||||
// locked account, which may hold no folders at all
|
||||
if let Err(err) = validate_file_node_hierarchy(
|
||||
None,
|
||||
&file_node,
|
||||
is_shared && !access_token.delegate_may_write(account_id),
|
||||
&cache,
|
||||
&created_folders,
|
||||
) {
|
||||
response.not_created.append(id, err);
|
||||
continue 'create;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ async fn before<T: JmapObject>(
|
||||
|
||||
for (client_id, value) in request.create.iter().flat_map(|c| c.iter()) {
|
||||
let after = serde_json::to_value(value).unwrap_or_default();
|
||||
let described = diff::describe(&after);
|
||||
let mut described = diff::describe(&after);
|
||||
described.name = full_name(server, object, &after, described.name).await;
|
||||
let changes = after
|
||||
.as_object()
|
||||
.map(|patch| diff::patch(object, None, patch))
|
||||
@@ -192,7 +193,10 @@ async fn before<T: JmapObject>(
|
||||
None => fork_current(server, object, id).await,
|
||||
};
|
||||
let patch = serde_json::to_value(value).unwrap_or_default();
|
||||
let described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||
if let Some(before) = &before {
|
||||
described.name = full_name(server, object, before, described.name).await;
|
||||
}
|
||||
let changes = patch
|
||||
.as_object()
|
||||
.map(|patch| diff::patch(object, before.as_ref(), patch))
|
||||
@@ -214,7 +218,10 @@ async fn before<T: JmapObject>(
|
||||
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
||||
for id in destroy {
|
||||
let before = stored(server, registry, id).await;
|
||||
let described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||
if let Some(before) = &before {
|
||||
described.name = full_name(server, object, before, described.name).await;
|
||||
}
|
||||
records.push((
|
||||
Item::Destroy(id.clone()),
|
||||
Action::Destroy,
|
||||
@@ -345,6 +352,29 @@ fn id_text(id: &MaybeInvalid<Id>) -> String {
|
||||
/// The fork's own settings as they are now, as JSON, so their changes are
|
||||
/// recorded with what they replaced. Their stored names are the JMAP
|
||||
/// property names.
|
||||
/// An account's or a mailing list's name is only its local part, and two
|
||||
/// domains' "leslie" would read alike: records name it by its full address.
|
||||
async fn full_name(server: &Server, object: &str, value: &Value, name: Option<String>) -> Option<String> {
|
||||
let name = name?;
|
||||
if !matches!(object, "x:Account" | "x:MailingList") || name.contains('@') {
|
||||
return Some(name);
|
||||
}
|
||||
let domain = value
|
||||
.get("domainId")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| <Id as std::str::FromStr>::from_str(id).ok());
|
||||
match domain {
|
||||
Some(domain) => match server.domain_by_id(domain.document_id()).await {
|
||||
Ok(Some(domain)) => match domain.names.first() {
|
||||
Some(domain) => Some(format!("{name}@{domain}")),
|
||||
None => Some(name),
|
||||
},
|
||||
_ => Some(name),
|
||||
},
|
||||
None => Some(name),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> Option<Value> {
|
||||
use inbuxa_features::{ai::limits, audit::log, security};
|
||||
let data = server.store();
|
||||
@@ -361,6 +391,34 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|policy| serde_json::to_value(policy).ok()),
|
||||
// LH-1: a hold as the API shows it, so a change reads before/after
|
||||
"inbuxa:LegalHold" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
let hold = inbuxa_features::hold::get(data, u32::try_from(id.id()).ok()?)
|
||||
.await
|
||||
.ok()??;
|
||||
let ids = |list: &[u32]| list.iter().map(|id| Id::from(*id).to_string()).collect::<Vec<_>>();
|
||||
let date = |at: Option<u64>| {
|
||||
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at as i64).to_string())
|
||||
};
|
||||
Some(serde_json::json!({
|
||||
"name": hold.name,
|
||||
"reference": hold.reference,
|
||||
"description": hold.description,
|
||||
"scope": {
|
||||
"server": hold.scope.server,
|
||||
"accounts": ids(&hold.scope.accounts),
|
||||
"groups": ids(&hold.scope.groups),
|
||||
"domains": ids(&hold.scope.domains),
|
||||
"tenants": ids(&hold.scope.tenants),
|
||||
},
|
||||
"from": date(hold.from),
|
||||
"to": date(hold.to),
|
||||
"released": !hold.is_active(),
|
||||
}))
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
"inbuxa:TenantProtocolPolicy" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
security::tenant_protocol_policy::get(data, id.document_id())
|
||||
|
||||
@@ -124,13 +124,29 @@ pub async fn reserved(
|
||||
/// of upstream's immediate destruction. Returns the other accounts whose
|
||||
/// access changed, or `None` when nothing is kept.
|
||||
pub async fn keep(server: &Server, id: Id, account: &Account) -> trc::Result<Option<Vec<u32>>> {
|
||||
let Some(period) = retention(server.registry()).await?.accounts else {
|
||||
return Ok(None);
|
||||
};
|
||||
let account_id = id.document_id();
|
||||
let deleted_at = now();
|
||||
let kept_until = deleted_at + period;
|
||||
let inner = ObjectInner::Account(account.clone());
|
||||
// inbuxa: LH-8: a held account's data stays, with no expiry, whether or
|
||||
// not undelete keeps accounts; its holds name it from now on
|
||||
let member = inbuxa_features::hold::Member::of(account_id, &inner);
|
||||
let held = match &member {
|
||||
Some(member) => {
|
||||
!inbuxa_features::hold::covering(server.store(), member)
|
||||
.await?
|
||||
.is_empty()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let period = retention(server.registry()).await?.accounts;
|
||||
let deleted_at = now();
|
||||
let kept_until = match (held, period) {
|
||||
(true, _) => inbuxa_features::hold::HELD_UNTIL,
|
||||
(false, Some(period)) => deleted_at + period,
|
||||
(false, None) => return Ok(None),
|
||||
};
|
||||
if held && let Some(member) = &member {
|
||||
inbuxa_features::hold::pin_account(server.store(), member).await?;
|
||||
}
|
||||
let addresses = addresses_of(server, &inner)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -324,6 +340,18 @@ pub async fn set(
|
||||
|
||||
for id in will_destroy {
|
||||
match data::kept_account(data, id.document_id()).await? {
|
||||
// inbuxa: LH-8: a held account's data can't be destroyed
|
||||
Some(kept)
|
||||
if may_reach(access_token, &kept, Permission::SysAccountDestroy)
|
||||
&& (inbuxa_features::hold::is_held_until(kept.kept_until)
|
||||
|| server.is_kept_held(id.document_id(), &kept).await?) =>
|
||||
{
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("A legal hold applies to this account, so its data stays."),
|
||||
);
|
||||
}
|
||||
Some(kept) if may_reach(access_token, &kept, Permission::SysAccountDestroy) => {
|
||||
destroy_now(server, id, &kept).await?;
|
||||
response.destroyed.push(id);
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Collecting what a legal hold keeps, as a ZIP (audit-hold-lock spec,
|
||||
//! LH-12). For each account the hold covers (or those asked for): its mail,
|
||||
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
||||
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
||||
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
||||
|
||||
use common::{Server, hold::kept_member};
|
||||
use email::{
|
||||
cache::MessageCacheFetch,
|
||||
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
|
||||
};
|
||||
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard, file::FileNode};
|
||||
use inbuxa_features::{
|
||||
hold::{Hold, Keeping, is_held_until},
|
||||
undelete::records,
|
||||
};
|
||||
use registry::schema::{prelude::ObjectType, structs::ArchivedItem};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{Cursor, Write};
|
||||
use store::{
|
||||
ValueKey,
|
||||
registry::RegistryQuery,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::{
|
||||
collection::{Collection, SyncCollection},
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
/// The largest ZIP built in memory. A bigger collection is refused with a
|
||||
/// clear error rather than taking the node down; export fewer accounts.
|
||||
pub const MAX_EXPORT: u64 = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
/// One line of `manifest.csv`.
|
||||
struct Entry {
|
||||
path: String,
|
||||
account: String,
|
||||
kind: &'static str,
|
||||
folder: String,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
size: usize,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn csv(field: &str) -> String {
|
||||
if field.contains([',', '"', '\n', '\r']) {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A path segment that's safe in a ZIP: no separators, no leading dots.
|
||||
fn segment(name: &str) -> String {
|
||||
let cleaned: String = name
|
||||
.chars()
|
||||
.map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c })
|
||||
.collect();
|
||||
let trimmed = cleaned.trim_start_matches('.').trim();
|
||||
if trimmed.is_empty() { "_".into() } else { trimmed.chars().take(120).collect() }
|
||||
}
|
||||
|
||||
fn date_text(at: Option<i64>) -> String {
|
||||
at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
zip: ZipWriter<Cursor<Vec<u8>>>,
|
||||
entries: Vec<Entry>,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn new() -> Self {
|
||||
Builder {
|
||||
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
||||
entries: Vec::new(),
|
||||
written: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add(
|
||||
&mut self,
|
||||
path: String,
|
||||
bytes: &[u8],
|
||||
account: &str,
|
||||
kind: &'static str,
|
||||
folder: &str,
|
||||
date: Option<i64>,
|
||||
archived: bool,
|
||||
) -> trc::Result<()> {
|
||||
self.written += bytes.len() as u64;
|
||||
if self.written > MAX_EXPORT {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("The collection is larger than one export can hold (2 GB). Export fewer accounts at a time."));
|
||||
}
|
||||
// Unique within the ZIP, however names collide
|
||||
let mut name = path.clone();
|
||||
let mut n = 1;
|
||||
while self.entries.iter().any(|e| e.path == name) {
|
||||
n += 1;
|
||||
name = match path.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.ends_with('/') => format!("{stem} ({n}).{ext}"),
|
||||
_ => format!("{path} ({n})"),
|
||||
};
|
||||
}
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
self.zip
|
||||
.start_file(name.as_str(), options)
|
||||
.and_then(|_| self.zip.write_all(bytes).map_err(Into::into))
|
||||
.map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to write the export")
|
||||
.reason(err)
|
||||
})?;
|
||||
self.entries.push(Entry {
|
||||
path: name,
|
||||
account: account.to_string(),
|
||||
kind,
|
||||
folder: folder.to_string(),
|
||||
date,
|
||||
archived,
|
||||
size: bytes.len(),
|
||||
sha256: hex(&Sha256::digest(bytes)),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes the ZIP with its manifest and the manifest's hash. Returns the
|
||||
/// bytes and how many items went in.
|
||||
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
||||
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
||||
for e in &self.entries {
|
||||
manifest.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{}\n",
|
||||
csv(&e.path),
|
||||
csv(&e.account),
|
||||
e.kind,
|
||||
csv(&e.folder),
|
||||
date_text(e.date),
|
||||
e.archived,
|
||||
e.size,
|
||||
e.sha256
|
||||
));
|
||||
}
|
||||
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let fail = |err: zip::result::ZipError| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to write the export")
|
||||
.reason(err)
|
||||
};
|
||||
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||
self.zip
|
||||
.write_all(format!("{manifest_hash} manifest.csv\n").as_bytes())
|
||||
.map_err(|e| fail(e.into()))?;
|
||||
let items = self.entries.len();
|
||||
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
||||
Ok((bytes, items))
|
||||
}
|
||||
}
|
||||
|
||||
/// The accounts to collect: those asked for that the hold covers, or every
|
||||
/// account it covers, deleted ones it keeps included.
|
||||
async fn accounts(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<Vec<u32>> {
|
||||
let mut covered = Vec::new();
|
||||
for id in server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Account))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
let account_id = id.document_id();
|
||||
if let Some(member) = server.member_of(account_id).await
|
||||
&& hold.scope.covers(&member)
|
||||
{
|
||||
covered.push(account_id);
|
||||
}
|
||||
}
|
||||
for (account_id, kept) in inbuxa_features::undelete::data::kept_accounts(server.store()).await? {
|
||||
if hold.scope.covers(&kept_member(account_id, &kept)) {
|
||||
covered.push(account_id);
|
||||
}
|
||||
}
|
||||
covered.sort_unstable();
|
||||
covered.dedup();
|
||||
if !asked.is_empty() {
|
||||
covered.retain(|id| asked.contains(id));
|
||||
}
|
||||
Ok(covered)
|
||||
}
|
||||
|
||||
async fn blob(server: &Server, hash: &[u8]) -> trc::Result<Option<Vec<u8>>> {
|
||||
server.blob_store().get_blob(hash, 0..usize::MAX).await
|
||||
}
|
||||
|
||||
/// Collects `accounts` under `hold` into a ZIP. Returns its bytes and item
|
||||
/// count.
|
||||
pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(Vec<u8>, usize)> {
|
||||
let keeping = Keeping::new(None, std::slice::from_ref(hold));
|
||||
let data = server.store();
|
||||
let mut out = Builder::new();
|
||||
|
||||
for account_id in accounts(server, hold, asked).await? {
|
||||
let address = server.audit_account_name(account_id).await;
|
||||
let base = format!("{}/", segment(&address));
|
||||
let live = server.account(account_id).await.is_ok();
|
||||
|
||||
if live {
|
||||
// Mail, by the folder it's in
|
||||
let cache = server
|
||||
.get_cached_messages(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for message in cache.emails.items.iter() {
|
||||
let Some(metadata_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
message.document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let metadata = metadata_
|
||||
.unarchive::<MessageMetadata>()
|
||||
.caused_by(trc::location!())?;
|
||||
let received = metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK;
|
||||
if !keeping.covers(Some(received)) {
|
||||
continue;
|
||||
}
|
||||
let folder = message
|
||||
.mailboxes
|
||||
.first()
|
||||
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
||||
.map(|b| b.path.clone())
|
||||
.unwrap_or_default();
|
||||
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
||||
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||
let path = format!(
|
||||
"{base}mail/{}/{}.eml",
|
||||
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
||||
Id::from(message.document_id)
|
||||
);
|
||||
out.add(path, &bytes, &address, "email", &folder, Some(received as i64), false)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Calendars, contacts and files, by their DAV paths
|
||||
for (sync, kind) in [
|
||||
(SyncCollection::Calendar, "event"),
|
||||
(SyncCollection::AddressBook, "contact"),
|
||||
(SyncCollection::FileNode, "file"),
|
||||
] {
|
||||
let resources = server
|
||||
.fetch_dav_resources(account_id, account_id, sync)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for path in resources.paths.iter() {
|
||||
let Some(resource) = resources.resources.get(path.resource_idx) else {
|
||||
continue;
|
||||
};
|
||||
let folder = path.path.rsplit_once('/').map(|(f, _)| f).unwrap_or_default();
|
||||
let zip_path = |ext: Option<&str>| {
|
||||
let mut p = format!(
|
||||
"{base}{}/{}",
|
||||
match kind {
|
||||
"event" => "calendar",
|
||||
"contact" => "contacts",
|
||||
_ => "files",
|
||||
},
|
||||
path.path.split('/').map(segment).collect::<Vec<_>>().join("/")
|
||||
);
|
||||
if let Some(ext) = ext
|
||||
&& !p.ends_with(ext)
|
||||
{
|
||||
p.push_str(ext);
|
||||
}
|
||||
p
|
||||
};
|
||||
use common::DavResourceMetadata as M;
|
||||
match &resource.data {
|
||||
M::CalendarEvent { start, .. } => {
|
||||
if !keeping.covers_event(Some((*start).max(0) as u64)) {
|
||||
continue;
|
||||
}
|
||||
let Some(event_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::CalendarEvent,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
||||
let text = event.data.event.to_string();
|
||||
out.add(zip_path(Some(".ics")), text.as_bytes(), &address, kind, folder, Some(*start), false)?;
|
||||
}
|
||||
M::ContactCard { .. } => {
|
||||
let Some(card_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::ContactCard,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
||||
let mut text = String::with_capacity(256);
|
||||
let _ = card.card.write_to(&mut text, server.core.groupware.vcard_version);
|
||||
out.add(zip_path(Some(".vcf")), text.as_bytes(), &address, kind, folder, None, false)?;
|
||||
}
|
||||
M::File { size: Some(_), .. } => {
|
||||
let Some(file_) = data
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
||||
account_id,
|
||||
Collection::FileNode,
|
||||
resource.document_id,
|
||||
))
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
||||
let Some(props) = file.file.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
||||
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||
out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the hold keeps of what was deleted
|
||||
for (id, item) in records::of_account(data, server.registry(), account_id).await? {
|
||||
if !is_held_until(item.archived_until().timestamp().max(0) as u64) {
|
||||
continue;
|
||||
}
|
||||
let (kind, ext, date) = match &item {
|
||||
ArchivedItem::Email(e) => ("email", ".eml", Some(e.received_at.timestamp())),
|
||||
ArchivedItem::CalendarEvent(e) => ("event", ".ics", e.start_time.map(|t| t.timestamp())),
|
||||
ArchivedItem::ContactCard(_) => ("contact", ".vcf", None),
|
||||
ArchivedItem::FileNode(_) => ("file", "", None),
|
||||
ArchivedItem::SieveScript(_) => ("sieve", ".sieve", None),
|
||||
};
|
||||
// Kept by this hold, not only by another one over the same account
|
||||
let in_range = match kind {
|
||||
"event" => keeping.covers_event(date.map(|d| d.max(0) as u64)),
|
||||
_ => keeping.covers(date.map(|d| d.max(0) as u64)),
|
||||
};
|
||||
if !in_range {
|
||||
continue;
|
||||
}
|
||||
let name = match &item {
|
||||
ArchivedItem::FileNode(f) => segment(&f.name),
|
||||
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
||||
_ => format!("{id}{ext}"),
|
||||
};
|
||||
if let Some(bytes) = blob(server, item.blob_id().hash.as_slice()).await? {
|
||||
out.add(format!("{base}archived/{kind}/{name}"), &bytes, &address, kind, "", date, true)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_are_safe_in_a_zip() {
|
||||
assert_eq!(segment("../etc/passwd"), "_etc_passwd");
|
||||
assert_eq!(segment(" "), "_");
|
||||
assert_eq!(segment("Q3 report.pdf"), "Q3 report.pdf");
|
||||
assert_eq!(csv("a,b"), "\"a,b\"");
|
||||
assert_eq!(csv("say \"hi\""), "\"say \"\"hi\"\"\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zip_carries_its_manifest_and_its_hash() {
|
||||
let mut b = Builder::new();
|
||||
b.add("[email protected]/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
||||
.unwrap();
|
||||
b.add("[email protected]/mail/INBOX/b.eml".into(), b"other", "[email protected]", "email", "INBOX", None, true)
|
||||
.unwrap();
|
||||
let (bytes, items) = b.finish().unwrap();
|
||||
assert_eq!(items, 2);
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut manifest = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||
assert!(manifest.contains("[email protected]/mail/INBOX/b (2).eml"), "{manifest}");
|
||||
let mut hash = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HoldExport` (audit-hold-lock spec, LH-12): starting a collection
|
||||
//! of what a hold keeps, and seeing how it went. The ZIP is the creator's
|
||||
//! blob, to download once it's ready. Creating one is audited, with its
|
||||
//! reason (AU-1.9, AU-12).
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::hold::{self, Export, ExportStatus};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_hold_export::{
|
||||
HoldExport, HoldExportProperty as P, HoldExportSetArguments, HoldExportValue,
|
||||
},
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::str::FromStr;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, HoldExportValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::HoldId,
|
||||
P::AccountIds,
|
||||
P::Reason,
|
||||
P::Status,
|
||||
P::CreatedAt,
|
||||
P::CreatedBy,
|
||||
P::FinishedAt,
|
||||
P::BlobId,
|
||||
P::Size,
|
||||
P::Items,
|
||||
P::Sha256,
|
||||
P::Error,
|
||||
];
|
||||
|
||||
fn date(seconds: u64) -> LValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn opt_text(value: &Option<String>) -> LValue {
|
||||
value.as_ref().map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||
}
|
||||
|
||||
fn to_value(export: &Export, properties: &[P]) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(HoldExportValue::Id(Id::from(export.id))),
|
||||
P::HoldId => Value::Str(Id::from(export.hold_id).to_string().into()),
|
||||
P::AccountIds => Value::Array(
|
||||
export
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||
.collect(),
|
||||
),
|
||||
P::Reason => Value::Str(export.reason.clone().into()),
|
||||
P::Status => Value::Str(
|
||||
match export.status {
|
||||
ExportStatus::Running => "running",
|
||||
ExportStatus::Ready => "ready",
|
||||
ExportStatus::Failed => "failed",
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
P::CreatedAt => date(export.created_at),
|
||||
P::CreatedBy => Value::Str(export.created_by.clone().into()),
|
||||
P::FinishedAt => export.finished_at.map_or(Value::Null, date),
|
||||
P::BlobId => opt_text(&export.blob_id),
|
||||
P::Size => Value::Number(export.size.into()),
|
||||
P::Items => Value::Number(export.items.into()),
|
||||
P::Sha256 => opt_text(&export.sha256),
|
||||
P::Error => opt_text(&export.error),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:HoldExport/get`: every export, newest first.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
mut request: GetRequest<HoldExport>,
|
||||
) -> trc::Result<GetResponse<HoldExport>> {
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let mut exports = hold::exports(server.store()).await?;
|
||||
exports.reverse();
|
||||
match ids {
|
||||
None => response
|
||||
.list
|
||||
.extend(exports.iter().map(|e| to_value(e, &properties))),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match exports.iter().find(|e| u64::from(e.id) == id.id()) {
|
||||
Some(export) => response.list.push(to_value(export, &properties)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(why.to_string())
|
||||
}
|
||||
|
||||
/// `inbuxa:HoldExport/set`: create starts an export; nothing else is
|
||||
/// allowed. The request layer records it with its reason.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, HoldExport>,
|
||||
) -> trc::Result<SetResponse<HoldExport>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let arguments: HoldExportSetArguments = std::mem::take(&mut request.arguments);
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
'create: for (client_id, value) in request.unwrap_create() {
|
||||
let mut hold_id = None;
|
||||
let mut accounts = Vec::new();
|
||||
let mut reason = arguments.reason.clone();
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, &value) {
|
||||
(Key::Property(P::HoldId), Value::Str(id)) => {
|
||||
hold_id = Id::from_str(id).ok().and_then(|id| u32::try_from(id.id()).ok())
|
||||
}
|
||||
(Key::Property(P::AccountIds), Value::Array(items)) => {
|
||||
for item in items {
|
||||
match item {
|
||||
Value::Str(id) => match Id::from_str(id) {
|
||||
Ok(id) => accounts.push(id.document_id()),
|
||||
Err(_) => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::AccountIds, "accountIds must be account ids."),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Key::Property(P::Reason), Value::Str(r)) => reason = Some(r.to_string()),
|
||||
_ => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties().with_property(key.clone().into_owned()),
|
||||
);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(reason) = reason
|
||||
.map(|r| r.trim().chars().take(500).collect::<String>())
|
||||
.filter(|r| !r.is_empty())
|
||||
else {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::Reason, "Say why: a reason is required and is kept in the audit log."),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let hold = match hold_id {
|
||||
Some(id) => hold::get(data, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let Some(hold) = hold else {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, invalid(P::HoldId, "No such legal hold."));
|
||||
continue;
|
||||
};
|
||||
if !hold.is_active() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
invalid(P::HoldId, "That hold was released; export while a hold is in place."),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let Some(created_by_id) = actor.account_id else {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, SetError::forbidden().with_description("Sign in as a person to export."));
|
||||
continue;
|
||||
};
|
||||
accounts.sort_unstable();
|
||||
accounts.dedup();
|
||||
let export = Export {
|
||||
id: 0,
|
||||
hold_id: hold.id,
|
||||
accounts,
|
||||
reason,
|
||||
created_at: now(),
|
||||
created_by: actor.name.clone(),
|
||||
created_by_id,
|
||||
status: ExportStatus::Running,
|
||||
finished_at: None,
|
||||
blob_id: None,
|
||||
size: 0,
|
||||
items: 0,
|
||||
sha256: None,
|
||||
error: None,
|
||||
};
|
||||
let id = hold::create_export(data, &export).await?;
|
||||
let export = Export { id, ..export };
|
||||
|
||||
// The collection runs on its own; get says when it's ready
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut done = export.clone();
|
||||
match crate::inbuxa::hold_export::build(&server, &hold, &export.accounts).await {
|
||||
Ok((bytes, items)) => match server.put_jmap_blob(export.created_by_id, &bytes).await {
|
||||
Ok(blob) => {
|
||||
done.status = ExportStatus::Ready;
|
||||
done.blob_id = Some(blob.to_string());
|
||||
done.size = bytes.len() as u64;
|
||||
done.items = items as u64;
|
||||
done.sha256 = Some(
|
||||
Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
done.status = ExportStatus::Failed;
|
||||
done.error = Some(err.to_string());
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
done.status = ExportStatus::Failed;
|
||||
done.error = Some(
|
||||
err.value_as_str(trc::Key::Details)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| err.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
done.finished_at = Some(now());
|
||||
if let Err(err) = hold::update_export(server.store(), &done).await {
|
||||
trc::error!(err.details("Failed to save a legal hold export's result"));
|
||||
}
|
||||
});
|
||||
|
||||
let mut out = Map::with_capacity(1);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(HoldExportValue::Id(Id::from(id))),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
|
||||
for (id, _) in request.unwrap_update() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("An export can't be changed; start a new one."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Exports stay listed; the file expires on its own."),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:LegalHold` (audit-hold-lock spec, LH-1 to LH-14): placing,
|
||||
//! widening and releasing holds. Only server-level administrators reach
|
||||
//! 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, hold::HoldSummary};
|
||||
use inbuxa_features::hold::{self, Hold, Refusal, Release, Scope};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_legal_hold::{
|
||||
LegalHold, LegalHoldProperty as P, LegalHoldSetArguments, LegalHoldValue,
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use std::str::FromStr;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, LegalHoldValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::Name,
|
||||
P::Reference,
|
||||
P::Description,
|
||||
P::Scope,
|
||||
P::From,
|
||||
P::To,
|
||||
P::PlacedAt,
|
||||
P::PlacedBy,
|
||||
P::Released,
|
||||
P::ReleasedAt,
|
||||
P::ReleasedBy,
|
||||
P::ReleaseReason,
|
||||
];
|
||||
|
||||
/// The longest a name, reference or description may be.
|
||||
const MAX_TEXT: usize = 500;
|
||||
|
||||
fn date(seconds: u64) -> LValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn text(value: &Option<String>) -> LValue {
|
||||
value
|
||||
.as_ref()
|
||||
.map_or(Value::Null, |v| Value::Str(v.clone().into()))
|
||||
}
|
||||
|
||||
fn ids(list: &[u32]) -> LValue {
|
||||
Value::Array(
|
||||
list.iter()
|
||||
.map(|id| Value::Str(Id::from(*id).to_string().into()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
P::Id => Value::Element(LegalHoldValue::Id(Id::from(hold.id))),
|
||||
P::Name => Value::Str(hold.name.clone().into()),
|
||||
P::Reference => text(&hold.reference),
|
||||
P::Description => text(&hold.description),
|
||||
P::Scope => {
|
||||
let mut scope = Map::with_capacity(5);
|
||||
scope.insert_unchecked(Key::Borrowed("server"), Value::Bool(hold.scope.server));
|
||||
scope.insert_unchecked(Key::Borrowed("accounts"), ids(&hold.scope.accounts));
|
||||
scope.insert_unchecked(Key::Borrowed("groups"), ids(&hold.scope.groups));
|
||||
scope.insert_unchecked(Key::Borrowed("domains"), ids(&hold.scope.domains));
|
||||
scope.insert_unchecked(Key::Borrowed("tenants"), ids(&hold.scope.tenants));
|
||||
Value::Object(scope)
|
||||
}
|
||||
P::From => hold.from.map_or(Value::Null, date),
|
||||
P::To => hold.to.map_or(Value::Null, date),
|
||||
P::Reason => Value::Null,
|
||||
P::PlacedAt => date(hold.placed_at),
|
||||
P::PlacedBy => Value::Str(hold.placed_by.clone().into()),
|
||||
P::Released => Value::Bool(!hold.is_active()),
|
||||
P::ReleasedAt => hold.released.as_ref().map_or(Value::Null, |r| date(r.at)),
|
||||
P::ReleasedBy => hold
|
||||
.released
|
||||
.as_ref()
|
||||
.map_or(Value::Null, |r| Value::Str(r.by.clone().into())),
|
||||
P::ReleaseReason => hold
|
||||
.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);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:LegalHold/get`: every hold, released ones included (LH-1).
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
mut request: GetRequest<LegalHold>,
|
||||
) -> trc::Result<GetResponse<LegalHold>> {
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
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()
|
||||
};
|
||||
// LH-14: the holds on one account, whether it's live or deleted and kept
|
||||
if let Some(account) = request.arguments.covering_account.take() {
|
||||
let account_id = account.document_id();
|
||||
let covering = match server.member_of(account_id).await {
|
||||
Some(member) => hold::covering(data, &member).await?,
|
||||
None => match inbuxa_features::undelete::data::kept_account(data, account_id).await? {
|
||||
Some(kept) => {
|
||||
hold::covering(data, &common::hold::kept_member(account_id, &kept)).await?
|
||||
}
|
||||
None => Vec::new(),
|
||||
},
|
||||
};
|
||||
for current in covering {
|
||||
response
|
||||
.list
|
||||
.push(to_value(¤t, &properties, summaries.get(¤t.id)));
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
match ids {
|
||||
None => {
|
||||
for current in hold::all(data).await? {
|
||||
response
|
||||
.list
|
||||
.push(to_value(¤t, &properties, summaries.get(¤t.id)));
|
||||
}
|
||||
}
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match u32::try_from(id.id())
|
||||
.ok()
|
||||
.map(|id| hold::get(data, id))
|
||||
{
|
||||
Some(found) => match found.await? {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn reason_of(reason: Option<&str>) -> Option<String> {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty())
|
||||
.map(|r| r.chars().take(MAX_TEXT).collect())
|
||||
}
|
||||
|
||||
fn reason_required() -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Reason)
|
||||
.with_description("Say why: a reason is required and is kept in the audit log.")
|
||||
}
|
||||
|
||||
fn refused(refusal: Refusal) -> SetError<P> {
|
||||
let property = match refusal {
|
||||
Refusal::Released => P::Released,
|
||||
Refusal::Narrowed | Refusal::Backwards => P::From,
|
||||
Refusal::ScopeShrunk | Refusal::EmptyScope => P::Scope,
|
||||
};
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(refusal.describe())
|
||||
}
|
||||
|
||||
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(why.to_string())
|
||||
}
|
||||
|
||||
/// A text property: a string, trimmed and capped, or null for none.
|
||||
fn parse_text(
|
||||
property: P,
|
||||
value: &Value<'_, P, LegalHoldValue>,
|
||||
required: bool,
|
||||
) -> Result<Option<String>, SetError<P>> {
|
||||
match value {
|
||||
Value::Str(s) => {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
if required {
|
||||
Err(invalid(property, "This can't be empty."))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(Some(s.chars().take(MAX_TEXT).collect()))
|
||||
}
|
||||
}
|
||||
Value::Null if !required => Ok(None),
|
||||
_ => Err(invalid(property, "Expected text.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_date(property: P, value: &Value<'_, P, LegalHoldValue>) -> Result<Option<u64>, SetError<P>> {
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Str(s) => UTCDate::from_str(s)
|
||||
.ok()
|
||||
.map(|d| Some(d.timestamp().max(0) as u64))
|
||||
.ok_or_else(|| invalid(property, "Expected a UTC date, or null.")),
|
||||
_ => Err(invalid(property, "Expected a UTC date, or null.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a scope and checks that every account, group, domain and tenant
|
||||
/// it names exists and is the right kind (LH-1).
|
||||
async fn parse_scope(server: &Server, value: &Value<'_, P, LegalHoldValue>) -> Result<Scope, SetError<P>> {
|
||||
let Value::Object(map) = value else {
|
||||
return Err(invalid(P::Scope, "Expected an object."));
|
||||
};
|
||||
let mut scope = Scope::default();
|
||||
for (key, value) in map.iter() {
|
||||
let name: String = key.to_string().to_string();
|
||||
if name == "server" {
|
||||
match value {
|
||||
Value::Bool(b) => scope.server = *b,
|
||||
_ => return Err(invalid(P::Scope, "`server` must be true or false.")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Value::Array(items) = value else {
|
||||
return Err(invalid(P::Scope, &format!("`{name}` must be a list of ids.")));
|
||||
};
|
||||
let mut list = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let id = match item {
|
||||
Value::Str(s) => Id::from_str(s).ok(),
|
||||
Value::Element(LegalHoldValue::Id(id)) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
.and_then(|id| u32::try_from(id.id()).ok())
|
||||
.ok_or_else(|| invalid(P::Scope, &format!("`{name}` must be a list of ids.")))?;
|
||||
list.push(id);
|
||||
}
|
||||
for id in &list {
|
||||
let exists = match name.as_str() {
|
||||
"accounts" => server.account(*id).await.is_ok_and(|a| a.is_user_account()),
|
||||
"groups" => server.account(*id).await.is_ok_and(|a| !a.is_user_account()),
|
||||
"domains" => server.domain_by_id(*id).await.ok().flatten().is_some(),
|
||||
"tenants" => server.tenant(*id).await.is_ok(),
|
||||
_ => return Err(invalid(P::Scope, &format!("Unknown scope entry `{name}`."))),
|
||||
};
|
||||
if !exists {
|
||||
return Err(invalid(
|
||||
P::Scope,
|
||||
&format!("No such {} as {}.", name.trim_end_matches('s'), Id::from(*id)),
|
||||
));
|
||||
}
|
||||
}
|
||||
match name.as_str() {
|
||||
"accounts" => scope.accounts = list,
|
||||
"groups" => scope.groups = list,
|
||||
"domains" => scope.domains = list,
|
||||
_ => scope.tenants = list,
|
||||
}
|
||||
}
|
||||
Ok(scope)
|
||||
}
|
||||
|
||||
/// `inbuxa:LegalHold/set`: create places a hold; update renames it, widens
|
||||
/// its range or scope, or releases it; destroy is refused (LH-13). The
|
||||
/// request layer records each, with its reason.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, LegalHold>,
|
||||
) -> trc::Result<SetResponse<LegalHold>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let arguments: LegalHoldSetArguments = std::mem::take(&mut request.arguments);
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
'create: for (client_id, value) in request.unwrap_create() {
|
||||
let mut new = Hold {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
reference: None,
|
||||
description: None,
|
||||
scope: Scope::default(),
|
||||
from: None,
|
||||
to: None,
|
||||
placed_at: now(),
|
||||
placed_by: actor.name.clone(),
|
||||
placed_by_id: actor.account_id,
|
||||
released: None,
|
||||
};
|
||||
let mut reason = reason_of(arguments.reason.as_deref());
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let parsed = match &key {
|
||||
Key::Property(P::Name) => parse_text(P::Name, &value, true).map(|v| {
|
||||
new.name = v.unwrap_or_default();
|
||||
}),
|
||||
Key::Property(P::Reference) => {
|
||||
parse_text(P::Reference, &value, false).map(|v| new.reference = v)
|
||||
}
|
||||
Key::Property(P::Description) => {
|
||||
parse_text(P::Description, &value, false).map(|v| new.description = v)
|
||||
}
|
||||
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| new.scope = v),
|
||||
Key::Property(P::From) => parse_date(P::From, &value).map(|v| new.from = v),
|
||||
Key::Property(P::To) => parse_date(P::To, &value).map(|v| new.to = v),
|
||||
Key::Property(P::Reason) => {
|
||||
if let Value::Str(r) = &value {
|
||||
reason = reason_of(Some(r)).or(reason);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
|
||||
};
|
||||
if let Err(error) = parsed {
|
||||
response.not_created.append(client_id, error);
|
||||
continue 'create;
|
||||
}
|
||||
}
|
||||
if new.name.is_empty() {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, invalid(P::Name, "A hold needs a case name."));
|
||||
continue;
|
||||
}
|
||||
if reason.is_none() {
|
||||
response.not_created.append(client_id, reason_required());
|
||||
continue;
|
||||
}
|
||||
if let Err(refusal) = new.check_new() {
|
||||
response.not_created.append(client_id, refused(refusal));
|
||||
continue;
|
||||
}
|
||||
let id = hold::create(data, &new).await?;
|
||||
let mut out = Map::with_capacity(1);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(LegalHoldValue::Id(Id::from(id))),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
|
||||
'update: for (id, value) in request.unwrap_update().into_valid() {
|
||||
let Some(current) = (match u32::try_from(id.id()) {
|
||||
Ok(hold_id) => hold::get(data, hold_id).await?,
|
||||
Err(_) => None,
|
||||
}) else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
let Some(reason) = reason_of(arguments.reason.as_deref()) else {
|
||||
response.not_updated.append(id, reason_required());
|
||||
continue;
|
||||
};
|
||||
let mut next = current.clone();
|
||||
let mut release = false;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let parsed = match &key {
|
||||
Key::Property(P::Name) => {
|
||||
parse_text(P::Name, &value, true).map(|v| next.name = v.unwrap_or_default())
|
||||
}
|
||||
Key::Property(P::Reference) => {
|
||||
parse_text(P::Reference, &value, false).map(|v| next.reference = v)
|
||||
}
|
||||
Key::Property(P::Description) => {
|
||||
parse_text(P::Description, &value, false).map(|v| next.description = v)
|
||||
}
|
||||
Key::Property(P::Scope) => parse_scope(server, &value).await.map(|v| next.scope = v),
|
||||
Key::Property(P::From) => parse_date(P::From, &value).map(|v| next.from = v),
|
||||
Key::Property(P::To) => parse_date(P::To, &value).map(|v| next.to = v),
|
||||
Key::Property(P::Released) => match value {
|
||||
Value::Bool(true) => {
|
||||
release = true;
|
||||
Ok(())
|
||||
}
|
||||
Value::Bool(false) if current.is_active() => Ok(()),
|
||||
_ => Err(invalid(
|
||||
P::Released,
|
||||
"A released hold can't be put back; place a new one instead.",
|
||||
)),
|
||||
},
|
||||
_ => Err(SetError::invalid_properties().with_property(key.clone().into_owned())),
|
||||
};
|
||||
if let Err(error) = parsed {
|
||||
response.not_updated.append(id, error);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
if let Err(refusal) = current.check_update(&mut next) {
|
||||
response.not_updated.append(id, refused(refusal));
|
||||
continue;
|
||||
}
|
||||
if release {
|
||||
next.released = Some(Release {
|
||||
at: now(),
|
||||
by: actor.name.clone(),
|
||||
by_id: actor.account_id,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
if next != current {
|
||||
hold::update(data, &next).await?;
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
// LH-6, LH-10, LH-11: the archive follows what's now held
|
||||
if !response.created.is_empty() || !response.updated.is_empty() {
|
||||
server.settle_archive().await?;
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden()
|
||||
.with_description("A hold is never deleted. Release it, and it stays listed."),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
pub mod access;
|
||||
pub mod account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod hold_export;
|
||||
pub mod hold_export_api;
|
||||
pub mod audit;
|
||||
pub mod audit_log;
|
||||
pub mod ai_limits;
|
||||
|
||||
@@ -298,6 +298,33 @@ pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result<Registr
|
||||
|
||||
for id in std::mem::take(&mut set.destroy) {
|
||||
match undelete::records::get(data, registry, account_id, id).await? {
|
||||
// inbuxa: LH-7: a held item can't be destroyed; restoring it
|
||||
// still can. The hold is named only to those who may see holds.
|
||||
Some(item)
|
||||
if inbuxa_features::hold::is_held_until(
|
||||
item.archived_until().timestamp().max(0) as u64,
|
||||
) =>
|
||||
{
|
||||
let mut why = "A legal hold applies to this item, so it can't be deleted.".to_string();
|
||||
if set
|
||||
.access_token
|
||||
.has_permission(registry::schema::enums::Permission::SysLegalHoldGet)
|
||||
{
|
||||
let names = set
|
||||
.server
|
||||
.holds_on(account_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|hold| hold.name)
|
||||
.collect::<Vec<_>>();
|
||||
if !names.is_empty() {
|
||||
why = format!("Held by {}, so it can't be deleted.", names.join(", "));
|
||||
}
|
||||
}
|
||||
set.response
|
||||
.not_destroyed
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
}
|
||||
Some(item) => {
|
||||
undelete::records::remove(data, registry, id, &item).await?;
|
||||
set.response.destroyed.push(id);
|
||||
|
||||
@@ -1739,6 +1739,11 @@ pub enum Permission {
|
||||
SysAccountLockCreate = 666,
|
||||
SysAccountLockUpdate = 667,
|
||||
SysAccountLockDestroy = 668,
|
||||
// inbuxa: legal hold (audit-hold-lock spec, LH-13)
|
||||
SysLegalHoldGet = 669,
|
||||
SysLegalHoldCreate = 670,
|
||||
SysLegalHoldUpdate = 671,
|
||||
SysLegalHoldExport = 672,
|
||||
SysAccountGet = 219,
|
||||
SysAccountCreate = 220,
|
||||
SysAccountUpdate = 221,
|
||||
|
||||
@@ -7080,6 +7080,10 @@ impl EnumImpl for Permission {
|
||||
b"sysAccountLockCreate" => Permission::SysAccountLockCreate,
|
||||
b"sysAccountLockUpdate" => Permission::SysAccountLockUpdate,
|
||||
b"sysAccountLockDestroy" => Permission::SysAccountLockDestroy,
|
||||
b"sysLegalHoldGet" => Permission::SysLegalHoldGet,
|
||||
b"sysLegalHoldCreate" => Permission::SysLegalHoldCreate,
|
||||
b"sysLegalHoldUpdate" => Permission::SysLegalHoldUpdate,
|
||||
b"sysLegalHoldExport" => Permission::SysLegalHoldExport,
|
||||
b"sysAccountGet" => Permission::SysAccountGet,
|
||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||
@@ -7765,6 +7769,10 @@ impl EnumImpl for Permission {
|
||||
Permission::SysAccountLockCreate => "sysAccountLockCreate",
|
||||
Permission::SysAccountLockUpdate => "sysAccountLockUpdate",
|
||||
Permission::SysAccountLockDestroy => "sysAccountLockDestroy",
|
||||
Permission::SysLegalHoldGet => "sysLegalHoldGet",
|
||||
Permission::SysLegalHoldCreate => "sysLegalHoldCreate",
|
||||
Permission::SysLegalHoldUpdate => "sysLegalHoldUpdate",
|
||||
Permission::SysLegalHoldExport => "sysLegalHoldExport",
|
||||
Permission::SysAccountGet => "sysAccountGet",
|
||||
Permission::SysAccountCreate => "sysAccountCreate",
|
||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||
@@ -8443,6 +8451,10 @@ impl EnumImpl for Permission {
|
||||
666 => Some(Permission::SysAccountLockCreate),
|
||||
667 => Some(Permission::SysAccountLockUpdate),
|
||||
668 => Some(Permission::SysAccountLockDestroy),
|
||||
669 => Some(Permission::SysLegalHoldGet),
|
||||
670 => Some(Permission::SysLegalHoldCreate),
|
||||
671 => Some(Permission::SysLegalHoldUpdate),
|
||||
672 => Some(Permission::SysLegalHoldExport),
|
||||
219 => Some(Permission::SysAccountGet),
|
||||
220 => Some(Permission::SysAccountCreate),
|
||||
221 => Some(Permission::SysAccountUpdate),
|
||||
@@ -8887,7 +8899,7 @@ impl EnumImpl for Permission {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 669;
|
||||
const COUNT: usize = 673;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Permission {
|
||||
|
||||
@@ -55,6 +55,23 @@ impl DestroyAccountTask for Server {
|
||||
async fn destroy_account(server: &Server, task: &TaskDestroyAccount) -> trc::Result<TaskResult> {
|
||||
let account_id = task.account_id.document_id();
|
||||
|
||||
// inbuxa: LH-8, LH-10: a kept account waits for its time, and a held one
|
||||
// for its release; "destroy now" clears the kept record first
|
||||
if let Some(kept) =
|
||||
inbuxa_features::undelete::data::kept_account(&server.core.storage.data, account_id).await?
|
||||
{
|
||||
let now = store::write::now();
|
||||
let held = inbuxa_features::hold::is_held_until(kept.kept_until)
|
||||
|| server.is_kept_held(account_id, &kept).await?;
|
||||
if held || kept.kept_until > now {
|
||||
let retry = if held { now + 86_400 } else { kept.kept_until };
|
||||
return Ok(TaskResult::deferred(
|
||||
Some(retry),
|
||||
"The account is still kept: a legal hold applies, or its time hasn't come.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy public keys and masked emails
|
||||
for object in [ObjectType::PublicKey, ObjectType::MaskedEmail] {
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
@@ -229,11 +229,19 @@ impl SearchIndexTask for Server {
|
||||
IndexDocumentType::Email => None,
|
||||
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await
|
||||
{
|
||||
// inbuxa: LH-5: the note stays, so the retry archives
|
||||
// it; nothing a hold keeps is lost to a failure
|
||||
trc::error!(
|
||||
err.account_id(account_id)
|
||||
.document_id(document_id)
|
||||
.details("Failed to archive a deleted item")
|
||||
);
|
||||
results.push(IndexTaskResult {
|
||||
task_type: TaskType::Delete,
|
||||
index: task.document_type,
|
||||
result: TaskResult::temporary("Failed to archive a deleted item"),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
document_deletions[idx]
|
||||
@@ -696,8 +704,9 @@ pub fn trace_search_document(
|
||||
document
|
||||
}
|
||||
|
||||
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
|
||||
// deletion, when archiving is on; otherwise its note is dropped
|
||||
// inbuxa: UD-1, UD-4, LH-4: archives a deleted file, event or contact noted
|
||||
// at deletion, when archiving is on or a hold covers it; otherwise its note is
|
||||
// dropped. The note goes only once the item is archived.
|
||||
async fn archive_noted(
|
||||
server: &Server,
|
||||
kind: undelete::groupware::Kind,
|
||||
@@ -705,12 +714,22 @@ async fn archive_noted(
|
||||
document_id: u32,
|
||||
) -> trc::Result<()> {
|
||||
let data = &server.core.storage.data;
|
||||
let Some(note) = undelete::groupware::take(data, kind, account_id, document_id).await? else {
|
||||
let Some(note) = undelete::groupware::peek(data, kind, account_id, document_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(retention) = undelete::settings::retention(server.registry()).await?.items else {
|
||||
return Ok(());
|
||||
// LH-3: events by their start; contacts and files whole
|
||||
let keeping = server.keeping(account_id).await?;
|
||||
let held = match kind {
|
||||
undelete::groupware::Kind::CalendarEvent => {
|
||||
keeping.covers_event(undelete::groupware::event_start(¬e))
|
||||
}
|
||||
_ => keeping.covers(None),
|
||||
};
|
||||
let now = store::write::now();
|
||||
let Some(until) = keeping.until(now, held) else {
|
||||
return undelete::groupware::clear(data, kind, account_id, document_id).await;
|
||||
};
|
||||
let retention = until.saturating_sub(now);
|
||||
let blob_hash = match (¬e.content, ¬e.blob_hash) {
|
||||
(Some(text), _) => {
|
||||
server
|
||||
@@ -723,11 +742,11 @@ async fn archive_noted(
|
||||
.into_err()
|
||||
.details("Invalid blob hash in undelete note")
|
||||
})?,
|
||||
(None, None) => return Ok(()),
|
||||
(None, None) => return undelete::groupware::clear(data, kind, account_id, document_id).await,
|
||||
};
|
||||
undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.await?;
|
||||
undelete::groupware::clear(data, kind, account_id, document_id).await
|
||||
}
|
||||
|
||||
async fn delete_email_metadata(
|
||||
|
||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.27.1"
|
||||
"2026.9.28.1"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
SXIEex8gcOKNb6F6RKdEJLxzY-dKbdu8-DF23YN0Epc
|
||||
-jadTddv9zRK0gp8fBFYRmhwmXopxPxF2yjc86bSKRM
|
||||
@@ -86,6 +86,7 @@ dns-update = { version = "0.5", features = ["test_provider"] }
|
||||
x509-parser = "0.18"
|
||||
rcgen = "0.14"
|
||||
sha2 = "0.11"
|
||||
zip = "8.6" # inbuxa: reading legal hold exports
|
||||
time = "0.3"
|
||||
testcontainers = { version = "0.28", features = ["reusable-containers"] }
|
||||
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"] }
|
||||
|
||||
@@ -36,6 +36,9 @@ const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
"urn:ietf:params:jmap:calendars",
|
||||
"urn:ietf:params:jmap:contacts",
|
||||
"urn:ietf:params:jmap:filenode",
|
||||
"urn:inbuxa:jmap",
|
||||
];
|
||||
|
||||
@@ -179,6 +182,26 @@ pub async fn test(test: &mut TestServer) {
|
||||
assert_eq!(delegation["access"], "read", "AL-7");
|
||||
assert_eq!(delegation["sendAs"], false, "AL-7");
|
||||
|
||||
// AL-7: the whole account, not only mail: even a kind the owner holds
|
||||
// none of (no files here) reads as empty rather than refused
|
||||
for (method, arguments) in [
|
||||
("FileNode/query", json!({"accountId": owner_id})),
|
||||
("Calendar/get", json!({"accountId": owner_id, "ids": null})),
|
||||
("AddressBook/get", json!({"accountId": owner_id, "ids": null})),
|
||||
] {
|
||||
let (name, response) = delegate.call(method, arguments).await;
|
||||
assert_eq!(name, method, "AL-7: {method} refused to the delegate: {response}");
|
||||
}
|
||||
|
||||
// AL-6: reading adds nothing, not even at the top of empty Files
|
||||
let (_, response) = delegate
|
||||
.call(
|
||||
"FileNode/set",
|
||||
json!({"accountId": owner_id, "create": {"f": {"name": "Notes", "parentId": null}}}),
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("f").is_none(), "AL-6: a read delegate added a file: {response}");
|
||||
|
||||
// The delegate reads the mail that arrived
|
||||
let (_, found) = delegate
|
||||
.call(
|
||||
@@ -222,6 +245,27 @@ pub async fn test(test: &mut TestServer) {
|
||||
"AL-5: {response}"
|
||||
);
|
||||
|
||||
// AL-7: organize adds at the top of the locked account's Files, which
|
||||
// held none, and sees what it made
|
||||
let (_, response) = delegate
|
||||
.call(
|
||||
"FileNode/set",
|
||||
json!({"accountId": owner_id, "create": {"f": {"name": "Handover notes", "parentId": null}}}),
|
||||
)
|
||||
.await;
|
||||
let folder_id = response["created"]["f"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("AL-7: organize couldn't add to empty Files: {response}"))
|
||||
.to_string();
|
||||
let (_, response) = delegate
|
||||
.call("FileNode/get", json!({"accountId": owner_id, "ids": [folder_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["list"].as_array().map(Vec::len),
|
||||
Some(1),
|
||||
"AL-7: the delegate can't see the folder it made: {response}"
|
||||
);
|
||||
|
||||
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
|
||||
// never deletes it
|
||||
let (_, mailboxes) = delegate
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Legal holds, the object itself (audit-hold-lock spec, LH-1, LH-3, LH-13,
|
||||
//! AU-12): placing, widening and releasing a hold, and who may. What a hold
|
||||
//! keeps is tested with the undelete hooks.
|
||||
|
||||
use crate::utils::{
|
||||
account::Account,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant,
|
||||
UserRoles,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use types::id::Id;
|
||||
|
||||
const INBOX_ID: u32 = 0;
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:contacts",
|
||||
"urn:inbuxa:jmap",
|
||||
];
|
||||
|
||||
impl Account {
|
||||
async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) {
|
||||
if arguments.get("accountId").is_none() {
|
||||
arguments["accountId"] = self.id_string().into();
|
||||
}
|
||||
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
|
||||
let call = response
|
||||
.0
|
||||
.pointer("/methodResponses/0")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
|
||||
}
|
||||
|
||||
async fn hold_set(&self, arguments: Value) -> Value {
|
||||
let (name, response) = self.hold_call("inbuxa:LegalHold/set", arguments).await;
|
||||
assert_eq!(name, "inbuxa:LegalHold/set", "{response}");
|
||||
response
|
||||
}
|
||||
|
||||
async fn archived_items(&self) -> Vec<Value> {
|
||||
let (_, response) = self
|
||||
.hold_call("x:ArchivedItem/get", json!({"ids": null}))
|
||||
.await;
|
||||
response["list"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn hold_get(&self, id: &str) -> Value {
|
||||
let (name, response) = self
|
||||
.hold_call("inbuxa:LegalHold/get", json!({"ids": [id]}))
|
||||
.await;
|
||||
assert_eq!(name, "inbuxa:LegalHold/get", "{response}");
|
||||
response["list"][0].clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running legal hold tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let custodian = admin
|
||||
.create_user_account("[email protected]", "custodian-secret-2201", "Custodian", &[], vec![])
|
||||
.await;
|
||||
let other = admin
|
||||
.create_user_account("[email protected]", "other-secret-7310", "Other", &[], vec![])
|
||||
.await;
|
||||
let custodian_id = custodian.id_string().to_string();
|
||||
let other_id = other.id_string().to_string();
|
||||
|
||||
// AU-12: no hold without a reason; LH-1: nor without a name or a scope
|
||||
let response = admin
|
||||
.hold_set(json!({"create": {"h": {"name": "Matter 4411",
|
||||
"scope": {"accounts": [custodian_id]}}}}))
|
||||
.await;
|
||||
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "AU-12: {response}");
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||
"scope": {"accounts": [custodian_id]}}}}))
|
||||
.await;
|
||||
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 name: {response}");
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||
"name": "Matter 4411", "scope": {}}}}))
|
||||
.await;
|
||||
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-1 scope: {response}");
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||
"name": "Matter 4411", "scope": {"accounts": ["zzzzzz"]}}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["h"]["type"], "invalidProperties",
|
||||
"LH-1 unknown account: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Counsel's letter", "create": {"h": {
|
||||
"name": "Matter 4411",
|
||||
"from": "2026-06-30T00:00:00Z", "to": "2026-01-01T00:00:00Z",
|
||||
"scope": {"accounts": [custodian_id]}}}}))
|
||||
.await;
|
||||
assert_eq!(response["notCreated"]["h"]["type"], "invalidProperties", "LH-3 backwards: {response}");
|
||||
|
||||
// LH-1: placed, with a reference and a range
|
||||
let response = admin
|
||||
.hold_set(json!({"create": {"h": {
|
||||
"name": "Matter 4411", "reference": "4411-A", "reason": "Counsel's letter",
|
||||
"from": "2026-01-01T00:00:00Z", "to": "2026-06-30T23:59:59Z",
|
||||
"scope": {"accounts": [custodian_id]}}}}))
|
||||
.await;
|
||||
let hold_id = response["created"]["h"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("LH-1: not placed: {response}"))
|
||||
.to_string();
|
||||
let hold = admin.hold_get(&hold_id).await;
|
||||
assert_eq!(hold["name"], "Matter 4411", "{hold}");
|
||||
assert_eq!(hold["reference"], "4411-A", "{hold}");
|
||||
assert_eq!(hold["scope"]["accounts"], json!([custodian_id]), "{hold}");
|
||||
assert_eq!(hold["from"], "2026-01-01T00:00:00Z", "{hold}");
|
||||
assert_eq!(hold["released"], false, "{hold}");
|
||||
assert!(hold["placedBy"].as_str().is_some_and(|by| by.contains("admin")), "{hold}");
|
||||
|
||||
// AU-12: every later change needs a reason too
|
||||
let response = admin
|
||||
.hold_set(json!({"update": {hold_id.as_str(): {"name": "Renamed"}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"AU-12: {response}"
|
||||
);
|
||||
|
||||
// LH-3: narrowing is refused, widening is allowed
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Narrow it", "update": {hold_id.as_str(): {
|
||||
"from": "2026-03-01T00:00:00Z"}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"LH-3 narrowed: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Counsel widened the matter", "update": {hold_id.as_str(): {
|
||||
"from": "2025-01-01T00:00:00Z", "to": null}}}))
|
||||
.await;
|
||||
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-3 widened: {response}");
|
||||
let hold = admin.hold_get(&hold_id).await;
|
||||
assert_eq!(hold["from"], "2025-01-01T00:00:00Z", "{hold}");
|
||||
assert_eq!(hold["to"], Value::Null, "LH-3: an open end catches mail to come: {hold}");
|
||||
|
||||
// The scope grows, and never shrinks
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Second custodian", "update": {hold_id.as_str(): {
|
||||
"scope": {"accounts": [custodian_id, other_id]}}}}))
|
||||
.await;
|
||||
assert!(response["updated"].get(hold_id.as_str()).is_some(), "scope grown: {response}");
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Drop one", "update": {hold_id.as_str(): {
|
||||
"scope": {"accounts": [other_id]}}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"scope shrunk: {response}"
|
||||
);
|
||||
|
||||
// LH-13: a hold is never deleted
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Delete it", "destroy": [hold_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notDestroyed"][hold_id.as_str()]["type"], "forbidden",
|
||||
"LH-13: {response}"
|
||||
);
|
||||
|
||||
// LH-13: only server-level administrators see holds, never a plain user
|
||||
let (name, response) = custodian
|
||||
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
|
||||
.await;
|
||||
assert_eq!(name, "error", "LH-13: a user read holds: {response}");
|
||||
|
||||
// ... and never a tenant administrator, whatever its role says: a hold
|
||||
// may concern the tenant's own administrator
|
||||
let tenant = admin
|
||||
.registry_create_object(Tenant {
|
||||
name: "Hold tenant".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(Domain {
|
||||
name: "tenant-hold.example.org".to_string(),
|
||||
is_enabled: true,
|
||||
member_tenant_id: Some(tenant),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let t_admin = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"tenant-admin-secret-6604",
|
||||
"Tenant admin",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
t_admin.id(),
|
||||
json!({Property::Roles: UserRoles::Admin}),
|
||||
)
|
||||
.await;
|
||||
let (name, response) = t_admin
|
||||
.hold_call("inbuxa:LegalHold/get", json!({"ids": null}))
|
||||
.await;
|
||||
assert_eq!(name, "error", "LH-13: a tenant administrator read holds: {response}");
|
||||
let (name, response) = t_admin
|
||||
.hold_call(
|
||||
"inbuxa:LegalHold/set",
|
||||
json!({"reason": "Mine", "create": {"h": {"name": "Tenant matter",
|
||||
"scope": {"accounts": [t_admin.id_string()]}}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error", "LH-13: a tenant administrator placed a hold: {response}");
|
||||
|
||||
// Test 7, LH-2: a hold on a domain reaches an account created there
|
||||
// later, and keeps it by name when it moves to another domain
|
||||
let held_domain = admin
|
||||
.registry_create_object(Domain {
|
||||
name: "held.example.net".to_string(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let elsewhere = admin
|
||||
.registry_create_object(Domain {
|
||||
name: "elsewhere.example.net".to_string(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Whole division", "create": {"d": {
|
||||
"name": "Matter 5120", "scope": {"domains": [held_domain.to_string()]}}}}))
|
||||
.await;
|
||||
let domain_hold = response["created"]["d"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("LH-1 domain hold: {response}"))
|
||||
.to_string();
|
||||
let mover = admin
|
||||
.create_user_account("[email protected]", "mover-secret-8812", "Mover", &[], vec![])
|
||||
.await;
|
||||
assert_eq!(
|
||||
admin.hold_get(&domain_hold).await["scope"]["accounts"],
|
||||
json!([]),
|
||||
"LH-2: covered through the domain, not named yet"
|
||||
);
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
mover.id(),
|
||||
json!({Property::DomainId: elsewhere.to_string()}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
admin.hold_get(&domain_hold).await["scope"]["accounts"],
|
||||
json!([mover.id_string()]),
|
||||
"test 7, LH-2: the moved account escaped the hold"
|
||||
);
|
||||
|
||||
// Test 6, LH-4: what a hold keeps, with undelete switched off, so only
|
||||
// the hold can be keeping anything
|
||||
admin
|
||||
.registry_update_setting(
|
||||
DataRetention {
|
||||
archive_deleted_items_for: None,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::ArchiveDeletedItemsFor],
|
||||
)
|
||||
.await;
|
||||
let held = admin
|
||||
.create_user_account("[email protected]", "held-secret-4419", "Held", &[], vec![])
|
||||
.await;
|
||||
let ranged = admin
|
||||
.create_user_account("[email protected]", "ranged-secret-5530", "Ranged", &[], vec![])
|
||||
.await;
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Preserve everything", "create": {
|
||||
"w": {"name": "Matter 6001", "scope": {"accounts": [held.id_string()]}},
|
||||
"r": {"name": "Matter 6002", "from": "2020-01-01T00:00:00Z", "to": "2020-12-31T23:59:59Z",
|
||||
"scope": {"accounts": [ranged.id_string()]}}}}))
|
||||
.await;
|
||||
let whole_hold = response["created"]["w"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("LH-1: {response}"))
|
||||
.to_string();
|
||||
assert!(response["created"]["r"]["id"].is_string(), "LH-1: {response}");
|
||||
|
||||
let held_client = held.jmap_client().await;
|
||||
let ranged_client = ranged.jmap_client().await;
|
||||
let whole = import(&held_client, "Held whole", None).await;
|
||||
held_client.email_destroy(&whole).await.unwrap();
|
||||
// 2020-03-15: inside the range; now: outside it
|
||||
let inside = import(&ranged_client, "Inside the range", Some(1_584_230_400)).await;
|
||||
let outside = import(&ranged_client, "Outside the range", None).await;
|
||||
ranged_client.email_destroy(&inside).await.unwrap();
|
||||
ranged_client.email_destroy(&outside).await.unwrap();
|
||||
|
||||
// LH-3: a contact is held whole, whatever the range
|
||||
let (_, books) = ranged
|
||||
.hold_call("AddressBook/get", json!({"ids": null}))
|
||||
.await;
|
||||
let book = books["list"][0]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("no address book: {books}"))
|
||||
.to_string();
|
||||
{
|
||||
let (_, created) = ranged
|
||||
.hold_call(
|
||||
"ContactCard/set",
|
||||
json!({"create": {"c": {"addressBookIds": {book: true},
|
||||
"name": {"full": "Kept Contact"}}}}),
|
||||
)
|
||||
.await;
|
||||
let card = created["created"]["c"]["id"].as_str().unwrap_or_default().to_string();
|
||||
let (_, destroyed) = ranged
|
||||
.hold_call("ContactCard/set", json!({"destroy": [card]}))
|
||||
.await;
|
||||
assert!(destroyed["destroyed"][0].is_string(), "{destroyed}");
|
||||
}
|
||||
test.wait_for_tasks().await;
|
||||
|
||||
let is_held = |item: &Value| item["archivedUntil"].as_str().is_some_and(|u| u.starts_with("9999-"));
|
||||
let kept = held.archived_items().await;
|
||||
assert!(
|
||||
kept.iter().any(|i| i["subject"] == "Held whole" && is_held(i)),
|
||||
"test 6, LH-4: a held account's mail wasn't kept: {kept:?}"
|
||||
);
|
||||
let kept = ranged.archived_items().await;
|
||||
assert!(
|
||||
kept.iter().any(|i| i["subject"] == "Inside the range" && is_held(i)),
|
||||
"LH-3: mail inside the range wasn't kept: {kept:?}"
|
||||
);
|
||||
assert!(
|
||||
!kept.iter().any(|i| i["subject"] == "Outside the range"),
|
||||
"LH-3: mail outside the range was kept, with undelete off: {kept:?}"
|
||||
);
|
||||
assert!(
|
||||
kept.iter().any(|i| i["name"] == "Kept Contact" && is_held(i)),
|
||||
"LH-3: a contact wasn't kept whole: {kept:?}"
|
||||
);
|
||||
|
||||
// LH-6: placing a hold freezes what's already archived; LH-7: frozen
|
||||
// items can't be destroyed; LH-11: releasing one hold of two frees
|
||||
// nothing; LH-10: releasing the last gives a real deadline back
|
||||
admin
|
||||
.registry_update_setting(
|
||||
DataRetention {
|
||||
archive_deleted_items_for: Some(registry::schema::prelude::Duration(
|
||||
std::time::Duration::from_secs(30 * 86_400),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::ArchiveDeletedItemsFor],
|
||||
)
|
||||
.await;
|
||||
let frozen = admin
|
||||
.create_user_account("[email protected]", "frozen-secret-9031", "Frozen", &[], vec![])
|
||||
.await;
|
||||
let frozen_client = frozen.jmap_client().await;
|
||||
let doomed = import(&frozen_client, "Deleted before the hold", None).await;
|
||||
frozen_client.email_destroy(&doomed).await.unwrap();
|
||||
test.wait_for_tasks().await;
|
||||
let archived = |items: Vec<Value>| {
|
||||
items
|
||||
.into_iter()
|
||||
.find(|i| i["subject"] == "Deleted before the hold")
|
||||
.unwrap_or_else(|| panic!("not archived"))
|
||||
};
|
||||
let item = archived(frozen.archived_items().await);
|
||||
assert!(!is_held(&item), "undelete's 30 days first: {item}");
|
||||
let item_id = item["id"].as_str().unwrap().to_string();
|
||||
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "First matter", "create": {
|
||||
"a": {"name": "Matter 7001", "scope": {"accounts": [frozen.id_string()]}},
|
||||
"b": {"name": "Matter 7002", "scope": {"accounts": [frozen.id_string()]}}}}))
|
||||
.await;
|
||||
let first = response["created"]["a"]["id"].as_str().unwrap().to_string();
|
||||
let second = response["created"]["b"]["id"].as_str().unwrap().to_string();
|
||||
assert!(
|
||||
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}");
|
||||
// LH-14: the holds on one account, for the console's Held badge
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"inbuxa:LegalHold/get",
|
||||
json!({"coveringAccount": frozen.id_string(), "properties": ["name"]}),
|
||||
)
|
||||
.await;
|
||||
let mut names = response["list"]
|
||||
.as_array()
|
||||
.map(|l| l.iter().filter_map(|h| h["name"].as_str()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, vec!["Matter 7001", "Matter 7002"], "LH-14: {response}");
|
||||
|
||||
// LH-12: collect what the hold keeps, as a ZIP with its manifest
|
||||
import(&frozen_client, "Still in the inbox", None).await;
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"inbuxa:HoldExport/set",
|
||||
json!({"create": {"x": {"holdId": first, "accountIds": [frozen.id_string()]}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||
"AU-12: an export without a reason: {response}"
|
||||
);
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"inbuxa:HoldExport/set",
|
||||
json!({"reason": "Production to opposing counsel",
|
||||
"create": {"x": {"holdId": first,
|
||||
"accountIds": [frozen.id_string(), admin.id_string()]}}}),
|
||||
)
|
||||
.await;
|
||||
let export_id = response["created"]["x"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("LH-12: not started: {response}"))
|
||||
.to_string();
|
||||
let mut export = Value::Null;
|
||||
for _ in 0..60 {
|
||||
let (_, got) = admin
|
||||
.hold_call("inbuxa:HoldExport/get", json!({"ids": [export_id]}))
|
||||
.await;
|
||||
export = got["list"][0].clone();
|
||||
if export["status"] != "running" {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
assert_eq!(export["status"], "ready", "LH-12: {export}");
|
||||
let bytes = admin
|
||||
.jmap_client()
|
||||
.await
|
||||
.download(export["blobId"].as_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(export["size"].as_u64(), Some(bytes.len() as u64), "{export}");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
names.iter().any(|n| n.starts_with("[email protected]/mail/") && n.ends_with(".eml")),
|
||||
"LH-12: live mail missing: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.iter().any(|n| n.starts_with("[email protected]/archived/email/")),
|
||||
"LH-12: the kept deleted mail is missing: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.")),
|
||||
"LH-12: an account the hold doesn't cover was exported: {names:?}"
|
||||
);
|
||||
let mut manifest = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.csv").unwrap(), &mut manifest).unwrap();
|
||||
let mut hash = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||
use sha2::Digest;
|
||||
let expected: String = sha2::Sha256::digest(manifest.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
|
||||
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
|
||||
// LH-13: only sysLegalHoldExport starts one
|
||||
let (_, response) = frozen
|
||||
.hold_call(
|
||||
"inbuxa:HoldExport/set",
|
||||
json!({"reason": "Mine", "create": {"x": {"holdId": first}}}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response.to_string().contains("forbidden") && response["created"].is_null(),
|
||||
"LH-13: a user exported a hold: {response}"
|
||||
);
|
||||
|
||||
let (_, response) = frozen
|
||||
.hold_call("x:ArchivedItem/set", json!({"destroy": [item_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notDestroyed"][item_id.as_str()]["type"], "forbidden",
|
||||
"test 6, LH-7: the owner destroyed a held item: {response}"
|
||||
);
|
||||
assert!(
|
||||
!response.to_string().contains("Matter 70"),
|
||||
"LH-7: the hold was named to someone who can't see holds: {response}"
|
||||
);
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"x:ArchivedItem/set",
|
||||
json!({"accountId": frozen.id_string(), "destroy": [item_id]}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response.to_string().contains("Matter 7001"),
|
||||
"LH-7: the administrator isn't told which hold: {response}"
|
||||
);
|
||||
|
||||
admin
|
||||
.hold_set(json!({"reason": "First settled", "update": {first.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
assert!(
|
||||
is_held(&archived(frozen.archived_items().await)),
|
||||
"test 9, LH-11: releasing one hold of two freed the item"
|
||||
);
|
||||
admin
|
||||
.hold_set(json!({"reason": "Second settled", "update": {second.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
let item = archived(frozen.archived_items().await);
|
||||
assert!(!is_held(&item), "LH-10: the last release left it held: {item}");
|
||||
let (_, response) = admin
|
||||
.hold_call(
|
||||
"inbuxa:HoldExport/set",
|
||||
json!({"reason": "Too late", "create": {"x": {"holdId": first}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["x"]["type"], "invalidProperties",
|
||||
"LH-12: a released hold was exported: {response}"
|
||||
);
|
||||
let until = item["archivedUntil"].as_str().unwrap_or_default().to_string();
|
||||
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||
assert!(
|
||||
until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"test 8, LH-10: under 30 days of grace after release: {until}"
|
||||
);
|
||||
|
||||
// Test 8, LH-8: a held account destroyed as a login is kept, data and
|
||||
// all, with no expiry, although undelete keeps no accounts here
|
||||
let held_id = held.id_string().to_string();
|
||||
admin.destroy_account(held).await;
|
||||
let kept = |list: Value| {
|
||||
list["list"]
|
||||
.as_array()
|
||||
.and_then(|l| l.iter().find(|a| a["id"] == held_id.as_str()).cloned())
|
||||
};
|
||||
let (_, list) = admin
|
||||
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
|
||||
.await;
|
||||
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-8: not kept: {list}"));
|
||||
assert!(
|
||||
entry["keptUntil"].as_str().is_some_and(|u| u.starts_with("9999-")),
|
||||
"test 8, LH-8: kept with an expiry: {entry}"
|
||||
);
|
||||
let (_, response) = admin
|
||||
.hold_call("inbuxa:DeletedAccount/set", json!({"destroy": [held_id]}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notDestroyed"][held_id.as_str()]["type"], "forbidden",
|
||||
"test 8, LH-8: destroy-now wasn't refused: {response}"
|
||||
);
|
||||
// Its hold names it now, so no domain or tenant move can drop it
|
||||
assert!(
|
||||
admin.hold_get(&whole_hold).await["scope"]["accounts"]
|
||||
.as_array()
|
||||
.is_some_and(|a| a.iter().any(|id| id == held_id.as_str())),
|
||||
"LH-8: the hold doesn't name the deleted account"
|
||||
);
|
||||
// Release: the data is destroyed 30 days later, not before
|
||||
admin
|
||||
.hold_set(json!({"reason": "Matter closed", "update": {whole_hold.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
let (_, list) = admin
|
||||
.hold_call("inbuxa:DeletedAccount/get", json!({"ids": null}))
|
||||
.await;
|
||||
let entry = kept(list.clone()).unwrap_or_else(|| panic!("test 8, LH-10: gone at release: {list}"));
|
||||
let until = entry["keptUntil"].as_str().unwrap_or_default().to_string();
|
||||
let grace = chrono::Utc::now() + chrono::Duration::days(29);
|
||||
assert!(
|
||||
!until.starts_with("9999-") && until > grace.format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
"test 8, LH-10: after release, not 30 days of grace: {until}"
|
||||
);
|
||||
|
||||
// LH-10: release needs a reason, and a released hold stays, read-only
|
||||
let response = admin
|
||||
.hold_set(json!({"update": {hold_id.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"AU-12 release: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Matter settled", "update": {hold_id.as_str(): {"released": true}}}))
|
||||
.await;
|
||||
assert!(response["updated"].get(hold_id.as_str()).is_some(), "LH-10: {response}");
|
||||
let hold = admin.hold_get(&hold_id).await;
|
||||
assert_eq!(hold["released"], true, "{hold}");
|
||||
assert_eq!(hold["releaseReason"], "Matter settled", "{hold}");
|
||||
assert!(hold["releasedAt"].is_string(), "{hold}");
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Rename", "update": {hold_id.as_str(): {"name": "After"}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"LH-1: a released hold changed: {response}"
|
||||
);
|
||||
let response = admin
|
||||
.hold_set(json!({"reason": "Undo", "update": {hold_id.as_str(): {"released": false}}}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notUpdated"][hold_id.as_str()]["type"], "invalidProperties",
|
||||
"LH-10: a released hold came back: {response}"
|
||||
);
|
||||
|
||||
// AU-12: placing, widening and releasing are recorded with their reasons
|
||||
let (_, query) = admin
|
||||
.hold_call(
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"filter": {"targetKind": "inbuxa:LegalHold"}}),
|
||||
)
|
||||
.await;
|
||||
let ids = query["ids"].clone();
|
||||
let (_, records) = admin
|
||||
.hold_call("inbuxa:AuditEvent/get", json!({"ids": ids}))
|
||||
.await;
|
||||
let reasons = records["list"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("AU-12: no records: {records}"))
|
||||
.iter()
|
||||
.filter_map(|r| r["reason"].as_str())
|
||||
.collect::<Vec<_>>();
|
||||
for reason in ["Counsel's letter", "Counsel widened the matter", "Matter settled"] {
|
||||
assert!(reasons.contains(&reason), "AU-12: {reason:?} not recorded: {reasons:?}");
|
||||
}
|
||||
// AU-1.9: an export is recorded with its reason
|
||||
let (_, query) = admin
|
||||
.hold_call(
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"filter": {"targetKind": "inbuxa:HoldExport"}}),
|
||||
)
|
||||
.await;
|
||||
let (_, exports) = admin
|
||||
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
|
||||
.await;
|
||||
assert!(
|
||||
exports["list"]
|
||||
.as_array()
|
||||
.is_some_and(|l| l.iter().any(|r| r["reason"] == "Production to opposing counsel")),
|
||||
"AU-1.9: the export isn't recorded: {exports}"
|
||||
);
|
||||
// A release is recorded under the hold's name, from before to after
|
||||
let list = records["list"].as_array().cloned().unwrap_or_default();
|
||||
let release = list
|
||||
.iter()
|
||||
.find(|r| r["reason"] == "First settled")
|
||||
.unwrap_or_else(|| panic!("the first release isn't recorded: {list:?}"));
|
||||
assert_eq!(release["target"]["name"], "Matter 7001", "{release}");
|
||||
assert!(
|
||||
release["changes"]
|
||||
.as_array()
|
||||
.is_some_and(|c| c.iter().any(|c| c["field"] == "released" && c["before"] == false && c["after"] == true)),
|
||||
"the release doesn't read before/after: {release}"
|
||||
);
|
||||
|
||||
// Accounts are named by their full address, not the bare local part
|
||||
let (_, query) = admin
|
||||
.hold_call("inbuxa:AuditEvent/query", json!({"filter": {"targetKind": "x:Account"}}))
|
||||
.await;
|
||||
let (_, accounts) = admin
|
||||
.hold_call("inbuxa:AuditEvent/get", json!({"ids": query["ids"].clone()}))
|
||||
.await;
|
||||
assert!(
|
||||
accounts["list"]
|
||||
.as_array()
|
||||
.is_some_and(|l| l.iter().any(|r| r["target"]["name"] == "[email protected]")),
|
||||
"an account isn't named by its address: {accounts}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn import(
|
||||
client: &jmap_client::client::Client,
|
||||
subject: &str,
|
||||
received_at: Option<i64>,
|
||||
) -> String {
|
||||
client
|
||||
.email_import(
|
||||
format!("From: [email protected]\r\nSubject: {subject}\r\n\r\nBody.\r\n").into_bytes(),
|
||||
[Id::from(INBOX_ID).to_string()],
|
||||
None::<Vec<&str>>,
|
||||
received_at,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id()
|
||||
}
|
||||
|
||||
/// Runs these tests alone: `cargo test -p tests legal_hold_tests -- --ignored`.
|
||||
#[ignore]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn legal_hold_tests() {
|
||||
let mut test = TestServerBuilder::new("legal_hold_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.create_admin_account("[email protected]").await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod ai;
|
||||
pub mod ai_calibration;
|
||||
pub mod ai_explain;
|
||||
pub mod account_lock; // inbuxa: account lock with delegation
|
||||
pub mod legal_hold; // inbuxa: legal hold
|
||||
pub mod audit; // inbuxa: the audit log
|
||||
pub mod authorization;
|
||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||
|
||||
Reference in New Issue
Block a user