Files
inbuxa-server/crates/features/src/undelete/email.rs
T
jcoffey-dev 7b97efbb7f Legal holds, step 3: deleted items in a held account are kept
Every way of deleting mail (JMAP, IMAP EXPUNGE, POP3, mailbox removal,
Trash emptying) and Sieve scripts, events, contacts and files now asks
how the account's deletions are kept: a hold keeps them with no expiry
(archivedUntil 9999-12-31), even with undelete off; otherwise undelete's
period applies as before (LH-4).

A hold's date range decides by the item's own date (LH-3). Mail is noted
as held at deletion and settled when it's archived, once its received
date is known; outside the range it gets undelete's deadline or isn't
kept. Events go by their start, with a day's slack for time zones;
recurring events, contacts, files and scripts are held whole.

A groupware item's note now stays until its archive succeeds, and a
failure retries the task instead of being logged and lost (LH-5).
2026-09-27 19:33:07 -07:00

208 lines
6.3 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Deleted email (UD-1, UD-4, UD-5).
//!
//! Every way of deleting mail for good (JMAP, IMAP, POP3, Trash emptying,
//! mailbox removal) ends by scheduling the message's data for removal. At the
//! deletion itself, while its mailboxes and keywords are still known, a note
//! is made if archiving is on, fixing the deadline then. When the data is
//! finally removed, a noted message becomes an archived item.
use crate::{
hold::Keeping,
undelete::{
data::{self, EmailNote, Extra},
records,
},
};
use registry::{
schema::structs::{ArchivedEmail, ArchivedItem},
types::datetime::UTCDateTime,
};
use store::{
RegistryStore, Store,
write::{BatchBuilder, now},
};
use types::{blob::BlobId, blob_hash::BlobHash};
/// Notes a deleted message, when anything keeps it: undelete, or a legal
/// hold on the account (LH-4). A held note keeps it until it's archived,
/// when its received date says whether the hold's range covers it.
pub fn note(
batch: &mut BatchBuilder,
keeping: &Keeping,
account_id: u32,
document_id: u32,
size: u64,
mailboxes: Vec<u32>,
keywords: Vec<String>,
) -> trc::Result<()> {
let archived_at = now();
// Held until the date is known; the undelete deadline otherwise
let otherwise_until = keeping.until(archived_at, false);
let Some(archived_until) = keeping.until(archived_at, keeping.is_held()) else {
return Ok(());
};
data::note_email(
batch,
account_id,
document_id,
&EmailNote {
archived_at,
archived_until,
size,
mailboxes,
keywords,
held_ranges: keeping.ranges.clone(),
otherwise_until: if keeping.is_held() { otherwise_until } else { None },
},
)
}
/// The keywords a restored message gets back: all it had, except
/// `$deleted`, which would only have it expunged again.
pub fn keywords_to_keep(keywords: impl IntoIterator<Item = String>) -> Vec<String> {
keywords
.into_iter()
.filter(|keyword| !keyword.eq_ignore_ascii_case("$deleted"))
.collect()
}
/// What the message's stored summary says, for the archived record.
pub struct Summary<'x> {
pub blob_hash: BlobHash,
pub from: Option<&'x str>,
pub subject: Option<&'x str>,
pub received_at: u64,
}
/// A message's data is being removed: if it was noted at deletion, it
/// becomes an archived item, and its kept copy is held until the deadline.
/// Returns whether it was archived.
pub async fn archive(
data: &Store,
registry: &RegistryStore,
account_id: u32,
document_id: u32,
summary: Summary<'_>,
) -> trc::Result<bool> {
let Some(mut note) = data::email_note(data, account_id, document_id).await? else {
return Ok(false);
};
// LH-3: a held note's range decides now that the date is known; outside
// it, undelete's deadline, or nothing kept at all
if !note.held_ranges.is_empty() {
let keeping = Keeping {
retention: None,
ranges: std::mem::take(&mut note.held_ranges),
};
if !keeping.covers(Some(summary.received_at)) {
match note.otherwise_until {
Some(until) => note.archived_until = until,
None => {
let mut batch = BatchBuilder::new();
data::clear_email_note(&mut batch, account_id, document_id);
return data.write(batch.build_all()).await.map(|_| false);
}
}
}
}
let item = ArchivedItem::Email(ArchivedEmail {
from: summary.from.unwrap_or_default().to_string(),
subject: summary.subject.unwrap_or_default().to_string(),
received_at: UTCDateTime::from_timestamp(summary.received_at as i64),
size: note.size,
account_id: types::id::Id::from(account_id),
archived_at: UTCDateTime::from_timestamp(note.archived_at as i64),
archived_until: UTCDateTime::from_timestamp(note.archived_until as i64),
blob_id: BlobId::new(summary.blob_hash, Default::default()),
});
records::insert(
data,
registry,
&item,
&Extra::Email {
mailboxes: note.mailboxes,
keywords: note.keywords,
},
)
.await?;
let mut batch = BatchBuilder::new();
data::clear_email_note(&mut batch, account_id, document_id);
data.write(batch.build_all()).await.map(|_| true)
}
/// Where a restored message goes (UD-8): back into the mailboxes it was in
/// that still exist. Trash only if Trash is all it was in; otherwise the
/// others. Into `inbox` if none are left.
pub fn restore_mailboxes(
original: &[u32],
exists: impl Fn(u32) -> bool,
inbox: u32,
trash: u32,
) -> Vec<u32> {
let only_trash = original.len() == 1 && original[0] == trash;
let mailboxes = original
.iter()
.copied()
.filter(|id| exists(*id) && (only_trash || *id != trash))
.collect::<Vec<_>>();
if mailboxes.is_empty() {
vec![inbox]
} else {
mailboxes
}
}
#[cfg(test)]
mod tests {
use super::*;
const INBOX: u32 = 0;
const TRASH: u32 = 1;
#[test]
fn back_where_it_was() {
// Acceptance test 4: both labels come back
assert_eq!(
restore_mailboxes(&[0, 7], |_| true, INBOX, TRASH),
vec![0, 7]
);
// Acceptance test 5: mailboxes gone, so Inbox
assert_eq!(
restore_mailboxes(&[7, 8], |_| false, INBOX, TRASH),
vec![INBOX]
);
assert_eq!(
restore_mailboxes(&[7, 8], |id| id == 8, INBOX, TRASH),
vec![8]
);
}
#[test]
fn trash_only_if_that_was_all() {
assert_eq!(
restore_mailboxes(&[TRASH], |_| true, INBOX, TRASH),
vec![TRASH]
);
assert_eq!(
restore_mailboxes(&[TRASH, 7], |_| true, INBOX, TRASH),
vec![7]
);
assert_eq!(restore_mailboxes(&[], |_| true, INBOX, TRASH), vec![INBOX]);
}
#[test]
fn deleted_keyword_isnt_kept() {
assert_eq!(
keywords_to_keep(["$seen".to_string(), "$Deleted".to_string()]),
vec!["$seen".to_string()]
);
}
}