diff --git a/Cargo.lock b/Cargo.lock index 726f254..396def3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4258,6 +4258,7 @@ dependencies = [ "tungstenite 0.30.0", "types", "utils", + "zip", ] [[package]] @@ -8624,6 +8625,7 @@ dependencies = [ "types", "utils", "x509-parser", + "zip", ] [[package]] diff --git a/crates/features/src/hold/mod.rs b/crates/features/src/hold/mod.rs index cdee60f..fa1aaf6 100644 --- a/crates/features/src/hold/mod.rs +++ b/crates/features/src/hold/mod.rs @@ -108,6 +108,45 @@ impl Keeping { 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, + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blob_id: Option, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub items: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} /// How many times creating a hold retries when another node took its id. const CREATE_ATTEMPTS: usize = 5; @@ -402,6 +441,70 @@ pub async fn set_original_deadline(data: &Store, item_id: u64, until: Option 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> { + 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::::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 { + 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> { Ok(data diff --git a/crates/jmap-proto/src/object/inbuxa_hold_export.rs b/crates/jmap-proto/src/object/inbuxa_hold_export.rs new file mode 100644 index 0000000..5057dff --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_hold_export.rs @@ -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 { + 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 { + 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 { + HoldExportProperty::parse(s).ok_or(()) + } +} + +impl Element for HoldExportValue { + type Property = HoldExportProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + 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, +} + +impl<'de> DeserializeArguments<'de> for HoldExportSetArguments { + fn deserialize_argument(&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::()?; + } + 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 for HoldExportValue { + fn from(id: Id) -> Self { + HoldExportValue::Id(id) + } +} + +impl JmapObjectId for HoldExportValue { + fn as_id(&self) -> Option { + match self { + HoldExportValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + 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 { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index c9c537d..02a357a 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -25,6 +25,7 @@ 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 diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 0462c89..3b99b1f 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -73,6 +73,9 @@ impl Response<'_> { 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) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index ca391d9..84a34c7 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -50,6 +50,7 @@ impl Response<'_> { 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)? @@ -115,6 +116,9 @@ impl Response<'_> { 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)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index d23af91..24b7753 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -60,6 +60,7 @@ pub enum MethodObject { AccountLock, // inbuxa: legal hold LegalHold, + HoldExport, ProtocolPolicy, TenantProtocolPolicy, } @@ -94,7 +95,8 @@ impl MethodObject { | MethodObject::AuditExport | MethodObject::AuditVerification | MethodObject::AccountLock - | MethodObject::LegalHold => Capability::Inbuxa, + | MethodObject::LegalHold + | MethodObject::HoldExport => Capability::Inbuxa, MethodObject::ProtocolPolicy => Capability::Inbuxa, MethodObject::TenantProtocolPolicy => Capability::Inbuxa, } @@ -284,6 +286,8 @@ impl MethodName { (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" } @@ -430,6 +434,8 @@ impl MethodName { "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), @@ -495,6 +501,7 @@ impl Display for MethodObject { 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) => { diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 6f24964..66ff11a 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -120,6 +120,7 @@ pub enum GetRequestMethod { AuditSettings(Box>), AccountLock(Box>), LegalHold(Box>), + HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, @@ -153,6 +154,7 @@ pub enum SetRequestMethod<'x> { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + HoldExport(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( Box>, diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 8de167e..ac4c5ef 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -566,6 +566,21 @@ 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)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index b340c5e..e13cebd 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -107,6 +107,7 @@ pub enum GetResponseMethod { AuditSettings(GetResponse), AccountLock(GetResponse), LegalHold(GetResponse), + HoldExport(GetResponse), ProtocolPolicy(GetResponse), TenantProtocolPolicy( GetResponse, @@ -140,6 +141,7 @@ pub enum SetResponseMethod { AuditVerification(Box>), AccountLock(Box>), LegalHold(Box>), + HoldExport(Box>), Explanation(Box>), ProtocolPolicy(Box>), TenantProtocolPolicy( @@ -780,3 +782,16 @@ impl<'x> From> for Resp ResponseMethod::Set(SetResponseMethod::LegalHold(Box::new(value))) } } + +// inbuxa: legal hold exports +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::HoldExport(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::HoldExport(Box::new(value))) + } +} diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 67154d6..dde3e3b 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -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" diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 520f909..dce0434 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -97,6 +97,7 @@ 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, @@ -232,6 +233,14 @@ impl JmapAuthorization for AccessToken { 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, @@ -380,6 +389,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::AuditVerification | MethodObject::AccountLock | MethodObject::LegalHold + | MethodObject::HoldExport | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges, // inbuxa: x:MaskedEmail/changes reads what /get reads diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index a347b86..8347e6d 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -276,6 +276,9 @@ impl RequestHandler for Server { 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); } @@ -450,6 +453,11 @@ impl RequestHandler for Server { .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() @@ -807,6 +815,34 @@ impl RequestHandler for Server { } // 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(|| { diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 7707627..9928c8b 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -425,6 +425,7 @@ impl IntermediateChangesResponse { | MethodObject::AuditVerification | MethodObject::AccountLock | MethodObject::LegalHold + | MethodObject::HoldExport | MethodObject::ProtocolPolicy | MethodObject::TenantProtocolPolicy | MethodObject::Registry(_) => unreachable!(), diff --git a/crates/jmap/src/inbuxa/hold_export.rs b/crates/jmap/src/inbuxa/hold_export.rs new file mode 100644 index 0000000..be359b5 --- /dev/null +++ b/crates/jmap/src/inbuxa/hold_export.rs @@ -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, + 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) -> String { + at.map(|at| jmap_proto::types::date::UTCDate::from_timestamp(at).to_string()) + .unwrap_or_default() +} + +struct Builder { + zip: ZipWriter>>, + entries: Vec, + 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, + 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, 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> { + let mut covered = Vec::new(); + for id in server + .registry() + .query::>(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>> { + 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, 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::>(ValueKey::property( + account_id, + Collection::Email, + message.document_id, + EmailField::Metadata, + )) + .await? + else { + continue; + }; + let metadata = metadata_ + .unarchive::() + .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::>().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::>().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::>(ValueKey::archive( + account_id, + Collection::CalendarEvent, + resource.document_id, + )) + .await? + else { + continue; + }; + let event = event_.unarchive::().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::>(ValueKey::archive( + account_id, + Collection::ContactCard, + resource.document_id, + )) + .await? + else { + continue; + }; + let card = card_.unarchive::().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::>(ValueKey::archive( + account_id, + Collection::FileNode, + resource.document_id, + )) + .await? + else { + continue; + }; + let file = file_.unarchive::().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("a@example.com/mail/INBOX/b.eml".into(), b"Subject: x\r\n\r\ny", "a@example.com", "email", "INBOX", Some(0), false) + .unwrap(); + b.add("a@example.com/mail/INBOX/b.eml".into(), b"other", "a@example.com", "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("a@example.com/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())))); + } +} diff --git a/crates/jmap/src/inbuxa/hold_export_api.rs b/crates/jmap/src/inbuxa/hold_export_api.rs new file mode 100644 index 0000000..fda3390 --- /dev/null +++ b/crates/jmap/src/inbuxa/hold_export_api.rs @@ -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) -> 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, +) -> trc::Result> { + 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

{ + 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> { + 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::()) + .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) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 249aee8..b1e984f 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -10,6 +10,8 @@ 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; diff --git a/tests/Cargo.toml b/tests/Cargo.toml index f561122..e47b1e9 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -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"] } diff --git a/tests/src/system/legal_hold.rs b/tests/src/system/legal_hold.rs index cd1d523..621fa45 100644 --- a/tests/src/system/legal_hold.rs +++ b/tests/src/system/legal_hold.rs @@ -436,6 +436,90 @@ pub async fn test(test: &mut TestServer) { 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::>(); + assert!( + names.iter().any(|n| n.starts_with("frozen@example.com/mail/") && n.ends_with(".eml")), + "LH-12: live mail missing: {names:?}" + ); + assert!( + names.iter().any(|n| n.starts_with("frozen@example.com/archived/email/")), + "LH-12: the kept deleted mail is missing: {names:?}" + ); + assert!( + names + .iter() + .all(|n| n.starts_with("frozen@example.com/") || 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; @@ -470,6 +554,16 @@ pub async fn test(test: &mut TestServer) { .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!( @@ -574,6 +668,22 @@ pub async fn test(test: &mut TestServer) { 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