inbuxa:HoldExport/set takes a hold, optionally some of the accounts it covers, and a reason; the collection runs in the background and get says when it's ready. The ZIP has, per account, mail as .eml under its folders, calendars as .ics, contacts as .vcf, files as stored, and the archived items the hold keeps under archived/; a manifest.csv gives each entry's account, kind, folder, date, whether it was archived, size and SHA-256, and manifest.sha256 hashes the manifest. Accounts the hold doesn't cover are left out, and items outside its date range are too: live mail by arrival, events by start, and archived items the same way, so an export doesn't carry deleted items that only another hold keeps. The finished file is a blob of whoever started the export, so only they download it, and it lasts as long as any upload (uploadTtl). Exports are records under the hold (SUBSPACE_INBUXA H/e): never changed or destroyed, each with its status, counts, size and checksum. Starting one needs sysLegalHoldExport, an active hold and a reason, and is recorded in the audit log like the audit log's own export. The build is in memory and capped at 2 GB; bigger holds fail with a message saying so, and are split by picking accounts. Tested: unit tests for safe ZIP names and the manifest and its hash; the legal_hold system test, on RocksDB, PostgreSQL and MySQL, exports a hold end to end (live and archived mail, the manifest's hash, an asked- for account the hold doesn't cover left out) and checks the refusals (no reason, a user without the permission, a released hold) and the audit record; and by hand from the console on a local server. Not covered by a test: the archived-item date range with two holds of different ranges over one account.
484 lines
20 KiB
Rust
484 lines
20 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
use crate::{
|
|
error::set::SetError,
|
|
method::{
|
|
copy::CopyRequest,
|
|
get::GetRequest,
|
|
import::ImportEmailRequest,
|
|
parse::ParseRequest,
|
|
search_snippet::GetSearchSnippetRequest,
|
|
set::{SetRequest, SetResponse},
|
|
upload::{BlobUploadRequest, DataSourceObject},
|
|
},
|
|
object::{AnyId, JmapObject, JmapObjectId},
|
|
references::{Graph, eval::EvalObjectReferences, topological_sort},
|
|
request::{
|
|
CopyRequestMethod, GetRequestMethod, MaybeInvalid, ParseRequestMethod, RequestMethod,
|
|
SetRequestMethod,
|
|
reference::{MaybeIdReference, MaybeResultReference},
|
|
},
|
|
response::Response,
|
|
};
|
|
use compact_str::format_compact;
|
|
use jmap_tools::{Element, Key, Property, Value};
|
|
use std::collections::HashMap;
|
|
use types::id::Id;
|
|
|
|
impl Response<'_> {
|
|
pub fn resolve_references(&self, request: &mut RequestMethod) -> trc::Result<()> {
|
|
match request {
|
|
RequestMethod::Get(request) => match request {
|
|
GetRequestMethod::Email(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Mailbox(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Thread(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Identity(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::EmailSubmission(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::PushSubscription(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Sieve(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::VacationResponse(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
|
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)?
|
|
}
|
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::AddressBook(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::ContactCard(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::FileNode(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::ShareNotification(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::Calendar(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::CalendarEvent(request) => request.resolve_references(self)?,
|
|
GetRequestMethod::CalendarEventNotification(request) => {
|
|
request.resolve_references(self)?
|
|
}
|
|
GetRequestMethod::ParticipantIdentity(request) => {
|
|
request.resolve_references(self)?
|
|
}
|
|
GetRequestMethod::PrincipalAvailability(_) => (),
|
|
GetRequestMethod::Registry(request) => request.resolve_references(self)?,
|
|
},
|
|
RequestMethod::Set(request) => match request {
|
|
SetRequestMethod::Email(request) => request.resolve_references(self, 2, false)?,
|
|
SetRequestMethod::Mailbox(request) => request.resolve_references(self, 1, false)?,
|
|
SetRequestMethod::Identity(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::EmailSubmission(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::PushSubscription(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::Sieve(request) => request.resolve_references(self, 1, false)?,
|
|
SetRequestMethod::VacationResponse(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::MaskedEmail(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::DeletedAccount(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::AiLimits(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::Explanation(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::AuditSettings(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::AuditExport(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::AuditVerification(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
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)?
|
|
}
|
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::AddressBook(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::ContactCard(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::FileNode(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::ShareNotification(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::Calendar(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::CalendarEvent(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::CalendarEventNotification(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::ParticipantIdentity(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
SetRequestMethod::Registry(request) => request.resolve_references(self, 5, true)?,
|
|
},
|
|
RequestMethod::Copy(request) => match request {
|
|
CopyRequestMethod::Email(request) => request.resolve_references(self, 1, false)?,
|
|
CopyRequestMethod::CalendarEvent(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
CopyRequestMethod::ContactCard(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
CopyRequestMethod::FileNode(request) => {
|
|
request.resolve_references(self, 1, false)?
|
|
}
|
|
CopyRequestMethod::Blob(_) => (),
|
|
},
|
|
RequestMethod::ImportEmail(request) => request.resolve_references(self)?,
|
|
RequestMethod::SearchSnippet(request) => request.resolve_references(self)?,
|
|
RequestMethod::UploadBlob(request) => request.resolve_references(self)?,
|
|
RequestMethod::Parse(request) => match request {
|
|
ParseRequestMethod::Email(request) => request.resolve_references(self)?,
|
|
ParseRequestMethod::ContactCard(request) => request.resolve_references(self)?,
|
|
ParseRequestMethod::CalendarEvent(request) => request.resolve_references(self)?,
|
|
},
|
|
_ => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub trait ResolveCreatedReference<P, E>
|
|
where
|
|
P: Property,
|
|
E: Element<Property = P> + JmapObjectId,
|
|
{
|
|
fn get_created_id(&self, id_ref: &str) -> Option<AnyId>;
|
|
|
|
fn resolve_self_references(
|
|
&self,
|
|
value: &mut Value<'_, P, E>,
|
|
depth: usize,
|
|
eval_strings: bool,
|
|
) -> Result<(), SetError<P>> {
|
|
match value {
|
|
Value::Object(obj) if eval_strings && depth < 5 => {
|
|
for (key, value) in obj.as_mut_vec() {
|
|
if let Some(id) = key
|
|
.as_string_key()
|
|
.and_then(|k| k.strip_prefix('#'))
|
|
.and_then(|id_ref| self.get_created_id(id_ref))
|
|
{
|
|
*key = Key::Owned(match id {
|
|
AnyId::Id(id) => id.to_string(),
|
|
AnyId::BlobId(id) => id.to_string(),
|
|
});
|
|
}
|
|
|
|
if matches!(value, Value::Object(_) | Value::Array(_)) {
|
|
self.resolve_self_references(value, depth + 1, eval_strings)?;
|
|
}
|
|
}
|
|
}
|
|
Value::Element(element) => {
|
|
if let Some(id_ref) = element.as_id_ref() {
|
|
if let Some(id) = self.get_created_id(id_ref) {
|
|
if !element.try_set_id(id) {
|
|
return Err(SetError::invalid_properties()
|
|
.with_description("Id reference points to invalid type."));
|
|
}
|
|
} else {
|
|
return Err(SetError::not_found()
|
|
.with_description(format!("Id reference {id_ref:?} not found.")));
|
|
}
|
|
}
|
|
}
|
|
Value::Array(items) if depth < 5 => {
|
|
for item in items {
|
|
self.resolve_self_references(item, depth + 1, eval_strings)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub(crate) trait ResolveReference {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()>;
|
|
}
|
|
|
|
pub(crate) trait ResolveSetReference {
|
|
fn resolve_references(
|
|
&mut self,
|
|
response: &Response<'_>,
|
|
max_depth: usize,
|
|
eval_strings: bool,
|
|
) -> trc::Result<()>;
|
|
}
|
|
|
|
impl<T: JmapObject> ResolveReference for GetRequest<T> {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
|
// Resolve id references
|
|
match &mut self.ids {
|
|
Some(MaybeResultReference::Reference(reference)) => {
|
|
self.ids = Some(MaybeResultReference::Value(
|
|
response
|
|
.eval_result_references(reference)?
|
|
.into_ids::<T::Id>(reference)
|
|
.map(|f| f.map(MaybeIdReference::Id))
|
|
.collect::<Result<_, _>>()?,
|
|
));
|
|
}
|
|
Some(MaybeResultReference::Value(ids)) => {
|
|
for id in ids {
|
|
if let MaybeIdReference::Reference(reference) = id {
|
|
if let Some(resolved_id) = response
|
|
.created_ids
|
|
.get(reference)
|
|
.cloned()
|
|
.and_then(|v| T::Id::try_from(v).ok())
|
|
{
|
|
*id = MaybeIdReference::Id(resolved_id);
|
|
} else {
|
|
return Err(trc::JmapEvent::InvalidResultReference.into_err().details(
|
|
format_compact!(
|
|
"Id reference {reference:?} does not exist or is invalid."
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
// Resolve properties references
|
|
if let Some(MaybeResultReference::Reference(reference)) = &self.properties {
|
|
self.properties = Some(MaybeResultReference::Value(
|
|
response
|
|
.eval_result_references(reference)?
|
|
.into_properties::<T::Property>(reference)
|
|
.map(|f| f.map(MaybeInvalid::Value))
|
|
.collect::<Result<_, _>>()?,
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<'x, T: JmapObject> ResolveSetReference for SetRequest<'x, T> {
|
|
fn resolve_references(
|
|
&mut self,
|
|
response: &Response<'_>,
|
|
max_depth: usize,
|
|
eval_strings: bool,
|
|
) -> trc::Result<()> {
|
|
// Resolve create references
|
|
if let Some(create) = &mut self.create {
|
|
let mut graph = HashMap::with_capacity(create.len());
|
|
for (id, obj) in create.iter_mut() {
|
|
obj.eval_object_references(
|
|
response,
|
|
&mut Graph::Some {
|
|
child_id: &*id,
|
|
graph: &mut graph,
|
|
},
|
|
0,
|
|
max_depth,
|
|
eval_strings,
|
|
)?;
|
|
}
|
|
|
|
// Perform topological sort
|
|
if !graph.is_empty() {
|
|
self.create = topological_sort(create, graph)?.into();
|
|
}
|
|
}
|
|
|
|
// Resolve update references
|
|
if let Some(update) = &mut self.update {
|
|
for obj in update.values_mut() {
|
|
obj.eval_object_references(response, &mut Graph::None, 0, max_depth, eval_strings)?;
|
|
}
|
|
}
|
|
|
|
// Resolve destroy references
|
|
if let Some(MaybeResultReference::Reference(reference)) = &self.destroy {
|
|
self.destroy = Some(MaybeResultReference::Value(
|
|
response
|
|
.eval_result_references(reference)?
|
|
.into_ids::<Id>(reference)
|
|
.map(|f| f.map(MaybeInvalid::Value))
|
|
.collect::<Result<_, _>>()?,
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<'x, T: JmapObject> ResolveSetReference for CopyRequest<'x, T> {
|
|
fn resolve_references(
|
|
&mut self,
|
|
response: &Response<'_>,
|
|
max_depth: usize,
|
|
eval_strings: bool,
|
|
) -> trc::Result<()> {
|
|
// Resolve create references
|
|
for (id, obj) in self.create.iter_mut() {
|
|
obj.eval_object_references(response, &mut Graph::None, 0, max_depth, eval_strings)?;
|
|
|
|
if let MaybeIdReference::Reference(ir) = id {
|
|
*id = MaybeIdReference::Id(response.eval_id_reference(ir)?);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<T: JmapObject> ResolveReference for ParseRequest<T> {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
|
// Resolve blobId references
|
|
for id in self.blob_ids.iter_mut() {
|
|
if let MaybeIdReference::Reference(ir) = id {
|
|
*id = MaybeIdReference::Id(response.eval_blob_id_reference(ir)?);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ResolveReference for ImportEmailRequest {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
|
// Resolve email mailbox references
|
|
for email in self.emails.values_mut() {
|
|
match &mut email.mailbox_ids {
|
|
MaybeResultReference::Reference(reference) => {
|
|
email.mailbox_ids = MaybeResultReference::Value(
|
|
response
|
|
.eval_result_references(reference)?
|
|
.into_ids::<Id>(reference)
|
|
.map(|f| f.map(MaybeIdReference::Id))
|
|
.collect::<Result<_, _>>()?,
|
|
);
|
|
}
|
|
MaybeResultReference::Value(values) => {
|
|
for value in values {
|
|
if let MaybeIdReference::Reference(ir) = value {
|
|
*value = MaybeIdReference::Id(response.eval_id_reference(ir)?);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ResolveReference for GetSearchSnippetRequest {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
|
// Resolve emailIds references
|
|
if let MaybeResultReference::Reference(reference) = &self.email_ids {
|
|
self.email_ids = MaybeResultReference::Value(
|
|
response
|
|
.eval_result_references(reference)?
|
|
.into_ids::<Id>(reference)
|
|
.map(|f| f.map(MaybeInvalid::Value))
|
|
.collect::<Result<_, _>>()?,
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl ResolveReference for BlobUploadRequest {
|
|
fn resolve_references(&mut self, response: &Response<'_>) -> trc::Result<()> {
|
|
let mut graph = HashMap::with_capacity(self.create.len());
|
|
for (create_id, object) in self.create.iter_mut() {
|
|
for data in &mut object.data {
|
|
if let DataSourceObject::Id { id, .. } = data
|
|
&& let MaybeIdReference::Reference(parent_id) = id
|
|
{
|
|
match response.created_ids.get(parent_id) {
|
|
Some(AnyId::BlobId(blob_id)) => {
|
|
*id = MaybeIdReference::Id(blob_id.clone());
|
|
}
|
|
Some(_) => {
|
|
return Err(trc::JmapEvent::InvalidResultReference.into_err().details(
|
|
format_compact!(
|
|
"Id reference {parent_id:?} points to invalid type."
|
|
),
|
|
));
|
|
}
|
|
None => {
|
|
graph
|
|
.entry(create_id.to_string())
|
|
.or_insert_with(Vec::new)
|
|
.push(parent_id.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Perform topological sort
|
|
if !graph.is_empty() {
|
|
self.create = topological_sort(&mut self.create, graph)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<T> ResolveCreatedReference<T::Property, T::Element> for SetResponse<T>
|
|
where
|
|
T: JmapObject,
|
|
{
|
|
fn get_created_id(&self, id_ref: &str) -> Option<AnyId> {
|
|
self.created
|
|
.get(id_ref)
|
|
.and_then(|v| v.as_object())
|
|
.and_then(|v| v.get(&Key::Property(T::ID_PROPERTY)))
|
|
.and_then(|v| v.as_element())
|
|
.and_then(|v| v.as_any_id())
|
|
}
|
|
}
|