From 4a631bd0b5e0bd9e7b0ff2818744f79fcfdd12b2 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 18 Sep 2026 20:05:56 -0700 Subject: [PATCH] Undelete: deleted email is kept, restored where it was, and managed over x:ArchivedItem (UD-1 to UD-14 for email) Every way of deleting mail for good (JMAP, IMAP expunge, POP3, Trash emptying, mailbox removal) notes the message's mailboxes and keywords while archiving is on, fixing its deadline then; when its data is finally removed it becomes an x:ArchivedItem record, written as upstream writes them, with its copy held until the deadline. Retention is read at deletion time, so a change applies at once. Restore puts a message back in the mailboxes it was in (Trash only if that was all), with its keywords, and removes the record; over quota it stays archived. x:ArchivedItem/get returns status and accountId; query filters on type, archivedAt and text; set requests a restore once or destroys; /changes is a fork addition. Expired items go in the data purge. The shared account-access rule moves to jmap::inbuxa::access. system_tests now calls undelete::test, and the archiving gate is gone. --- Cargo.lock | 3 + crates/email/src/mailbox/destroy.rs | 18 + crates/email/src/message/delete.rs | 18 + crates/features/Cargo.toml | 2 + crates/features/src/lib.rs | 1 + crates/features/src/undelete/data.rs | 413 +++++++++++++++ crates/features/src/undelete/email.rs | 177 +++++++ crates/features/src/undelete/mod.rs | 19 + crates/features/src/undelete/records.rs | 232 +++++++++ crates/features/src/undelete/settings.rs | 39 ++ crates/imap/Cargo.toml | 1 + crates/imap/src/op/expunge.rs | 18 + crates/jmap-proto/src/request/method.rs | 6 +- crates/jmap/src/api/request.rs | 16 +- crates/jmap/src/inbuxa/access.rs | 40 ++ crates/jmap/src/inbuxa/masked_email.rs | 30 +- crates/jmap/src/inbuxa/mod.rs | 2 + crates/jmap/src/inbuxa/undelete.rs | 376 ++++++++++++++ crates/jmap/src/registry/get.rs | 4 + crates/jmap/src/registry/mod.rs | 3 +- crates/jmap/src/registry/query.rs | 10 + crates/jmap/src/registry/set.rs | 5 + crates/services/src/task_manager/index.rs | 22 +- .../services/src/task_manager/maintenance.rs | 8 + .../services/src/task_manager/restore_item.rs | 68 ++- tests/src/system/mod.rs | 4 +- tests/src/system/undelete.rs | 482 ++++++++++++++++++ 27 files changed, 1965 insertions(+), 52 deletions(-) create mode 100644 crates/features/src/undelete/data.rs create mode 100644 crates/features/src/undelete/email.rs create mode 100644 crates/features/src/undelete/mod.rs create mode 100644 crates/features/src/undelete/records.rs create mode 100644 crates/features/src/undelete/settings.rs create mode 100644 crates/jmap/src/inbuxa/access.rs create mode 100644 crates/jmap/src/inbuxa/undelete.rs create mode 100644 tests/src/system/undelete.rs 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; + } +}