Legal holds (phase 3) #70
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!())?;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(¬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) {
|
let blob_hash = match (¬e.content, ¬e.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(
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user