Legal holds (phase 3) #70

Merged
jcoffey-dev merged 8 commits from feature/legal-hold into main 2026-09-28 03:00:43 +00:00
11 changed files with 350 additions and 42 deletions
Showing only changes of commit 7b97efbb7f - Show all commits
+10 -1
View File
@@ -10,7 +10,7 @@
//! there are few holds. //! there are few holds.
use crate::Server; use crate::Server;
use inbuxa_features::hold::{self, Hold, Member}; use inbuxa_features::hold::{self, Hold, Keeping, Member};
impl Server { impl Server {
/// The active holds covering `account_id`, through its own name, its /// The active holds covering `account_id`, through its own name, its
@@ -36,6 +36,15 @@ impl Server {
hold::covering(self.store(), &member).await 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?))
}
/// Whether any active hold covers `account_id` at all. /// Whether any active hold covers `account_id` at all.
pub async fn is_held(&self, account_id: u32) -> trc::Result<bool> { pub async fn is_held(&self, account_id: u32) -> trc::Result<bool> {
Ok(!self.holds_on(account_id).await?.is_empty()) Ok(!self.holds_on(account_id).await?.is_empty())
+4 -6
View File
@@ -92,10 +92,8 @@ impl MailboxDestroy for Server {
let mut deleted_ids = RoaringBitmap::new(); let mut deleted_ids = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let retention = inbuxa_features::undelete::settings::retention(self.registry()) let keeping = self.keeping(account_id).await?;
.await?
.items;
self.archives( self.archives(
account_id, account_id,
Collection::Email, Collection::Email,
@@ -125,10 +123,10 @@ impl MailboxDestroy for Server {
deleted_ids.insert(message_id); deleted_ids.insert(message_id);
thread_ids.insert(prev_message_data.inner.thread_id.to_native()); thread_ids.insert(prev_message_data.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving // 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( inbuxa_features::undelete::email::note(
&mut batch, &mut batch,
retention, &keeping,
account_id, account_id,
message_id, message_id,
prev_message_data.inner.size.to_native() as u64, prev_message_data.inner.size.to_native() as u64,
+4 -6
View File
@@ -69,10 +69,8 @@ impl EmailDeletion for Server {
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::Email); .with_collection(Collection::Email);
// inbuxa: UD-1, UD-6a: the retention in force now // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let retention = inbuxa_features::undelete::settings::retention(self.registry()) let keeping = self.keeping(account_id).await?;
.await?
.items;
self.archives( self.archives(
account_id, account_id,
Collection::Email, Collection::Email,
@@ -90,10 +88,10 @@ impl EmailDeletion for Server {
} }
thread_ids.insert(metadata.inner.thread_id.to_native()); thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving // 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( inbuxa_features::undelete::email::note(
batch, batch,
retention, &keeping,
account_id, account_id,
document_id, document_id,
metadata.inner.size.to_native() as u64, metadata.inner.size.to_native() as u64,
+6 -6
View File
@@ -44,12 +44,12 @@ impl SieveScriptDelete for Server {
)) ))
.await? .await?
{ {
// inbuxa: UD-1: a deleted script is kept, when archiving is on // inbuxa: UD-1, LH-4: a deleted script is kept, when archiving
if let Some(retention) = // is on or a hold covers the account (whole: scripts have no date)
inbuxa_features::undelete::settings::retention(self.registry()) let keeping = self.keeping(account_id).await?;
.await? let now = store::write::now();
.items if let Some(until) = keeping.until(now, keeping.is_held()) {
{ let retention = until.saturating_sub(now);
let script = obj_ let script = obj_
.deserialize::<SieveScript>() .deserialize::<SieveScript>()
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
+100
View File
@@ -26,6 +26,85 @@ use store::{
}; };
use trc::AddContext; 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 FEATURE: u8 = b'H';
const KIND_HOLD: u8 = b'h'; const KIND_HOLD: u8 = b'h';
@@ -511,6 +590,27 @@ mod tests {
assert!(held.scope.covers(&member) && !held.scope.covers(&moved)); 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] #[test]
fn stored_as_json() { fn stored_as_json() {
let current = hold(accounts(&[2]), Some(100), None); let current = hold(accounts(&[2]), Some(100), None);
+8
View File
@@ -123,6 +123,14 @@ pub struct EmailNote {
pub size: u64, pub size: u64,
pub mailboxes: Vec<u32>, pub mailboxes: Vec<u32>,
pub keywords: Vec<String>, 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). /// What restore needs beyond the kept copy (UD-4, UD-8).
+37 -7
View File
@@ -12,9 +12,12 @@
//! is made if archiving is on, fixing the deadline then. When the data is //! is made if archiving is on, fixing the deadline then. When the data is
//! finally removed, a noted message becomes an archived item. //! finally removed, a noted message becomes an archived item.
use crate::undelete::{ use crate::{
data::{self, EmailNote, Extra}, hold::Keeping,
records, undelete::{
data::{self, EmailNote, Extra},
records,
},
}; };
use registry::{ use registry::{
schema::structs::{ArchivedEmail, ArchivedItem}, schema::structs::{ArchivedEmail, ArchivedItem},
@@ -26,10 +29,12 @@ use store::{
}; };
use types::{blob::BlobId, blob_hash::BlobHash}; 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( pub fn note(
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
retention: u64, keeping: &Keeping,
account_id: u32, account_id: u32,
document_id: u32, document_id: u32,
size: u64, size: u64,
@@ -37,16 +42,23 @@ pub fn note(
keywords: Vec<String>, keywords: Vec<String>,
) -> trc::Result<()> { ) -> trc::Result<()> {
let archived_at = now(); 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( data::note_email(
batch, batch,
account_id, account_id,
document_id, document_id,
&EmailNote { &EmailNote {
archived_at, archived_at,
archived_until: archived_at + retention, archived_until,
size, size,
mailboxes, mailboxes,
keywords, 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, document_id: u32,
summary: Summary<'_>, summary: Summary<'_>,
) -> trc::Result<bool> { ) -> 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); 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 { let item = ArchivedItem::Email(ArchivedEmail {
from: summary.from.unwrap_or_default().to_string(), from: summary.from.unwrap_or_default().to_string(),
subject: summary.subject.unwrap_or_default().to_string(), subject: summary.subject.unwrap_or_default().to_string(),
+33
View File
@@ -97,6 +97,39 @@ pub async fn take(
Ok(Some(note)) 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 /// The value of the first line starting with `name` (as `NAME:` or
/// `NAME;params:`) in iCalendar or vCard text, unfolded. /// `NAME;params:`) in iCalendar or vCard text, unfolded.
fn property(text: &str, name: &str) -> Option<String> { fn property(text: &str, name: &str) -> Option<String> {
+4 -6
View File
@@ -243,10 +243,8 @@ impl<T: SessionStream> SessionData<T> {
let mut fully_deleted = RoaringBitmap::new(); let mut fully_deleted = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now // inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let retention = inbuxa_features::undelete::settings::retention(self.server.registry()) let keeping = self.server.keeping(account_id).await?;
.await?
.items;
self.server self.server
.archives( .archives(
account_id, account_id,
@@ -270,10 +268,10 @@ impl<T: SessionStream> SessionData<T> {
fully_deleted.insert(document_id); fully_deleted.insert(document_id);
thread_ids.insert(metadata.inner.thread_id.to_native()); thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving // 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( inbuxa_features::undelete::email::note(
batch, batch,
retention, &keeping,
account_id, account_id,
document_id, document_id,
metadata.inner.size.to_native() as u64, metadata.inner.size.to_native() as u64,
+27 -8
View File
@@ -229,11 +229,19 @@ impl SearchIndexTask for Server {
IndexDocumentType::Email => None, IndexDocumentType::Email => None,
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await } && 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!( trc::error!(
err.account_id(account_id) err.account_id(account_id)
.document_id(document_id) .document_id(document_id)
.details("Failed to archive a deleted item") .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] document_deletions[idx]
@@ -696,8 +704,9 @@ pub fn trace_search_document(
document document
} }
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at // inbuxa: UD-1, UD-4, LH-4: archives a deleted file, event or contact noted
// deletion, when archiving is on; otherwise its note is dropped // 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( async fn archive_noted(
server: &Server, server: &Server,
kind: undelete::groupware::Kind, kind: undelete::groupware::Kind,
@@ -705,12 +714,22 @@ async fn archive_noted(
document_id: u32, document_id: u32,
) -> trc::Result<()> { ) -> trc::Result<()> {
let data = &server.core.storage.data; 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(()); return Ok(());
}; };
let Some(retention) = undelete::settings::retention(server.registry()).await?.items else { // LH-3: events by their start; contacts and files whole
return Ok(()); let keeping = server.keeping(account_id).await?;
let held = match kind {
undelete::groupware::Kind::CalendarEvent => {
keeping.covers_event(undelete::groupware::event_start(&note))
}
_ => 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 (&note.content, &note.blob_hash) { let blob_hash = match (&note.content, &note.blob_hash) {
(Some(text), _) => { (Some(text), _) => {
server server
@@ -723,11 +742,11 @@ async fn archive_noted(
.into_err() .into_err()
.details("Invalid blob hash in undelete note") .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) undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
.await .await?;
.map(|_| ()) undelete::groupware::clear(data, kind, account_id, document_id).await
} }
async fn delete_email_metadata( async fn delete_email_metadata(
+117 -2
View File
@@ -14,11 +14,22 @@ use crate::utils::{
}; };
use registry::schema::{ use registry::schema::{
prelude::{ObjectType, Property}, prelude::{ObjectType, Property},
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, structs::{
CertificateManagement, DataRetention, DkimManagement, DnsManagement, Domain, Tenant,
UserRoles,
},
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use types::id::Id;
const USING: &[&str] = &["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; 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 { impl Account {
async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) { async fn hold_call(&self, method: &str, mut arguments: Value) -> (String, Value) {
@@ -38,6 +49,13 @@ impl Account {
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 { async fn hold_get(&self, id: &str) -> Value {
let (name, response) = self let (name, response) = self
.hold_call("inbuxa:LegalHold/get", json!({"ids": [id]})) .hold_call("inbuxa:LegalHold/get", json!({"ids": [id]}))
@@ -266,6 +284,86 @@ pub async fn test(test: &mut TestServer) {
"test 7, LH-2: the moved account escaped the hold" "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;
assert!(response["created"]["w"]["id"].is_string(), "LH-1: {response}");
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-10: release needs a reason, and a released hold stays, read-only // LH-10: release needs a reason, and a released hold stays, read-only
let response = admin let response = admin
.hold_set(json!({"update": {hold_id.as_str(): {"released": true}}})) .hold_set(json!({"update": {hold_id.as_str(): {"released": true}}}))
@@ -319,6 +417,23 @@ pub async fn test(test: &mut TestServer) {
} }
} }
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`. /// Runs these tests alone: `cargo test -p tests legal_hold_tests -- --ignored`.
#[ignore] #[ignore]
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]