diff --git a/Cargo.lock b/Cargo.lock index 8fe9d72..4b98a36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3896,6 +3896,7 @@ dependencies = [ "directory", "email", "imap_proto", + "inbuxa-features", "mail-parser", "md5", "nlp", @@ -3964,6 +3965,8 @@ dependencies = [ "ahash", "jmap_proto", "registry", + "serde", + "serde_json", "store", "tokio", "trc", diff --git a/crates/email/src/mailbox/destroy.rs b/crates/email/src/mailbox/destroy.rs index aac155a..150cd64 100644 --- a/crates/email/src/mailbox/destroy.rs +++ b/crates/email/src/mailbox/destroy.rs @@ -90,6 +90,10 @@ impl MailboxDestroy for Server { let mut deleted_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new(); + // inbuxa: UD-1, UD-6a: the retention in force now + let retention = inbuxa_features::undelete::settings::retention(self.registry()) + .await? + .items; self.archives( account_id, Collection::Email, @@ -118,6 +122,20 @@ impl MailboxDestroy for Server { } deleted_ids.insert(message_id); thread_ids.insert(prev_message_data.inner.thread_id.to_native()); + // inbuxa: UD-1, UD-4: a deleted message is noted for archiving + if let Some(retention) = retention { + inbuxa_features::undelete::email::note( + &mut batch, + retention, + account_id, + message_id, + prev_message_data.inner.size.to_native() as u64, + prev_message_data.inner.mailboxes.iter().map(|m| m.mailbox_id.to_native()).collect(), + inbuxa_features::undelete::email::keywords_to_keep( + prev_message_data.inner.keywords.iter().map(|k| k.to_string()), + ), + )?; + } batch .with_collection(Collection::Email) .with_document(message_id) diff --git a/crates/email/src/message/delete.rs b/crates/email/src/message/delete.rs index 6e437d0..a7b3e4a 100644 --- a/crates/email/src/message/delete.rs +++ b/crates/email/src/message/delete.rs @@ -67,6 +67,10 @@ impl EmailDeletion for Server { batch .with_account_id(account_id) .with_collection(Collection::Email); + // inbuxa: UD-1, UD-6a: the retention in force now + let retention = inbuxa_features::undelete::settings::retention(self.registry()) + .await? + .items; self.archives( account_id, Collection::Email, @@ -83,6 +87,20 @@ impl EmailDeletion for Server { ); } thread_ids.insert(metadata.inner.thread_id.to_native()); + // inbuxa: UD-1, UD-4: a deleted message is noted for archiving + if let Some(retention) = retention { + inbuxa_features::undelete::email::note( + batch, + retention, + account_id, + document_id, + metadata.inner.size.to_native() as u64, + metadata.inner.mailboxes.iter().map(|m| m.mailbox_id.to_native()).collect(), + inbuxa_features::undelete::email::keywords_to_keep( + metadata.inner.keywords.iter().map(|k| k.to_string()), + ), + )?; + } batch .with_document(document_id) .custom( diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index 27f1ca7..def9be7 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -13,6 +13,8 @@ trc = { path = "../trc" } types = { path = "../types" } utils = { path = "../utils" } ahash = { version = "0.8.12", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [dev-dependencies] tokio = { version = "1.53", features = ["macros", "rt"] } diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 7f3a94e..356aebc 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -20,3 +20,4 @@ pub mod masked_email; pub mod tenancy; +pub mod undelete; diff --git a/crates/features/src/undelete/data.rs b/crates/features/src/undelete/data.rs new file mode 100644 index 0000000..5ad9559 --- /dev/null +++ b/crates/features/src/undelete/data.rs @@ -0,0 +1,413 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What undelete keeps in the fork's own subspace (`store::SUBSPACE_INBUXA`). +//! Every key starts with `U`, then one byte for the kind: +//! +//! - `n` + account + document: a deleted email waiting for its archive +//! record, with what only the deletion knows (mailboxes, keywords, size) +//! and the deadline fixed then (UD-4, UD-5). +//! - `x` + item id: what restoring an archived item needs beyond the kept +//! copy (UD-4, UD-8). +//! - `b` + account + blob hash: the item a kept copy belongs to, since the +//! restore task names only the blob. +//! - `r` + item id: present while a restore is asked for (UD-11). +//! - `c` + account + change id: one change to the account's archive, for +//! `/changes`. +//! - `k` + account id: a deleted account kept for its period (UD-15a). +//! - `a` + address: an address a kept account holds reserved (UD-16). +//! +//! Values are JSON, so they read back across versions of the fork. + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; +use types::id::Id; + +const FEATURE: u8 = b'U'; +const KIND_NOTE: u8 = b'n'; +const KIND_EXTRA: u8 = b'x'; +const KIND_BLOB: u8 = b'b'; +const KIND_RESTORE: u8 = b'r'; +const KIND_CHANGE: u8 = b'c'; +const KIND_KEPT: u8 = b'k'; +const KIND_RESERVED: u8 = b'a'; + +fn class(kind: u8, rest: &[u8]) -> ValueClass { + let mut key = Vec::with_capacity(2 + rest.len()); + key.push(FEATURE); + key.push(kind); + key.extend_from_slice(rest); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn key(kind: u8, rest: &[u8]) -> ValueKey { + ValueKey::from(class(kind, rest)) +} + +/// A value stored as JSON. +pub struct Json(pub T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize undelete record") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid undelete record") + .reason(err) + }) + } +} + +async fn get( + data: &Store, + key: ValueKey, +) -> trc::Result> { + data.get_value::>(key) + .await + .map(|value| value.map(|Json(value)| value)) + .caused_by(trc::location!()) +} + +fn set( + batch: &mut BatchBuilder, + class: ValueClass, + value: &T, +) -> trc::Result<()> { + batch.set(class, Json(value).serialize()?); + Ok(()) +} + +fn account_document(account_id: u32, document_id: u32) -> [u8; 8] { + let mut out = [0u8; 8]; + out[..4].copy_from_slice(&account_id.to_be_bytes()); + out[4..].copy_from_slice(&document_id.to_be_bytes()); + out +} + +fn account_blob(account_id: u32, blob_hash: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + blob_hash.len()); + out.extend_from_slice(&account_id.to_be_bytes()); + out.extend_from_slice(blob_hash); + out +} + +/// A deleted email, noted at deletion for the archive record made when its +/// data is finally removed. +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +pub struct EmailNote { + /// When it was deleted, as a Unix timestamp. + pub archived_at: u64, + /// When the kept copy goes, fixed at deletion (UD-5). + pub archived_until: u64, + pub size: u64, + pub mailboxes: Vec, + pub keywords: Vec, +} + +/// What restore needs beyond the kept copy (UD-4, UD-8). +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(tag = "kind")] +pub enum Extra { + Email { + mailboxes: Vec, + keywords: Vec, + }, + FileNode { + parent_id: Option, + name: String, + media_type: Option, + }, + CalendarEvent { + calendar_ids: Vec, + name: String, + }, + ContactCard { + address_book_ids: Vec, + name: String, + }, + SieveScript { + name: String, + }, +} + +/// A deleted account, kept for its period (UD-15a). +#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +pub struct KeptAccount { + /// The `x:Account` record as it was, pickled. + pub record: Vec, + pub name: String, + pub addresses: Vec, + pub member_tenant_id: Option, + pub deleted_at: u64, + pub kept_until: u64, +} + +pub fn note_email( + batch: &mut BatchBuilder, + account_id: u32, + document_id: u32, + note: &EmailNote, +) -> trc::Result<()> { + set( + batch, + class(KIND_NOTE, &account_document(account_id, document_id)), + note, + ) +} + +pub async fn email_note( + data: &Store, + account_id: u32, + document_id: u32, +) -> trc::Result> { + get( + data, + key(KIND_NOTE, &account_document(account_id, document_id)), + ) + .await +} + +pub fn clear_email_note(batch: &mut BatchBuilder, account_id: u32, document_id: u32) { + batch.clear(class(KIND_NOTE, &account_document(account_id, document_id))); +} + +pub fn set_extra(batch: &mut BatchBuilder, item_id: Id, extra: &Extra) -> trc::Result<()> { + set(batch, class(KIND_EXTRA, &item_id.id().to_be_bytes()), extra) +} + +pub async fn extra(data: &Store, item_id: Id) -> trc::Result> { + get(data, key(KIND_EXTRA, &item_id.id().to_be_bytes())).await +} + +pub fn clear_extra(batch: &mut BatchBuilder, item_id: Id) { + batch.clear(class(KIND_EXTRA, &item_id.id().to_be_bytes())); +} + +pub fn set_blob_item(batch: &mut BatchBuilder, account_id: u32, blob_hash: &[u8], item_id: Id) { + batch.set( + class(KIND_BLOB, &account_blob(account_id, blob_hash)), + item_id.id().to_be_bytes().to_vec(), + ); +} + +pub async fn blob_item(data: &Store, account_id: u32, blob_hash: &[u8]) -> trc::Result> { + data.get_value::(key(KIND_BLOB, &account_blob(account_id, blob_hash))) + .await + .map(|id| id.map(Id::new)) + .caused_by(trc::location!()) +} + +pub fn clear_blob_item(batch: &mut BatchBuilder, account_id: u32, blob_hash: &[u8]) { + batch.clear(class(KIND_BLOB, &account_blob(account_id, blob_hash))); +} + +pub fn set_restore_requested(batch: &mut BatchBuilder, item_id: Id) { + batch.set(class(KIND_RESTORE, &item_id.id().to_be_bytes()), vec![1]); +} + +pub async fn is_restore_requested(data: &Store, item_id: Id) -> trc::Result { + data.key_exists(key(KIND_RESTORE, &item_id.id().to_be_bytes())) + .await + .caused_by(trc::location!()) +} + +pub fn clear_restore_requested(batch: &mut BatchBuilder, item_id: Id) { + batch.clear(class(KIND_RESTORE, &item_id.id().to_be_bytes())); +} + +/// What a change did to an archived item, for `/changes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Change { + Created = 0, + Updated = 1, + Destroyed = 2, +} + +fn change_rest(account_id: u32, change_id: u64) -> [u8; 12] { + let mut out = [0u8; 12]; + out[..4].copy_from_slice(&account_id.to_be_bytes()); + out[4..].copy_from_slice(&change_id.to_be_bytes()); + out +} + +pub fn log_change( + batch: &mut BatchBuilder, + account_id: u32, + change_id: u64, + item_id: Id, + change: Change, +) { + let mut value = Vec::with_capacity(9); + value.extend_from_slice(&item_id.id().to_be_bytes()); + value.push(change as u8); + batch.set( + class(KIND_CHANGE, &change_rest(account_id, change_id)), + value, + ); +} + +/// The account's changes after `since`, oldest first, each with its id. +pub async fn changes_since( + data: &Store, + account_id: u32, + since: u64, +) -> trc::Result> { + let mut changes = Vec::new(); + data.iterate( + IterateParams::new( + key( + KIND_CHANGE, + &change_rest(account_id, since.saturating_add(1)), + ), + key(KIND_CHANGE, &change_rest(account_id, u64::MAX)), + ) + .ascending(), + |key, value| { + if key.len() >= 8 && value.len() == 9 { + let change = match value[8] { + 0 => Change::Created, + 1 => Change::Updated, + _ => Change::Destroyed, + }; + changes.push(( + u64::from_be_bytes(key[key.len() - 8..].try_into().unwrap()), + Id::new(u64::from_be_bytes(value[0..8].try_into().unwrap())), + change, + )); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(changes) +} + +/// The account's latest change id, 0 when there's none. +pub async fn latest_change(data: &Store, account_id: u32) -> trc::Result { + let mut latest = 0; + data.iterate( + IterateParams::new( + key(KIND_CHANGE, &change_rest(account_id, 0)), + key(KIND_CHANGE, &change_rest(account_id, u64::MAX)), + ) + .descending() + .only_first() + .no_values(), + |key, _| { + if key.len() >= 8 { + latest = u64::from_be_bytes(key[key.len() - 8..].try_into().unwrap()); + } + Ok(false) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(latest) +} + +pub fn set_kept_account( + batch: &mut BatchBuilder, + account_id: u32, + kept: &KeptAccount, +) -> trc::Result<()> { + set(batch, class(KIND_KEPT, &account_id.to_be_bytes()), kept)?; + for address in &kept.addresses { + batch.set( + class(KIND_RESERVED, address.to_lowercase().as_bytes()), + account_id.to_be_bytes().to_vec(), + ); + } + Ok(()) +} + +pub async fn kept_account(data: &Store, account_id: u32) -> trc::Result> { + get(data, key(KIND_KEPT, &account_id.to_be_bytes())).await +} + +pub fn clear_kept_account(batch: &mut BatchBuilder, account_id: u32, kept: &KeptAccount) { + batch.clear(class(KIND_KEPT, &account_id.to_be_bytes())); + for address in &kept.addresses { + batch.clear(class(KIND_RESERVED, address.to_lowercase().as_bytes())); + } +} + +/// Every kept account, as (id, kept). +pub async fn kept_accounts(data: &Store) -> trc::Result> { + let mut kept = Vec::new(); + data.iterate( + IterateParams::new( + key(KIND_KEPT, &0u32.to_be_bytes()), + key(KIND_KEPT, &u32::MAX.to_be_bytes()), + ) + .ascending(), + |key, value| { + if key.len() >= 4 + && let Ok(Json(account)) = Json::::deserialize(value) + { + kept.push(( + u32::from_be_bytes(key[key.len() - 4..].try_into().unwrap()), + account, + )); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + Ok(kept) +} + +/// The kept account an address is reserved for (UD-16). +pub async fn reserved_by(data: &Store, address: &str) -> trc::Result> { + data.get_value::(key(KIND_RESERVED, address.to_lowercase().as_bytes())) + .await + .caused_by(trc::location!()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_round_trip() { + let extra = Extra::Email { + mailboxes: vec![0, 7], + keywords: vec!["$seen".into(), "$flagged".into()], + }; + let bytes = Json(&extra).serialize().unwrap(); + assert_eq!(Json::::deserialize(&bytes).unwrap().0, extra); + + let note = EmailNote { + archived_at: 1, + archived_until: 2, + size: 3, + mailboxes: vec![1], + keywords: vec![], + }; + let bytes = Json(¬e).serialize().unwrap(); + assert_eq!(Json::::deserialize(&bytes).unwrap().0, note); + } +} diff --git a/crates/features/src/undelete/email.rs b/crates/features/src/undelete/email.rs new file mode 100644 index 0000000..83cc244 --- /dev/null +++ b/crates/features/src/undelete/email.rs @@ -0,0 +1,177 @@ +/* + * 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::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 archiving is on (`retention` seconds). +pub fn note( + batch: &mut BatchBuilder, + retention: u64, + account_id: u32, + document_id: u32, + size: u64, + mailboxes: Vec, + keywords: Vec, +) -> trc::Result<()> { + let archived_at = now(); + data::note_email( + batch, + account_id, + document_id, + &EmailNote { + archived_at, + archived_until: archived_at + retention, + size, + mailboxes, + keywords, + }, + ) +} + +/// 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) -> Vec { + 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 { + let Some(note) = data::email_note(data, account_id, document_id).await? else { + return Ok(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 { + let only_trash = original.len() == 1 && original[0] == trash; + let mailboxes = original + .iter() + .copied() + .filter(|id| exists(*id) && (only_trash || *id != trash)) + .collect::>(); + 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()] + ); + } +} diff --git a/crates/features/src/undelete/mod.rs b/crates/features/src/undelete/mod.rs new file mode 100644 index 0000000..37189af --- /dev/null +++ b/crates/features/src/undelete/mod.rs @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Undelete, built from `docs/spec/features/undelete.md`. +//! +//! With `x:DataRetention.archiveDeletedItemsFor` set, a permanently deleted +//! item is kept for that long and can be restored. Upstream's `x:ArchivedItem` +//! record and the kept copy (a blob held by a temporary link until +//! `archivedUntil`) stay exactly as upstream writes them. What restore needs +//! beyond them, and the fork's bookkeeping, live in the fork's own subspace +//! (`data`). Requirements are named `UD-n`, after the spec. + +pub mod data; +pub mod email; +pub mod records; +pub mod settings; diff --git a/crates/features/src/undelete/records.rs b/crates/features/src/undelete/records.rs new file mode 100644 index 0000000..eb2b410 --- /dev/null +++ b/crates/features/src/undelete/records.rs @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `x:ArchivedItem` records, written as upstream writes them: the record and +//! its account index, with no link to the account, so deleting an account +//! isn't refused while it has archived items. Upstream's account removal +//! task clears exactly these two keys. + +use crate::undelete::data::{self, Change}; +use registry::schema::prelude::Property; +use registry::{ + schema::{prelude::ObjectType, structs::ArchivedItem}, + types::{EnumImpl, ObjectImpl, index::IndexValue}, +}; +use store::{ + RegistryStore, SerializeInfallible, Store, + registry::RegistryQuery, + write::{BatchBuilder, BlobLink, BlobOp, RegistryClass, ValueClass, now}, +}; +use trc::AddContext; +use types::id::Id; + +fn account_index(account_id: u32, item_id: u64) -> ValueClass { + ValueClass::Registry(RegistryClass::Index { + index_id: Property::AccountId.to_id(), + object_id: ObjectType::ArchivedItem.to_id(), + item_id, + key: IndexValue::U64(account_id as u64).serialize(), + }) +} + +fn item_class(item_id: u64) -> ValueClass { + ValueClass::Registry(RegistryClass::Item { + object_id: ObjectType::ArchivedItem.to_id(), + item_id, + }) +} + +/// Writes a new archived item, holding its kept copy until `archivedUntil`, +/// with what restore needs beside it. Returns its id. +pub async fn insert( + data: &Store, + registry: &RegistryStore, + item: &ArchivedItem, + extra: &data::Extra, +) -> trc::Result { + let item_id = registry.assign_id(); + let id = Id::new(item_id); + let account_id = item.account_id().document_id(); + let blob_hash = item.blob_id().hash.clone(); + + // The kept copy and the fork's records first, in the data store + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id).set( + BlobOp::Link { + hash: blob_hash.clone(), + to: BlobLink::Temporary { + until: item.archived_until().timestamp() as u64, + }, + }, + vec![], + ); + data::set_extra(&mut batch, id, extra)?; + data::set_blob_item(&mut batch, account_id, blob_hash.as_slice(), id); + data::log_change( + &mut batch, + account_id, + registry.assign_id(), + id, + Change::Created, + ); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + // Then the record, as upstream writes it + let mut batch = BatchBuilder::new(); + batch + .set(item_class(item_id), item.to_pickled_vec()) + .set(account_index(account_id, item_id), vec![]); + registry + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(id) +} + +/// Removes an archived item and releases its kept copy: on restore (UD-9), +/// on destroy (UD-12) and past its deadline (UD-13). +pub async fn remove( + data: &Store, + registry: &RegistryStore, + id: Id, + item: &ArchivedItem, +) -> trc::Result<()> { + let account_id = item.account_id().document_id(); + let blob_hash = item.blob_id().hash.clone(); + + let mut batch = BatchBuilder::new(); + batch + .clear(item_class(id.id())) + .clear(account_index(account_id, id.id())); + registry + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + let mut batch = BatchBuilder::new(); + batch.with_account_id(account_id).clear(BlobOp::Link { + hash: blob_hash.clone(), + to: BlobLink::Temporary { + until: item.archived_until().timestamp() as u64, + }, + }); + data::clear_extra(&mut batch, id); + data::clear_blob_item(&mut batch, account_id, blob_hash.as_slice()); + data::clear_restore_requested(&mut batch, id); + data::log_change( + &mut batch, + account_id, + registry.assign_id(), + id, + Change::Destroyed, + ); + data.write(batch.build_all()) + .await + .caused_by(trc::location!()) + .map(|_| ()) +} + +/// Whether an item is past its deadline: then it isn't restorable, even +/// before clean-up removes it (UD-13). +pub fn is_expired(item: &ArchivedItem) -> bool { + item.archived_until().timestamp() <= now() as i64 +} + +/// An account's archived items that are still restorable. Expired ones found +/// on the way are removed (UD-13). +pub async fn of_account( + data: &Store, + registry: &RegistryStore, + account_id: u32, +) -> trc::Result> { + let mut items = Vec::new(); + for id in registry + .query::>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id)) + .await + .caused_by(trc::location!())? + { + if let Some(item) = registry.object::(id).await? { + if is_expired(&item) { + remove(data, registry, id, &item).await?; + } else { + items.push((id, item)); + } + } + } + Ok(items) +} + +/// One archived item, if it exists, belongs to the account and is still +/// restorable. +pub async fn get( + data: &Store, + registry: &RegistryStore, + account_id: u32, + id: Id, +) -> trc::Result> { + match registry.object::(id).await? { + Some(item) if item.account_id().document_id() == account_id => { + if is_expired(&item) { + remove(data, registry, id, &item).await?; + Ok(None) + } else { + Ok(Some(item)) + } + } + _ => Ok(None), + } +} + +/// Removes every expired archived item on the server (UD-13), for the +/// scheduled clean-up. +pub async fn remove_expired(data: &Store, registry: &RegistryStore) -> trc::Result { + let mut removed = 0; + for id in registry + .query::>(RegistryQuery::new(ObjectType::ArchivedItem)) + .await + .caused_by(trc::location!())? + { + if let Some(item) = registry.object::(id).await? + && is_expired(&item) + { + remove(data, registry, id, &item).await?; + removed += 1; + } + } + Ok(removed) +} + +/// The archived item a restore task is for, found by its kept copy, with +/// what restore needs beside it. Items archived before the fork have no +/// pointer and no extra data: they're found by scanning the account's items. +/// `None` once it's already been restored (UD-11). +pub async fn for_restore( + data: &Store, + registry: &RegistryStore, + account_id: u32, + blob_hash: &[u8], +) -> trc::Result)>> { + let id = match data::blob_item(data, account_id, blob_hash).await? { + Some(id) => Some(id), + None => of_account(data, registry, account_id) + .await? + .into_iter() + .find(|(_, item)| item.blob_id().hash.as_slice() == blob_hash) + .map(|(id, _)| id), + }; + let Some(id) = id else { + return Ok(None); + }; + let Some(item) = get(data, registry, account_id, id).await? else { + return Ok(None); + }; + let extra = data::extra(data, id).await?; + Ok(Some((id, item, extra))) +} diff --git a/crates/features/src/undelete/settings.rs b/crates/features/src/undelete/settings.rs new file mode 100644 index 0000000..c777549 --- /dev/null +++ b/crates/features/src/undelete/settings.rs @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The retention settings, read from `x:DataRetention` each time they're +//! needed, so a change takes effect at once, with no settings reload (UD-6a). + +use registry::schema::structs::DataRetention; +use store::RegistryStore; +use types::id::Id; + +/// How long deleted things are kept, in seconds. `None` keeps nothing. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Retention { + /// `archiveDeletedItemsFor` (UD-1). + pub items: Option, + /// `archiveDeletedAccountsFor` (UD-15). + pub accounts: Option, +} + +/// The retention in force now. +pub async fn retention(registry: &RegistryStore) -> trc::Result { + let settings = registry + .object::(Id::singleton()) + .await? + .unwrap_or_default(); + Ok(Retention { + items: settings + .archive_deleted_items_for + .map(|d| d.as_secs()) + .filter(|secs| *secs > 0), + accounts: settings + .archive_deleted_accounts_for + .map(|d| d.as_secs()) + .filter(|secs| *secs > 0), + }) +} diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 8258a73..668dfbe 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -14,6 +14,7 @@ email = { path = "../email" } nlp = { path = "../nlp" } utils = { path = "../utils" } registry = { path = "../registry" } +inbuxa-features = { path = "../features" } mail-parser = { version = "0.11", features = ["full_encoding"] } tokio = { version = "1.53", features = ["full"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index 80133b9..03bedc7 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -223,6 +223,10 @@ impl SessionData { let mut fully_deleted = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new(); + // inbuxa: UD-1, UD-6a: the retention in force now + let retention = inbuxa_features::undelete::settings::retention(self.server.registry()) + .await? + .items; self.server .archives( account_id, @@ -245,6 +249,20 @@ impl SessionData { // Delete message fully_deleted.insert(document_id); thread_ids.insert(metadata.inner.thread_id.to_native()); + // inbuxa: UD-1, UD-4: a deleted message is noted for archiving + if let Some(retention) = retention { + inbuxa_features::undelete::email::note( + batch, + retention, + account_id, + document_id, + metadata.inner.size.to_native() as u64, + metadata.inner.mailboxes.iter().map(|m| m.mailbox_id.to_native()).collect(), + inbuxa_features::undelete::email::keywords_to_keep( + metadata.inner.keywords.iter().map(|k| k.to_string()), + ), + )?; + } batch .custom( ObjectIndexBuilder::<_, ()>::new() diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index bc1cd7f..9ce5c81 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -371,8 +371,10 @@ impl MethodName { "query" => MethodFunction::Query, "changes" => MethodFunction::Changes, )?; - // inbuxa: only masked email has /changes (a fork addition) - if fnc == MethodFunction::Changes && obj != ObjectType::MaskedEmail { + // inbuxa: only masked email and undelete have /changes (fork additions) + if fnc == MethodFunction::Changes + && !matches!(obj, ObjectType::MaskedEmail | ObjectType::ArchivedItem) + { return None; } diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index b117609..4c0b6e6 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -609,8 +609,12 @@ impl RequestHandler for Server { RequestMethod::Changes(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; - // inbuxa: x:MaskedEmail/changes - if matches!(method_name.obj, MethodObject::Registry(_)) { + // inbuxa: x:MaskedEmail/changes and x:ArchivedItem/changes + if method_name.obj + == MethodObject::Registry(registry::schema::prelude::ObjectType::ArchivedItem) + { + crate::inbuxa::undelete::changes(self, access_token, *req).await? + } else if matches!(method_name.obj, MethodObject::Registry(_)) { crate::inbuxa::masked_email::changes(self, access_token, *req).await? } else { self.changes(*req, method_name.obj, access_token) @@ -750,7 +754,13 @@ async fn assert_registry_account( access_token: &AccessToken, account_id: Id, ) -> trc::Result<()> { - if obj == MethodObject::Registry(registry::schema::prelude::ObjectType::MaskedEmail) { + if matches!( + obj, + MethodObject::Registry( + registry::schema::prelude::ObjectType::MaskedEmail + | registry::schema::prelude::ObjectType::ArchivedItem + ) + ) { crate::inbuxa::masked_email::assert_can_manage( server, access_token, diff --git a/crates/jmap/src/inbuxa/access.rs b/crates/jmap/src/inbuxa/access.rs new file mode 100644 index 0000000..fe991ce --- /dev/null +++ b/crates/jmap/src/inbuxa/access.rs @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Reaching another account's fork-managed objects. + +use common::{Server, auth::AccessToken}; +use registry::schema::enums::Permission; +use types::id::Id; + +/// Who may manage an account's masks (ME-18, ME-19) or archive (UD-7). The account itself (or +/// a group it's in); at server level, a holder of `impersonate`; inside a +/// tenant, a holder of `sysAccountUpdate`, for accounts in its own tenant +/// only, `impersonate` or not. +pub async fn assert_can_manage( + server: &Server, + access_token: &AccessToken, + account_id: u32, +) -> trc::Result<()> { + if access_token.is_account_id(account_id) { + return Ok(()); + } + let allowed = if let Some(tenant_id) = access_token.tenant_id() { + let target = server.account(account_id).await?; + target.id_tenant == Some(tenant_id) + && (access_token.has_permission(Permission::SysAccountUpdate) + || access_token.is_member(account_id)) + } else { + access_token.is_member(account_id) + }; + if allowed { + Ok(()) + } else { + Err(trc::JmapEvent::Forbidden + .into_err() + .details(format!("You can't manage account {}", Id::from(account_id)))) + } +} diff --git a/crates/jmap/src/inbuxa/masked_email.rs b/crates/jmap/src/inbuxa/masked_email.rs index 8094652..192cff6 100644 --- a/crates/jmap/src/inbuxa/masked_email.rs +++ b/crates/jmap/src/inbuxa/masked_email.rs @@ -49,35 +49,7 @@ pub enum CreateRefusal { RateLimited, } -/// ME-18, ME-19: who may manage an account's masks. The account itself (or -/// a group it's in); at server level, a holder of `impersonate`; inside a -/// tenant, a holder of `sysAccountUpdate`, for accounts in its own tenant -/// only, `impersonate` or not. -pub async fn assert_can_manage( - server: &Server, - access_token: &AccessToken, - account_id: u32, -) -> trc::Result<()> { - if access_token.is_account_id(account_id) { - return Ok(()); - } - let allowed = if let Some(tenant_id) = access_token.tenant_id() { - let target = server.account(account_id).await?; - target.id_tenant == Some(tenant_id) - && (access_token.has_permission(Permission::SysAccountUpdate) - || access_token.is_member(account_id)) - } else { - access_token.is_member(account_id) - }; - if allowed { - Ok(()) - } else { - Err(trc::JmapEvent::Forbidden.into_err().details(format!( - "You can't manage masked addresses of account {}", - Id::from(account_id) - ))) - } -} +pub use crate::inbuxa::access::assert_can_manage; /// The domains an account may have masks on, as (id, name): every domain /// and alias domain it's linked to, its primary domain first (ME-12). diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 7869b1f..f32906e 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -7,5 +7,7 @@ //! JMAP glue for INBUXA's rebuilt features. The features' rules live in //! `crates/features`; this module only speaks JMAP for them. +pub mod access; pub mod fastmail; pub mod masked_email; +pub mod undelete; diff --git a/crates/jmap/src/inbuxa/undelete.rs b/crates/jmap/src/inbuxa/undelete.rs new file mode 100644 index 0000000..b633d98 --- /dev/null +++ b/crates/jmap/src/inbuxa/undelete.rs @@ -0,0 +1,376 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Undelete over JMAP: `x:ArchivedItem` (`docs/spec/features/undelete.md`). +//! The rules themselves are in `inbuxa_features::undelete`. + +use crate::{ + api::query::QueryResponseBuilder, + inbuxa::access::assert_can_manage, + registry::{ + mapping::{RegistryGetResponse, RegistryQueryResponse, RegistrySetResponse}, + query::RegistryQueryFilters, + }, +}; +use common::{Server, auth::AccessToken}; +use inbuxa_features::{masked_email::ops::collapse, undelete}; +use jmap_proto::{error::set::SetError, types::state::State as JmapState}; +use jmap_tools::Key; +use registry::{ + jmap::{IntoValue, JmapValue}, + schema::{ + enums::ArchivedItemType, + prelude::Property, + structs::{ArchivedItem, Task, TaskRestoreArchivedItem, TaskStatus}, + }, + types::datetime::UTCDateTime, +}; +use std::str::FromStr; +use store::{registry::RegistryFilterOp, write::BatchBuilder}; +use types::id::Id; + +fn kind(item: &ArchivedItem) -> (ArchivedItemType, &'static str) { + match item { + ArchivedItem::Email(_) => (ArchivedItemType::Email, "Email"), + ArchivedItem::FileNode(_) => (ArchivedItemType::FileNode, "FileNode"), + ArchivedItem::CalendarEvent(_) => (ArchivedItemType::CalendarEvent, "CalendarEvent"), + ArchivedItem::ContactCard(_) => (ArchivedItemType::ContactCard, "ContactCard"), + ArchivedItem::SieveScript(_) => (ArchivedItemType::SieveScript, "SieveScript"), + } +} + +/// The item's original date: when a message was received, or when anything +/// else was created. Restore gives it back. +fn original_date(item: &ArchivedItem) -> UTCDateTime { + match item { + ArchivedItem::Email(item) => item.received_at, + ArchivedItem::FileNode(item) => item.created_at, + ArchivedItem::CalendarEvent(item) => item.created_at, + ArchivedItem::ContactCard(item) => item.created_at, + ArchivedItem::SieveScript(item) => item.created_at, + } +} + +/// When the item was archived. +fn archived_at(item: &ArchivedItem) -> i64 { + match item { + ArchivedItem::Email(item) => item.archived_at, + ArchivedItem::FileNode(item) => item.archived_at, + ArchivedItem::CalendarEvent(item) => item.archived_at, + ArchivedItem::ContactCard(item) => item.archived_at, + ArchivedItem::SieveScript(item) => item.archived_at, + } + .timestamp() +} + +/// Text a search matches: the summary fields (a fork addition). +fn summary_text(item: &ArchivedItem) -> String { + match item { + ArchivedItem::Email(item) => format!("{} {}", item.from, item.subject), + ArchivedItem::FileNode(item) => item.name.clone(), + ArchivedItem::CalendarEvent(item) => item.title.clone(), + ArchivedItem::ContactCard(item) => item.name.clone().unwrap_or_default(), + ArchivedItem::SieveScript(item) => item.name.clone(), + } + .to_lowercase() +} + +/// The archive's state, for `/get` and `/changes`. +pub async fn state(server: &Server, account_id: u32) -> trc::Result { + let latest = undelete::data::latest_change(&server.core.storage.data, account_id).await?; + Ok(if latest == 0 { + JmapState::Initial + } else { + JmapState::Exact(latest) + }) +} + +/// The item as `/get` shows it: every property, `status` and `accountId` +/// included (upstream omits them). +async fn to_value(server: &Server, id: Id, item: ArchivedItem) -> trc::Result> { + let requested = undelete::data::is_restore_requested(&server.core.storage.data, id).await?; + let mut value = item.into_value(); + if let JmapValue::Object(object) = &mut value { + object.insert_unchecked(Key::Property(Property::Id), JmapValue::Element(id.into())); + object.insert_unchecked( + Key::Property(Property::Status), + JmapValue::Str( + if requested { + "requestRestore" + } else { + "archived" + } + .into(), + ), + ); + } + Ok(value) +} + +/// `x:ArchivedItem/get` (UD-7). Items past their deadline aren't returned +/// (UD-13). +pub(crate) async fn get(mut get: RegistryGetResponse<'_>) -> trc::Result> { + let account_id = get.account_id; + assert_can_manage(get.server, get.access_token, account_id).await?; + let data = &get.server.core.storage.data; + let registry = get.server.registry(); + + match get.ids.take() { + None => { + for (id, item) in undelete::records::of_account(data, registry, account_id).await? { + let value = to_value(get.server, id, item).await?; + get.response.list.push(value); + } + } + Some(ids) => { + for id in ids { + match undelete::records::get(data, registry, account_id, id).await? { + Some(item) => { + let value = to_value(get.server, id, item).await?; + get.response.list.push(value); + } + None => get.not_found(id), + } + } + } + } + get.response.state = Some(state(get.server, account_id).await?); + Ok(get) +} + +/// `x:ArchivedItem/query`, filtering on `@type`, `archivedAt` and text over +/// the summary fields (a fork addition). Newest first. +pub(crate) async fn query(mut req: RegistryQueryResponse<'_>) -> trc::Result { + let account_id = req.request.account_id.document_id(); + assert_can_manage(req.server, req.access_token, account_id).await?; + + let mut typ = None; + let mut after = None; + let mut before = None; + let mut text = None; + req.request + .extract_filters(|property, op, value| match (property, op, value) { + (Property::Type, RegistryFilterOp::Equal, serde_json::Value::String(v)) => { + typ = Some(v); + true + } + (Property::ArchivedAt, op, serde_json::Value::String(v)) => { + let Ok(at) = UTCDateTime::from_str(&v) else { + return false; + }; + match op { + RegistryFilterOp::GreaterThan | RegistryFilterOp::GreaterEqualThan => { + after = Some(at.timestamp()); + true + } + RegistryFilterOp::LowerThan | RegistryFilterOp::LowerEqualThan => { + before = Some(at.timestamp()); + true + } + _ => false, + } + } + (Property::Text, _, serde_json::Value::String(v)) => { + text = Some(v.to_lowercase()); + true + } + (Property::AccountId, _, _) => true, + _ => false, + })?; + req.request + .extract_parameters(req.server.core.jmap.query_max_results, Some(Property::Id))?; + + let mut items = undelete::records::of_account( + &req.server.core.storage.data, + req.server.registry(), + account_id, + ) + .await? + .into_iter() + .filter(|(_, item)| { + let archived_at = archived_at(item); + typ.as_deref().is_none_or(|t| kind(item).1 == t) + && after.is_none_or(|at| archived_at >= at) + && before.is_none_or(|at| archived_at <= at) + && text + .as_deref() + .is_none_or(|t| summary_text(item).contains(t)) + }) + .collect::>(); + items.sort_by(|(a_id, a), (b_id, b)| archived_at(b).cmp(&archived_at(a)).then(b_id.cmp(a_id))); + + let mut response = QueryResponseBuilder::new( + items.len(), + req.server.core.jmap.query_max_results, + JmapState::Initial, + &req.request, + ); + for (id, _) in items { + if !response.add_id(id) { + break; + } + } + Ok(response) +} + +/// Asks for an item's restore: the server schedules the restore task, once +/// (UD-8, UD-11). +async fn request_restore( + server: &Server, + account_id: u32, + id: Id, + item: &ArchivedItem, +) -> trc::Result<()> { + let data = &server.core.storage.data; + if undelete::data::is_restore_requested(data, id).await? { + return Ok(()); + } + let mut batch = BatchBuilder::new(); + batch.schedule_task(Task::RestoreArchivedItem(TaskRestoreArchivedItem { + account_id: Id::from(account_id), + archived_item_type: kind(item).0, + blob_id: item.blob_id().clone(), + created_at: original_date(item), + archived_until: item.archived_until(), + status: TaskStatus::now(), + })); + undelete::data::set_restore_requested(&mut batch, id); + undelete::data::log_change( + &mut batch, + account_id, + server.registry().assign_id(), + id, + undelete::data::Change::Updated, + ); + server.store().write(batch.build_all()).await?; + server.notify_task_queue(); + Ok(()) +} + +/// `x:ArchivedItem/set`: `status: requestRestore` restores (UD-8), destroy +/// removes for good (UD-12). Items are never created over the API. +pub(crate) async fn set(mut set: RegistrySetResponse<'_>) -> trc::Result> { + let account_id = set.account_id; + assert_can_manage(set.server, set.access_token, account_id).await?; + let data = &set.server.core.storage.data; + let registry = set.server.registry(); + + set.fail_all_create("Archived items are created by deleting things, not directly."); + + for (id, value) in std::mem::take(&mut set.update) { + let Some(item) = undelete::records::get(data, registry, account_id, id).await? else { + set.response.not_updated.append(id, SetError::not_found()); + continue; + }; + let mut restore = false; + let mut invalid = None; + for (key, value) in value.into_expanded_object() { + match (key, value.as_str().as_deref()) { + (Key::Property(Property::Status), Some("requestRestore")) => restore = true, + (Key::Property(Property::Status), Some("archived")) => {} + (Key::Property(property), _) => { + invalid = Some(property); + break; + } + _ => { + invalid = Some(Property::Status); + break; + } + } + } + if let Some(property) = invalid { + set.response.not_updated.append( + id, + SetError::invalid_properties() + .with_property(property) + .with_description("Only status can be set, to requestRestore."), + ); + continue; + } + if restore { + request_restore(set.server, account_id, id, &item).await?; + } + set.response.updated.append(id, None); + } + + for id in std::mem::take(&mut set.destroy) { + match undelete::records::get(data, registry, account_id, id).await? { + Some(item) => { + undelete::records::remove(data, registry, id, &item).await?; + set.response.destroyed.push(id); + } + None => set.response.not_destroyed.append(id, SetError::not_found()), + } + } + + Ok(set) +} + +/// `x:ArchivedItem/changes` (a fork addition). +pub async fn changes( + server: &Server, + access_token: &AccessToken, + request: jmap_proto::method::changes::ChangesRequest, +) -> trc::Result> { + use jmap_proto::{ + method::changes::ChangesResponse, + response::{ChangesResponseMethod, ResponseMethod}, + }; + + let account_id = request.account_id.document_id(); + assert_can_manage(server, access_token, account_id).await?; + let since = match &request.since_state { + JmapState::Initial => 0, + JmapState::Exact(change_id) => *change_id, + JmapState::Intermediate(_) => { + return Err(trc::JmapEvent::CannotCalculateChanges.into_err()); + } + }; + let max = request + .max_changes + .filter(|max| *max != 0) + .unwrap_or(usize::MAX) + .min(server.core.jmap.changes_max_results); + let entries = undelete::data::changes_since(&server.core.storage.data, account_id, since) + .await? + .into_iter() + .map(|(change_id, id, change)| { + ( + change_id, + id, + match change { + undelete::data::Change::Created => { + inbuxa_features::masked_email::data::Change::Created + } + undelete::data::Change::Updated => { + inbuxa_features::masked_email::data::Change::Updated + } + undelete::data::Change::Destroyed => { + inbuxa_features::masked_email::data::Change::Destroyed + } + }, + ) + }) + .collect::>(); + let changes = collapse(since, &entries, max); + + Ok(ResponseMethod::Changes(ChangesResponseMethod::Registry( + Box::new(ChangesResponse { + account_id: request.account_id, + old_state: request.since_state, + new_state: if changes.new_state == 0 { + JmapState::Initial + } else { + JmapState::Exact(changes.new_state) + }, + has_more_changes: changes.has_more, + created: changes.created, + updated: changes.updated, + destroyed: changes.destroyed, + updated_properties: None, + }), + ))) +} diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs index 4b03273..b5de3c9 100644 --- a/crates/jmap/src/registry/get.rs +++ b/crates/jmap/src/registry/get.rs @@ -386,6 +386,10 @@ impl RegistryGet for Server { | ObjectType::AccountPassword | ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()), ObjectType::Action => Ok(get.not_found_any().into_response()), + // inbuxa: undelete (UD-7, UD-13) + ObjectType::ArchivedItem => crate::inbuxa::undelete::get(get) + .await + .map(|get| get.into_response()), #[cfg(not(feature = "enterprise"))] _ => Ok(get.not_found_any().into_response()), } diff --git a/crates/jmap/src/registry/mod.rs b/crates/jmap/src/registry/mod.rs index 5a07a87..3e20dae 100644 --- a/crates/jmap/src/registry/mod.rs +++ b/crates/jmap/src/registry/mod.rs @@ -20,8 +20,7 @@ impl EnterpriseRegistry for Server { fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> { if !matches!( object_type, - ObjectType::ArchivedItem - | ObjectType::Metric + ObjectType::Metric | ObjectType::Trace ) { return Ok(()); diff --git a/crates/jmap/src/registry/query.rs b/crates/jmap/src/registry/query.rs index 947b5e0..0d87228 100644 --- a/crates/jmap/src/registry/query.rs +++ b/crates/jmap/src/registry/query.rs @@ -131,6 +131,16 @@ impl RegistryQuery for Server { .await .and_then(|response| response.build()), + // inbuxa: filters on type, archivedAt and text (undelete) + ObjectType::ArchivedItem => crate::inbuxa::undelete::query(RegistryQueryResponse { + server: self, + access_token, + object_type, + request, + }) + .await + .and_then(|response| response.build()), + // inbuxa: filters on enabled, forDomain and text (masked email) ObjectType::MaskedEmail => crate::inbuxa::masked_email::query(RegistryQueryResponse { server: self, diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 3f80ee2..c7a8185 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -817,6 +817,11 @@ impl RegistrySet for Server { ObjectType::Task => task_set(set).await.map(|set| set.into_response()), + // inbuxa: undelete (UD-8, UD-12) + ObjectType::ArchivedItem => crate::inbuxa::undelete::set(set) + .await + .map(|set| set.into_response()), + ObjectType::Action => Box::pin(action_set(set)) .await .map(|set| set.into_response()), diff --git a/crates/services/src/task_manager/index.rs b/crates/services/src/task_manager/index.rs index e9c9a4c..420062b 100644 --- a/crates/services/src/task_manager/index.rs +++ b/crates/services/src/task_manager/index.rs @@ -6,7 +6,11 @@ use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult}; use common::Server; -use email::{cache::MessageCacheFetch, message::metadata::MessageMetadata}; +use email::{ + cache::MessageCacheFetch, + message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata}, +}; +use types::blob_hash::BlobHash; use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard}; use registry::{ schema::{ @@ -593,6 +597,22 @@ async fn delete_email_metadata( .caused_by(trc::location!())?; metadata.unindex(batch); + // inbuxa: UD-1, UD-4: a message noted at deletion is archived + let root = metadata.contents.first().and_then(|c| c.parts.first()); + inbuxa_features::undelete::email::archive( + &server.core.storage.data, + server.registry(), + account_id, + document_id, + inbuxa_features::undelete::email::Summary { + blob_hash: BlobHash::from(&metadata.blob_hash), + from: root.and_then(|part| part.from()), + subject: root.and_then(|part| part.subject()), + received_at: metadata.rcvd_attach.to_native() & MESSAGE_RECEIVED_MASK, + }, + ) + .await + .caused_by(trc::location!())?; } None => { trc::event!( diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index bd16940..1ca8d2f 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -227,6 +227,14 @@ async fn store_maintenance( .await .caused_by(trc::location!())?; + // inbuxa: UD-13: archived items past their deadline go + inbuxa_features::undelete::records::remove_expired( + &server.core.storage.data, + server.registry(), + ) + .await + .caused_by(trc::location!())?; + trc::event!( Store(StoreEvent::DataStorePurged), Elapsed = started.elapsed() diff --git a/crates/services/src/task_manager/restore_item.rs b/crates/services/src/task_manager/restore_item.rs index 82dada2..f10ac46 100644 --- a/crates/services/src/task_manager/restore_item.rs +++ b/crates/services/src/task_manager/restore_item.rs @@ -6,12 +6,15 @@ use common::{Server, auth::BuildAccessToken}; use email::{ - mailbox::INBOX_ID, + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, + mailbox::{INBOX_ID, TRASH_ID}, message::ingest::{EmailIngest, IngestEmail, IngestSource}, }; +use inbuxa_features::undelete; +use types::keyword::Keyword; use mail_parser::MessageParser; use registry::schema::{enums::ArchivedItemType, structs::TaskRestoreArchivedItem}; -use store::write::{BatchBuilder, BlobLink, BlobOp}; +use store::write::BatchBuilder; use trc::AddContext; use crate::task_manager::TaskResult; @@ -48,6 +51,40 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R .await .caused_by(trc::location!())?; + // inbuxa: UD-8, UD-11: the item, and where it goes back + let data = &server.core.storage.data; + let Some((item_id, item, extra)) = undelete::records::for_restore( + data, + server.registry(), + account_id, + task.blob_id.hash.as_slice(), + ) + .await? + else { + return Ok(TaskResult::Success(vec![])); + }; + let (mailbox_ids, keywords) = match extra { + Some(undelete::data::Extra::Email { + mailboxes, + keywords, + }) => { + let cache = server + .get_cached_messages(account_id) + .await + .caused_by(trc::location!())?; + ( + undelete::email::restore_mailboxes( + &mailboxes, + |id| cache.has_mailbox_id(&id), + INBOX_ID, + TRASH_ID, + ), + keywords.iter().map(|k| Keyword::parse(k)).collect(), + ) + } + _ => (vec![INBOX_ID], vec![]), + }; + let Some(bytes) = server .blob_store() .get_blob(task.blob_id.hash.as_slice(), 0..usize::MAX) @@ -62,8 +99,8 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R message: MessageParser::new().parse(&bytes), blob_hash: Some(&task.blob_id.hash), access_token: &access_token.build(), - mailbox_ids: vec![INBOX_ID], - keywords: vec![], + mailbox_ids, + keywords, received_at: (task.created_at.timestamp() as u64).into(), source: IngestSource::Restore, session_id: 0, @@ -71,17 +108,22 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R .await { Ok(_) => { - let mut batch = BatchBuilder::new(); - batch.with_account_id(account_id).clear(BlobOp::Link { - hash: task.blob_id.hash.clone(), - to: BlobLink::Temporary { - until: task.archived_until.timestamp() as u64, - }, - }); - server.store().write(batch.build_all()).await?; - + // inbuxa: UD-9: the archived record goes, and its copy is released + undelete::records::remove(data, server.registry(), item_id, &item).await?; Ok(TaskResult::Success(vec![])) } + // inbuxa: UD-10: over quota, the item stays archived and the task says why + Err(err) + if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) + || err.matches(trc::EventType::Limit(trc::LimitEvent::TenantQuota)) => + { + let mut batch = BatchBuilder::new(); + undelete::data::clear_restore_requested(&mut batch, item_id); + data.write(batch.build_all()).await?; + Ok(TaskResult::permanent( + "Not restored: the account or its tenant is over quota.".to_string(), + )) + } Err(mut err) if err.matches(trc::EventType::MessageIngest( trc::MessageIngestEvent::Error, diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 699547f..5fa9544 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -17,6 +17,7 @@ pub mod quota; pub mod security; pub mod task; pub mod tenant; +pub mod undelete; use crate::utils::server::TestServerBuilder; use registry::schema::structs::{Expression, Imap, MtaStageAuth}; @@ -70,8 +71,7 @@ pub async fn system_tests() { delivery::test(&mut test).await; crypto::test(&mut test).await; antispam::test(&mut test).await; - #[cfg(feature = "pending-rebuild")] // inbuxa: pending-rebuild, see docs/spec/features/undelete.md - archiving::test(&mut test).await; + undelete::test(&mut test).await; task::test(&mut test).await; if test.is_reset() { diff --git a/tests/src/system/undelete.rs b/tests/src/system/undelete.rs new file mode 100644 index 0000000..f46cdb3 --- /dev/null +++ b/tests/src/system/undelete.rs @@ -0,0 +1,482 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Undelete acceptance tests, from `docs/spec/features/undelete.md`. Each +//! check names the test number or the requirement it covers. + +use crate::utils::{ + account::Account, + imap::{ImapConnection, Type}, + jmap::JmapUtils, + pop3::{self, Pop3Connection}, + server::{TestServer, TestServerBuilder}, +}; +use email::{ + mailbox::{INBOX_ID, TRASH_ID}, + message::delete::EmailDeletion, +}; +use imap_proto::ResponseType; +use jmap_client::client::Client; +use registry::{ + schema::{ + prelude::{ObjectType, Property}, + structs::{DataRetention, Expression, Imap, MtaStageAuth}, + }, + types::duration::Duration, +}; +use serde_json::{Value, json}; +use types::id::Id; + +const SECRET: &str = "undelete test user passphrase"; +const DAY: u64 = 86_400; + +pub async fn test(test: &mut TestServer) { + println!("Running undelete tests..."); + let admin = test.account("admin@example.org"); + let user = admin + .create_user_account("undelete@example.org", SECRET, "Undelete", &[], vec![]) + .await; + let client = user.jmap_client().await; + + // Acceptance test 1: archiving off keeps nothing + admin.set_retention(None).await; + let id = import(&client, "Off", &[INBOX_ID], &[]).await; + client.email_destroy(&id).await.unwrap(); + test.wait_for_tasks().await; + assert!(user.archived().await.is_empty(), "test 1"); + + // Acceptance test 2: each way of deleting keeps one copy, 30 days out. + // No settings reload: the change applies at once (UD-6a). + admin.set_retention(Some(30 * DAY)).await; + + // JMAP + let id = import(&client, "Via JMAP", &[INBOX_ID], &[]).await; + client.email_destroy(&id).await.unwrap(); + // IMAP expunge + import(&client, "Via IMAP", &[INBOX_ID], &[]).await; + let mut imap = ImapConnection::connect(b"_x ").await; + imap.assert_read(Type::Untagged, ResponseType::Ok).await; + imap.authenticate("undelete@example.org", SECRET).await; + imap.send("SELECT INBOX").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("SEARCH SUBJECT \"Via IMAP\"").await; + let found = imap.assert_read(Type::Tagged, ResponseType::Ok).await; + let seq = found + .iter() + .find_map(|line| line.strip_prefix("* SEARCH ")) + .map(|s| s.trim().to_string()) + .expect("IMAP message"); + imap.send(&format!("STORE {seq} +FLAGS (\\Deleted)")).await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + imap.send("EXPUNGE").await; + imap.assert_read(Type::Tagged, ResponseType::Ok).await; + // POP3 delete: the newest message is the last in the maildrop + import(&client, "Via POP3", &[INBOX_ID], &[]).await; + let mut pop = Pop3Connection::connect().await; + pop.authenticate("undelete@example.org", SECRET).await; + pop.send("STAT").await; + let message = pop.assert_read(pop3::ResponseType::Ok).await[0] + .split_whitespace() + .nth(1) + .expect("POP3 count") + .to_string(); + pop.send(&format!("DELE {message}")).await; + pop.assert_read(pop3::ResponseType::Ok).await; + pop.send("QUIT").await; + pop.assert_read(pop3::ResponseType::Ok).await; + // Automatic Trash emptying + import(&client, "Via Trash emptying", &[TRASH_ID], &[]).await; + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + test.server + .emails_auto_expunge(user.id().document_id(), 0) + .await + .unwrap(); + test.wait_for_tasks().await; + + let archived = user.archived().await; + for subject in ["Via JMAP", "Via IMAP", "Via POP3", "Via Trash emptying"] { + let item = archived + .iter() + .find(|item| item["subject"] == subject) + .unwrap_or_else(|| panic!("test 2: {subject} not archived: {archived:?}")); + let span = seconds(&item["archivedUntil"]) - seconds(&item["archivedAt"]); + assert!( + (30 * DAY as i64 - 60..=30 * DAY as i64 + 60).contains(&span), + "test 2: {subject} kept for {span}s" + ); + assert_eq!(item["status"], "archived", "status is returned"); + assert_eq!(item["accountId"], user.id_string(), "accountId is returned"); + } + assert_eq!(archived.len(), 4, "test 2: {archived:?}"); + + // Acceptance test 3: moving to Trash keeps nothing + let id = import(&client, "Moved", &[INBOX_ID], &[]).await; + user.email_set_mailboxes(&id, &[TRASH_ID]).await; + test.wait_for_tasks().await; + assert_eq!(user.archived().await.len(), 4, "test 3"); + + // Acceptance test 13: archived copies don't count toward quota + let used = test + .server + .get_used_quota_account(user.id().document_id()) + .await + .unwrap(); + + // Acceptance test 4: restore puts a message back in both of its + // mailboxes with its keywords (UD-8) + let label = user.create_mailbox("Label").await; + let id = import( + &client, + "Two labels", + &[INBOX_ID, label], + &["$seen", "$flagged"], + ) + .await; + client.email_destroy(&id).await.unwrap(); + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(user.id().document_id()) + .await + .unwrap(), + used, + "test 13" + ); + let item = user.archived_with_subject("Two labels").await; + user.request_restore(item.object_id()).await; + test.wait_for_tasks().await; + let restored = user.email_with_subject("Two labels").await; + assert_eq!( + restored["mailboxIds"], + json!({Id::from(INBOX_ID).to_string(): true, Id::from(label).to_string(): true}), + "test 4: {restored}" + ); + assert_eq!( + restored["keywords"], + json!({"$seen": true, "$flagged": true}), + "test 4" + ); + // Acceptance test 7: a new id, and the record is gone (UD-9) + assert_ne!(restored.id(), id, "test 7"); + assert!( + user.archived() + .await + .iter() + .all(|i| i["subject"] != "Two labels"), + "test 7" + ); + + // Acceptance test 5: its mailbox gone, a message comes back to Inbox + let gone = user.create_mailbox("Gone").await; + let id = import(&client, "Lost label", &[gone], &[]).await; + client.email_destroy(&id).await.unwrap(); + user.destroy_mailbox(gone).await; + test.wait_for_tasks().await; + let item = user.archived_with_subject("Lost label").await; + user.request_restore(item.object_id()).await; + test.wait_for_tasks().await; + assert_eq!( + user.email_with_subject("Lost label").await["mailboxIds"], + json!({Id::from(INBOX_ID).to_string(): true}), + "test 5" + ); + + // Acceptance test 9: asking twice restores once (UD-11) + let item = user.archived_with_subject("Via JMAP").await; + user.request_restore(item.object_id()).await; + // The second ask may find it already restored, which is fine + user.jmap_method_call( + "x:ArchivedItem/set", + json!({ + "accountId": user.id_string(), + "update": {item.object_id().to_string(): {"status": "requestRestore"}} + }), + ) + .await; + test.wait_for_tasks().await; + assert_eq!(user.count_with_subject("Via JMAP").await, 1, "test 9"); + + // Acceptance test 10: the user destroys an archived item for good (UD-12) + let item = user.archived_with_subject("Via IMAP").await; + user.registry_destroy(ObjectType::ArchivedItem, [item.object_id()]) + .await + .assert_destroyed(&[item.object_id()]); + assert!( + user.archived() + .await + .iter() + .all(|i| i["subject"] != "Via IMAP"), + "test 10" + ); + + // Acceptance test 11: lowering retention doesn't move deadlines (UD-5) + let before = user.archived_with_subject("Via POP3").await["archivedUntil"].clone(); + admin.set_retention(Some(7 * DAY)).await; + assert_eq!( + user.archived_with_subject("Via POP3").await["archivedUntil"], + before, + "test 11" + ); + + // /changes (a fork addition) + let since = user.archive_state().await; + let id = import(&client, "For changes", &[INBOX_ID], &[]).await; + client.email_destroy(&id).await.unwrap(); + test.wait_for_tasks().await; + let changes = user + .jmap_method_call( + "x:ArchivedItem/changes", + json!({"accountId": user.id_string(), "sinceState": since}), + ) + .await; + assert_eq!( + changes.method_response()["created"] + .as_array() + .map(|a| a.len()), + Some(1), + "/changes: {changes:?}" + ); + + // /query filters (a fork addition) + let found = user + .registry_query_ids( + ObjectType::ArchivedItem, + [(Property::Text, "changes")], + Vec::<&str>::new(), + ) + .await; + assert_eq!(found.len(), 1, "query text"); + + // Acceptance test 15: nobody else sees the archive (UD-7) + let other = admin + .create_user_account("other@example.org", SECRET, "Other", &[], vec![]) + .await; + assert_eq!( + other + .jmap_method_call( + "x:ArchivedItem/get", + json!({"accountId": user.id_string(), "ids": null}), + ) + .await + .method_response() + .text_field("type"), + "forbidden", + "test 15" + ); + + // Clean up + admin.set_retention(None).await; + for item in user.archived().await { + user.registry_destroy(ObjectType::ArchivedItem, [item.object_id()]) + .await; + } + admin.destroy_account(other).await; + admin.destroy_account(user).await; + test.wait_for_tasks().await; +} + +/// Runs the undelete tests alone: +/// `cargo test -p tests undelete_tests -- --ignored`. +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn undelete_tests() { + let mut test = TestServerBuilder::new("undelete_tests") + .await + .with_default_listeners() + .await + .with_object(Imap { + allow_plain_text_auth: true, + ..Default::default() + }) + .await + .with_object(MtaStageAuth { + require: Expression { + else_: "false".to_string(), + ..Default::default() + }, + ..Default::default() + }) + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.org").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} + +fn seconds(value: &Value) -> i64 { + value + .as_str() + .and_then(|s| s.parse::().ok()) + .map(|d| d.timestamp()) + .unwrap_or_default() +} + +async fn import(client: &Client, subject: &str, mailboxes: &[u32], keywords: &[&str]) -> String { + client + .email_import( + format!( + "From: sender@example.org\r\nTo: undelete@example.org\r\nSubject: {subject}\r\n\r\nBody of {subject}.\r\n" + ) + .into_bytes(), + mailboxes.iter().map(|id| Id::from(*id).to_string()), + if keywords.is_empty() { + None + } else { + Some(keywords.to_vec()) + }, + None, + ) + .await + .unwrap() + .take_id() +} + +impl Account { + async fn set_retention(&self, keep_for: Option) { + self.registry_update_setting( + DataRetention { + archive_deleted_items_for: keep_for.map(|secs| Duration::from_millis(secs * 1000)), + ..Default::default() + }, + &[Property::ArchiveDeletedItemsFor], + ) + .await; + } + + async fn archived(&self) -> Vec { + self.jmap_method_call( + "x:ArchivedItem/get", + json!({"accountId": self.id_string(), "ids": null}), + ) + .await + .method_response()["list"] + .as_array() + .cloned() + .unwrap_or_default() + } + + async fn archived_with_subject(&self, subject: &str) -> Value { + let archived = self.archived().await; + archived + .into_iter() + .find(|item| item["subject"] == subject) + .unwrap_or_else(|| panic!("{subject} isn't archived")) + } + + async fn archive_state(&self) -> String { + self.jmap_method_call( + "x:ArchivedItem/get", + json!({"accountId": self.id_string(), "ids": []}), + ) + .await + .method_response()["state"] + .as_str() + .unwrap_or_default() + .to_string() + } + + async fn request_restore(&self, id: Id) { + self.registry_update_object( + ObjectType::ArchivedItem, + id, + json!({ Property::Status: "requestRestore" }), + ) + .await; + } + + async fn inbox_ids(&self) -> Vec { + self.jmap_method_call( + "Email/query", + json!({ + "accountId": self.id_string(), + "filter": {"inMailbox": Id::from(INBOX_ID).to_string()} + }), + ) + .await + .method_response()["ids"] + .as_array() + .map(|ids| { + ids.iter() + .map(|id| id.as_str().unwrap().to_string()) + .collect() + }) + .unwrap_or_default() + } + + async fn emails_with_subject(&self, subject: &str) -> Vec { + let response = self + .jmap_method_calls(json!([ + ["Email/query", { + "accountId": self.id_string(), + "filter": {"subject": subject} + }, "q"], + ["Email/get", { + "accountId": self.id_string(), + "#ids": {"resultOf": "q", "name": "Email/query", "path": "/ids"}, + "properties": ["id", "subject", "mailboxIds", "keywords"] + }, "g"] + ])) + .await; + response.0["methodResponses"][1][1]["list"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|email| email["subject"] == subject) + .collect() + } + + async fn email_with_subject(&self, subject: &str) -> Value { + self.emails_with_subject(subject) + .await + .into_iter() + .next() + .unwrap_or_else(|| panic!("no email with subject {subject}")) + } + + async fn count_with_subject(&self, subject: &str) -> usize { + self.emails_with_subject(subject).await.len() + } + + async fn email_set_mailboxes(&self, id: &str, mailboxes: &[u32]) { + let ids = mailboxes + .iter() + .map(|id| (Id::from(*id).to_string(), Value::Bool(true))) + .collect::>(); + self.jmap_method_call( + "Email/set", + json!({"accountId": self.id_string(), "update": {id: {"mailboxIds": ids}}}), + ) + .await + .updated(id); + } + + async fn create_mailbox(&self, name: &str) -> u32 { + let response = self + .jmap_method_call( + "Mailbox/set", + json!({"accountId": self.id_string(), "create": {"i0": {"name": name}}}), + ) + .await; + response.created_id(0).document_id() + } + + async fn destroy_mailbox(&self, id: u32) { + self.jmap_method_call( + "Mailbox/set", + json!({ + "accountId": self.id_string(), + "destroy": [Id::from(id).to_string()], + "onDestroyRemoveEmails": true + }), + ) + .await; + } +}