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:
@@ -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"] }
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
|
||||
pub mod masked_email;
|
||||
pub mod tenancy;
|
||||
pub mod undelete;
|
||||
|
||||
@@ -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(¬e).serialize().unwrap();
|
||||
assert_eq!(Json::<EmailNote>::deserialize(&bytes).unwrap().0, note);
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user