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.
This commit is contained in:
2026-09-18 20:05:56 -07:00
parent a1ce14b76b
commit 4a631bd0b5
27 changed files with 1965 additions and 52 deletions
Generated
+3
View File
@@ -3896,6 +3896,7 @@ dependencies = [
"directory", "directory",
"email", "email",
"imap_proto", "imap_proto",
"inbuxa-features",
"mail-parser", "mail-parser",
"md5", "md5",
"nlp", "nlp",
@@ -3964,6 +3965,8 @@ dependencies = [
"ahash", "ahash",
"jmap_proto", "jmap_proto",
"registry", "registry",
"serde",
"serde_json",
"store", "store",
"tokio", "tokio",
"trc", "trc",
+18
View File
@@ -90,6 +90,10 @@ impl MailboxDestroy for Server {
let mut deleted_ids = RoaringBitmap::new(); let mut deleted_ids = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items;
self.archives( self.archives(
account_id, account_id,
Collection::Email, Collection::Email,
@@ -118,6 +122,20 @@ impl MailboxDestroy for Server {
} }
deleted_ids.insert(message_id); deleted_ids.insert(message_id);
thread_ids.insert(prev_message_data.inner.thread_id.to_native()); thread_ids.insert(prev_message_data.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
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 batch
.with_collection(Collection::Email) .with_collection(Collection::Email)
.with_document(message_id) .with_document(message_id)
+18
View File
@@ -67,6 +67,10 @@ impl EmailDeletion for Server {
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::Email); .with_collection(Collection::Email);
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items;
self.archives( self.archives(
account_id, account_id,
Collection::Email, Collection::Email,
@@ -83,6 +87,20 @@ impl EmailDeletion for Server {
); );
} }
thread_ids.insert(metadata.inner.thread_id.to_native()); thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
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 batch
.with_document(document_id) .with_document(document_id)
.custom( .custom(
+2
View File
@@ -13,6 +13,8 @@ trc = { path = "../trc" }
types = { path = "../types" } types = { path = "../types" }
utils = { path = "../utils" } utils = { path = "../utils" }
ahash = { version = "0.8.12", features = ["serde"] } ahash = { version = "0.8.12", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.53", features = ["macros", "rt"] } tokio = { version = "1.53", features = ["macros", "rt"] }
+1
View File
@@ -20,3 +20,4 @@
pub mod masked_email; pub mod masked_email;
pub mod tenancy; pub mod tenancy;
pub mod undelete;
+413
View File
@@ -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<ValueClass> {
ValueKey::from(class(kind, rest))
}
/// A value stored as JSON.
pub struct Json<T>(pub T);
impl<T: SerdeSerialize> Serialize for Json<T> {
fn serialize(&self) -> trc::Result<Vec<u8>> {
serde_json::to_vec(&self.0).map_err(|err| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to serialize undelete record")
.reason(err)
})
}
}
impl<T: DeserializeOwned + Sync + Send> Deserialize for Json<T> {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid undelete record")
.reason(err)
})
}
}
async fn get<T: DeserializeOwned + Sync + Send + 'static>(
data: &Store,
key: ValueKey<ValueClass>,
) -> trc::Result<Option<T>> {
data.get_value::<Json<T>>(key)
.await
.map(|value| value.map(|Json(value)| value))
.caused_by(trc::location!())
}
fn set<T: SerdeSerialize>(
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<u8> {
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<u32>,
pub keywords: Vec<String>,
}
/// 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<u32>,
keywords: Vec<String>,
},
FileNode {
parent_id: Option<u32>,
name: String,
media_type: Option<String>,
},
CalendarEvent {
calendar_ids: Vec<u32>,
name: String,
},
ContactCard {
address_book_ids: Vec<u32>,
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<u8>,
pub name: String,
pub addresses: Vec<String>,
pub member_tenant_id: Option<u64>,
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<Option<EmailNote>> {
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<Option<Extra>> {
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<Option<Id>> {
data.get_value::<u64>(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<bool> {
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<Vec<(u64, Id, Change)>> {
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<u64> {
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<Option<KeptAccount>> {
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<Vec<(u32, KeptAccount)>> {
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::<KeptAccount>::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<Option<u32>> {
data.get_value::<u32>(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::<Extra>::deserialize(&bytes).unwrap().0, extra);
let note = EmailNote {
archived_at: 1,
archived_until: 2,
size: 3,
mailboxes: vec![1],
keywords: vec![],
};
let bytes = Json(&note).serialize().unwrap();
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
}
}
+177
View File
@@ -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<u32>,
keywords: Vec<String>,
) -> 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<Item = String>) -> Vec<String> {
keywords
.into_iter()
.filter(|keyword| !keyword.eq_ignore_ascii_case("$deleted"))
.collect()
}
/// What the message's stored summary says, for the archived record.
pub struct Summary<'x> {
pub blob_hash: BlobHash,
pub from: Option<&'x str>,
pub subject: Option<&'x str>,
pub received_at: u64,
}
/// A message's data is being removed: if it was noted at deletion, it
/// becomes an archived item, and its kept copy is held until the deadline.
/// Returns whether it was archived.
pub async fn archive(
data: &Store,
registry: &RegistryStore,
account_id: u32,
document_id: u32,
summary: Summary<'_>,
) -> trc::Result<bool> {
let Some(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<u32> {
let only_trash = original.len() == 1 && original[0] == trash;
let mailboxes = original
.iter()
.copied()
.filter(|id| exists(*id) && (only_trash || *id != trash))
.collect::<Vec<_>>();
if mailboxes.is_empty() {
vec![inbox]
} else {
mailboxes
}
}
#[cfg(test)]
mod tests {
use super::*;
const INBOX: u32 = 0;
const TRASH: u32 = 1;
#[test]
fn back_where_it_was() {
// Acceptance test 4: both labels come back
assert_eq!(
restore_mailboxes(&[0, 7], |_| true, INBOX, TRASH),
vec![0, 7]
);
// Acceptance test 5: mailboxes gone, so Inbox
assert_eq!(
restore_mailboxes(&[7, 8], |_| false, INBOX, TRASH),
vec![INBOX]
);
assert_eq!(
restore_mailboxes(&[7, 8], |id| id == 8, INBOX, TRASH),
vec![8]
);
}
#[test]
fn trash_only_if_that_was_all() {
assert_eq!(
restore_mailboxes(&[TRASH], |_| true, INBOX, TRASH),
vec![TRASH]
);
assert_eq!(
restore_mailboxes(&[TRASH, 7], |_| true, INBOX, TRASH),
vec![7]
);
assert_eq!(restore_mailboxes(&[], |_| true, INBOX, TRASH), vec![INBOX]);
}
#[test]
fn deleted_keyword_isnt_kept() {
assert_eq!(
keywords_to_keep(["$seen".to_string(), "$Deleted".to_string()]),
vec!["$seen".to_string()]
);
}
}
+19
View File
@@ -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;
+232
View File
@@ -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<Id> {
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<Vec<(Id, ArchivedItem)>> {
let mut items = Vec::new();
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem).with_account(account_id))
.await
.caused_by(trc::location!())?
{
if let Some(item) = registry.object::<ArchivedItem>(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<Option<ArchivedItem>> {
match registry.object::<ArchivedItem>(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<usize> {
let mut removed = 0;
for id in registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::ArchivedItem))
.await
.caused_by(trc::location!())?
{
if let Some(item) = registry.object::<ArchivedItem>(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<Option<(Id, ArchivedItem, Option<crate::undelete::data::Extra>)>> {
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)))
}
+39
View File
@@ -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<u64>,
/// `archiveDeletedAccountsFor` (UD-15).
pub accounts: Option<u64>,
}
/// The retention in force now.
pub async fn retention(registry: &RegistryStore) -> trc::Result<Retention> {
let settings = registry
.object::<DataRetention>(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),
})
}
+1
View File
@@ -14,6 +14,7 @@ email = { path = "../email" }
nlp = { path = "../nlp" } nlp = { path = "../nlp" }
utils = { path = "../utils" } utils = { path = "../utils" }
registry = { path = "../registry" } registry = { path = "../registry" }
inbuxa-features = { path = "../features" }
mail-parser = { version = "0.11", features = ["full_encoding"] } mail-parser = { version = "0.11", features = ["full_encoding"] }
tokio = { version = "1.53", features = ["full"] } tokio = { version = "1.53", features = ["full"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
+18
View File
@@ -223,6 +223,10 @@ impl<T: SessionStream> SessionData<T> {
let mut fully_deleted = RoaringBitmap::new(); let mut fully_deleted = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new(); let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.server.registry())
.await?
.items;
self.server self.server
.archives( .archives(
account_id, account_id,
@@ -245,6 +249,20 @@ impl<T: SessionStream> SessionData<T> {
// Delete message // Delete message
fully_deleted.insert(document_id); fully_deleted.insert(document_id);
thread_ids.insert(metadata.inner.thread_id.to_native()); thread_ids.insert(metadata.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
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 batch
.custom( .custom(
ObjectIndexBuilder::<_, ()>::new() ObjectIndexBuilder::<_, ()>::new()
+4 -2
View File
@@ -371,8 +371,10 @@ impl MethodName {
"query" => MethodFunction::Query, "query" => MethodFunction::Query,
"changes" => MethodFunction::Changes, "changes" => MethodFunction::Changes,
)?; )?;
// inbuxa: only masked email has /changes (a fork addition) // inbuxa: only masked email and undelete have /changes (fork additions)
if fnc == MethodFunction::Changes && obj != ObjectType::MaskedEmail { if fnc == MethodFunction::Changes
&& !matches!(obj, ObjectType::MaskedEmail | ObjectType::ArchivedItem)
{
return None; return None;
} }
+13 -3
View File
@@ -609,8 +609,12 @@ impl RequestHandler for Server {
RequestMethod::Changes(mut req) => { RequestMethod::Changes(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
// inbuxa: x:MaskedEmail/changes // inbuxa: x:MaskedEmail/changes and x:ArchivedItem/changes
if matches!(method_name.obj, MethodObject::Registry(_)) { 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? crate::inbuxa::masked_email::changes(self, access_token, *req).await?
} else { } else {
self.changes(*req, method_name.obj, access_token) self.changes(*req, method_name.obj, access_token)
@@ -750,7 +754,13 @@ async fn assert_registry_account(
access_token: &AccessToken, access_token: &AccessToken,
account_id: Id, account_id: Id,
) -> trc::Result<()> { ) -> 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( crate::inbuxa::masked_email::assert_can_manage(
server, server,
access_token, access_token,
+40
View File
@@ -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))))
}
}
+1 -29
View File
@@ -49,35 +49,7 @@ pub enum CreateRefusal {
RateLimited, RateLimited,
} }
/// ME-18, ME-19: who may manage an account's masks. The account itself (or pub use crate::inbuxa::access::assert_can_manage;
/// 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)
)))
}
}
/// The domains an account may have masks on, as (id, name): every domain /// 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). /// and alias domain it's linked to, its primary domain first (ME-12).
+2
View File
@@ -7,5 +7,7 @@
//! JMAP glue for INBUXA's rebuilt features. The features' rules live in //! JMAP glue for INBUXA's rebuilt features. The features' rules live in
//! `crates/features`; this module only speaks JMAP for them. //! `crates/features`; this module only speaks JMAP for them.
pub mod access;
pub mod fastmail; pub mod fastmail;
pub mod masked_email; pub mod masked_email;
pub mod undelete;
+376
View File
@@ -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<JmapState> {
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<JmapValue<'static>> {
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<RegistryGetResponse<'_>> {
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<QueryResponseBuilder> {
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::<Vec<_>>();
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<RegistrySetResponse<'_>> {
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<jmap_proto::response::ResponseMethod<'static>> {
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::<Vec<_>>();
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,
}),
)))
}
+4
View File
@@ -386,6 +386,10 @@ impl RegistryGet for Server {
| ObjectType::AccountPassword | ObjectType::AccountPassword
| ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()), | ObjectType::AppPassword => account_get(get).await.map(|get| get.into_response()),
ObjectType::Action => Ok(get.not_found_any().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"))] #[cfg(not(feature = "enterprise"))]
_ => Ok(get.not_found_any().into_response()), _ => Ok(get.not_found_any().into_response()),
} }
+1 -2
View File
@@ -20,8 +20,7 @@ impl EnterpriseRegistry for Server {
fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> { fn assert_enterprise_object(&self, object_type: ObjectType) -> trc::Result<()> {
if !matches!( if !matches!(
object_type, object_type,
ObjectType::ArchivedItem ObjectType::Metric
| ObjectType::Metric
| ObjectType::Trace | ObjectType::Trace
) { ) {
return Ok(()); return Ok(());
+10
View File
@@ -131,6 +131,16 @@ impl RegistryQuery for Server {
.await .await
.and_then(|response| response.build()), .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) // inbuxa: filters on enabled, forDomain and text (masked email)
ObjectType::MaskedEmail => crate::inbuxa::masked_email::query(RegistryQueryResponse { ObjectType::MaskedEmail => crate::inbuxa::masked_email::query(RegistryQueryResponse {
server: self, server: self,
+5
View File
@@ -817,6 +817,11 @@ impl RegistrySet for Server {
ObjectType::Task => task_set(set).await.map(|set| set.into_response()), 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)) ObjectType::Action => Box::pin(action_set(set))
.await .await
.map(|set| set.into_response()), .map(|set| set.into_response()),
+21 -1
View File
@@ -6,7 +6,11 @@
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult}; use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult};
use common::Server; 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 groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard};
use registry::{ use registry::{
schema::{ schema::{
@@ -593,6 +597,22 @@ async fn delete_email_metadata(
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
metadata.unindex(batch); 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 => { None => {
trc::event!( trc::event!(
@@ -227,6 +227,14 @@ async fn store_maintenance(
.await .await
.caused_by(trc::location!())?; .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!( trc::event!(
Store(StoreEvent::DataStorePurged), Store(StoreEvent::DataStorePurged),
Elapsed = started.elapsed() Elapsed = started.elapsed()
@@ -6,12 +6,15 @@
use common::{Server, auth::BuildAccessToken}; use common::{Server, auth::BuildAccessToken};
use email::{ use email::{
mailbox::INBOX_ID, cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
mailbox::{INBOX_ID, TRASH_ID},
message::ingest::{EmailIngest, IngestEmail, IngestSource}, message::ingest::{EmailIngest, IngestEmail, IngestSource},
}; };
use inbuxa_features::undelete;
use types::keyword::Keyword;
use mail_parser::MessageParser; use mail_parser::MessageParser;
use registry::schema::{enums::ArchivedItemType, structs::TaskRestoreArchivedItem}; use registry::schema::{enums::ArchivedItemType, structs::TaskRestoreArchivedItem};
use store::write::{BatchBuilder, BlobLink, BlobOp}; use store::write::BatchBuilder;
use trc::AddContext; use trc::AddContext;
use crate::task_manager::TaskResult; use crate::task_manager::TaskResult;
@@ -48,6 +51,40 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R
.await .await
.caused_by(trc::location!())?; .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 let Some(bytes) = server
.blob_store() .blob_store()
.get_blob(task.blob_id.hash.as_slice(), 0..usize::MAX) .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), message: MessageParser::new().parse(&bytes),
blob_hash: Some(&task.blob_id.hash), blob_hash: Some(&task.blob_id.hash),
access_token: &access_token.build(), access_token: &access_token.build(),
mailbox_ids: vec![INBOX_ID], mailbox_ids,
keywords: vec![], keywords,
received_at: (task.created_at.timestamp() as u64).into(), received_at: (task.created_at.timestamp() as u64).into(),
source: IngestSource::Restore, source: IngestSource::Restore,
session_id: 0, session_id: 0,
@@ -71,17 +108,22 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R
.await .await
{ {
Ok(_) => { Ok(_) => {
let mut batch = BatchBuilder::new(); // inbuxa: UD-9: the archived record goes, and its copy is released
batch.with_account_id(account_id).clear(BlobOp::Link { undelete::records::remove(data, server.registry(), item_id, &item).await?;
hash: task.blob_id.hash.clone(),
to: BlobLink::Temporary {
until: task.archived_until.timestamp() as u64,
},
});
server.store().write(batch.build_all()).await?;
Ok(TaskResult::Success(vec![])) 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) Err(mut err)
if err.matches(trc::EventType::MessageIngest( if err.matches(trc::EventType::MessageIngest(
trc::MessageIngestEvent::Error, trc::MessageIngestEvent::Error,
+2 -2
View File
@@ -17,6 +17,7 @@ pub mod quota;
pub mod security; pub mod security;
pub mod task; pub mod task;
pub mod tenant; pub mod tenant;
pub mod undelete;
use crate::utils::server::TestServerBuilder; use crate::utils::server::TestServerBuilder;
use registry::schema::structs::{Expression, Imap, MtaStageAuth}; use registry::schema::structs::{Expression, Imap, MtaStageAuth};
@@ -70,8 +71,7 @@ pub async fn system_tests() {
delivery::test(&mut test).await; delivery::test(&mut test).await;
crypto::test(&mut test).await; crypto::test(&mut test).await;
antispam::test(&mut test).await; antispam::test(&mut test).await;
#[cfg(feature = "pending-rebuild")] // inbuxa: pending-rebuild, see docs/spec/features/undelete.md undelete::test(&mut test).await;
archiving::test(&mut test).await;
task::test(&mut test).await; task::test(&mut test).await;
if test.is_reset() { if test.is_reset() {
+482
View File
@@ -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("[email protected]");
let user = admin
.create_user_account("[email protected]", 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("[email protected]", 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("[email protected]", 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("[email protected]", 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("[email protected]").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::<registry::types::datetime::UTCDateTime>().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: [email protected]\r\nTo: [email protected]\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<u64>) {
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<Value> {
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<String> {
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<Value> {
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::<serde_json::Map<_, _>>();
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;
}
}