Files
inbuxa-server/crates/features/src/undelete/data.rs
T
jcoffey-dev 5b963b06c6 Undelete: deleted files, calendar events, contacts and Sieve scripts are kept and restored where they were (UD-1, UD-8 to UD-11)
Files, events and contacts are noted when deleted for good and archived by
the unindex task when retention is on; Sieve scripts are archived at
deletion. A restore goes back to its folder, calendar or address book if it
still exists, takes a free " (restored)" name, comes back inactive for
scripts, and is refused over quota with the item left archived.
Acceptance tests 6, 8 and 12.
2026-09-18 21:07:27 -07:00

416 lines
12 KiB
Rust

/*
* 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>,
#[serde(default)]
size: u32,
},
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);
}
}