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.
This commit is contained in:
2026-09-18 21:07:27 -07:00
parent 4a631bd0b5
commit 5b963b06c6
16 changed files with 946 additions and 1 deletions
Generated
+1
View File
@@ -3032,6 +3032,7 @@ dependencies = [
"icu_locale_core", "icu_locale_core",
"icu_plurals", "icu_plurals",
"icu_provider", "icu_provider",
"inbuxa-features",
"indexmap 2.14.2", "indexmap 2.14.2",
"nlp", "nlp",
"percent-encoding", "percent-encoding",
+27
View File
@@ -42,6 +42,33 @@ impl SieveScriptDelete for Server {
)) ))
.await? .await?
{ {
// inbuxa: UD-1: a deleted script is kept, when archiving is on
if let Some(retention) =
inbuxa_features::undelete::settings::retention(self.registry())
.await?
.items
{
let script = obj_
.deserialize::<SieveScript>()
.caused_by(trc::location!())?;
let content = self
.blob_store()
.get_blob(script.blob_hash.as_slice(), 0..usize::MAX)
.await?
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default();
inbuxa_features::undelete::groupware::archive_sieve(
&self.core.storage.data,
self.registry(),
account_id,
&script.name,
content,
script.blob_hash.clone(),
retention,
)
.await?;
}
// Delete record // Delete record
batch batch
.with_account_id(account_id) .with_account_id(account_id)
+2
View File
@@ -137,6 +137,8 @@ pub enum Extra {
parent_id: Option<u32>, parent_id: Option<u32>,
name: String, name: String,
media_type: Option<String>, media_type: Option<String>,
#[serde(default)]
size: u32,
}, },
CalendarEvent { CalendarEvent {
calendar_ids: Vec<u32>, calendar_ids: Vec<u32>,
+282
View File
@@ -0,0 +1,282 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Deleted files, calendar events and contacts (UD-1, a deliberate
//! extension: upstream archives only email).
//!
//! The code that deletes them for good (over JMAP and WebDAV alike) builds
//! its changes without reaching the settings, so it notes every deletion:
//! the content (a calendar or contact's text, or a file's blob) and where
//! the item lived. The task that then removes the item from the search index
//! archives the note if archiving is on, or drops it.
use crate::undelete::{
data::{Extra, Json},
records,
};
use registry::{
schema::structs::{ArchivedCalendarEvent, ArchivedContactCard, ArchivedFileNode, ArchivedItem},
types::datetime::UTCDateTime,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
RegistryStore, Serialize, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass, now},
};
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
/// The kinds noted here, as stored in the note's key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Kind {
File = 0,
CalendarEvent = 1,
ContactCard = 2,
}
/// A deleted item, noted at deletion.
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
pub struct Note {
pub deleted_at: u64,
pub created_at: i64,
/// A calendar event or contact's text.
pub content: Option<String>,
/// A file's content, already a blob.
pub blob_hash: Option<Vec<u8>>,
pub extra: Extra,
}
fn note_class(kind: Kind, account_id: u32, document_id: u32) -> ValueClass {
let mut key = Vec::with_capacity(11);
key.extend_from_slice(b"Ug");
key.push(kind as u8);
key.extend_from_slice(&account_id.to_be_bytes());
key.extend_from_slice(&document_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: store::SUBSPACE_INBUXA,
key,
})
}
/// Notes an item deleted for good (UD-1).
pub fn note(
batch: &mut BatchBuilder,
kind: Kind,
account_id: u32,
document_id: u32,
mut note: Note,
) -> trc::Result<()> {
note.deleted_at = now();
batch.set(
note_class(kind, account_id, document_id),
Json(&note).serialize()?,
);
Ok(())
}
/// Takes an item's note, if it has one.
pub async fn take(
data: &Store,
kind: Kind,
account_id: u32,
document_id: u32,
) -> trc::Result<Option<Note>> {
let class = note_class(kind, account_id, document_id);
let Some(Json(note)) = data
.get_value::<Json<Note>>(ValueKey::from(class.clone()))
.await?
else {
return Ok(None);
};
let mut batch = BatchBuilder::new();
batch.clear(class);
data.write(batch.build_all()).await?;
Ok(Some(note))
}
/// The value of the first line starting with `name` (as `NAME:` or
/// `NAME;params:`) in iCalendar or vCard text, unfolded.
fn property(text: &str, name: &str) -> Option<String> {
let mut lines = text.split("\r\n").flat_map(|l| l.split('\n')).peekable();
while let Some(line) = lines.next() {
let Some(rest) = line
.get(..name.len())
.filter(|head| head.eq_ignore_ascii_case(name))
.map(|_| &line[name.len()..])
else {
continue;
};
if !(rest.starts_with(':') || rest.starts_with(';')) {
continue;
}
let mut value = rest.split_once(':')?.1.to_string();
while let Some(next) = lines.peek() {
if let Some(continued) = next.strip_prefix(' ').or_else(|| next.strip_prefix('\t')) {
value.push_str(continued);
lines.next();
} else {
break;
}
}
return Some(value.replace("\\,", ",").replace("\\;", ";").replace("\\n", " "));
}
None
}
/// An iCalendar date or date-time (`20260918`, `20260918T100000Z`) as a
/// Unix timestamp, read as UTC.
fn ical_time(value: &str) -> Option<i64> {
let digits = value.trim().trim_end_matches('Z');
let (date, time) = digits.split_once('T').unwrap_or((digits, "000000"));
if date.len() != 8 || time.len() < 6 {
return None;
}
let n = |s: &str| s.parse::<i64>().ok();
let (y, m, d) = (n(&date[..4])?, n(&date[4..6])?, n(&date[6..8])?);
let (hh, mm, ss) = (n(&time[..2])?, n(&time[2..4])?, n(&time[4..6])?);
// Days from the civil date (Howard Hinnant's algorithm)
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
}
/// Archives a noted item: holds its kept copy (`blob_hash`) until the
/// deadline and writes its record (UD-1, UD-4, UD-5).
pub async fn archive(
data: &Store,
registry: &RegistryStore,
account_id: u32,
note: Note,
blob_hash: BlobHash,
retention: u64,
) -> trc::Result<Id> {
let archived_at = now();
let common = (
types::id::Id::from(account_id),
UTCDateTime::from_timestamp(archived_at as i64),
UTCDateTime::from_timestamp((archived_at + retention) as i64),
BlobId::new(blob_hash, Default::default()),
UTCDateTime::from_timestamp(note.created_at),
);
let text = note.content.as_deref().unwrap_or_default();
let item = match &note.extra {
Extra::FileNode { name, .. } => ArchivedItem::FileNode(ArchivedFileNode {
name: name.clone(),
created_at: common.4,
account_id: common.0,
archived_at: common.1,
archived_until: common.2,
blob_id: common.3,
}),
Extra::CalendarEvent { .. } => ArchivedItem::CalendarEvent(ArchivedCalendarEvent {
title: property(text, "SUMMARY").unwrap_or_default(),
start_time: property(text, "DTSTART")
.and_then(|v| ical_time(&v))
.map(UTCDateTime::from_timestamp),
created_at: common.4,
account_id: common.0,
archived_at: common.1,
archived_until: common.2,
blob_id: common.3,
}),
Extra::ContactCard { .. } => ArchivedItem::ContactCard(ArchivedContactCard {
name: property(text, "FN"),
created_at: common.4,
account_id: common.0,
archived_at: common.1,
archived_until: common.2,
blob_id: common.3,
}),
Extra::Email { .. } | Extra::SieveScript { .. } => {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Not a groupware item"));
}
};
records::insert(data, registry, &item, &note.extra).await
}
/// The names a restored item tries, in order: the original, then with
/// ` (restored)`, then numbered (UD-8).
pub fn candidates(name: &str) -> impl Iterator<Item = String> + '_ {
let (stem, ext) = match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
_ => (name, String::new()),
};
std::iter::once(name.to_string())
.chain(std::iter::once(format!("{stem} (restored){ext}")))
.chain((2u32..).map(move |n| format!("{stem} (restored {n}){ext}")))
}
/// The first free name for a restored item (UD-8).
pub fn free_name(name: &str, is_taken: impl Fn(&str) -> bool) -> String {
candidates(name).find(|candidate| !is_taken(candidate)).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_ical_and_vcard() {
let ics = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Team\r\n lunch\r\nDTSTART;TZID=X:20260918T120000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
assert_eq!(property(ics, "SUMMARY").as_deref(), Some("Team lunch"));
assert_eq!(
property(ics, "DTSTART").and_then(|v| ical_time(&v)),
Some(1_789_732_800)
);
let vcf = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEND:VCARD\r\n";
assert_eq!(property(vcf, "FN").as_deref(), Some("Jane Doe"));
assert_eq!(property(vcf, "N"), None);
}
#[test]
fn restored_names() {
assert_eq!(free_name("a.txt", |_| false), "a.txt");
assert_eq!(free_name("a.txt", |n| n == "a.txt"), "a (restored).txt");
assert_eq!(
free_name("a.txt", |n| n == "a.txt" || n == "a (restored).txt"),
"a (restored 2).txt"
);
assert_eq!(free_name("script", |n| n == "script"), "script (restored)");
}
}
/// Archives a Sieve script deleted for good (UD-1). Its content is already
/// a blob, held from now until the deadline.
pub async fn archive_sieve(
data: &Store,
registry: &RegistryStore,
account_id: u32,
name: &str,
content: String,
blob_hash: BlobHash,
retention: u64,
) -> trc::Result<Id> {
let archived_at = now();
let item = ArchivedItem::SieveScript(registry::schema::structs::ArchivedSieveScript {
name: name.to_string(),
created_at: UTCDateTime::from_timestamp(archived_at as i64),
content,
account_id: types::id::Id::from(account_id),
archived_at: UTCDateTime::from_timestamp(archived_at as i64),
archived_until: UTCDateTime::from_timestamp((archived_at + retention) as i64),
blob_id: BlobId::new(blob_hash, Default::default()),
});
records::insert(
data,
registry,
&item,
&Extra::SieveScript {
name: name.to_string(),
},
)
.await
}
+1
View File
@@ -15,5 +15,6 @@
pub mod data; pub mod data;
pub mod email; pub mod email;
pub mod groupware;
pub mod records; pub mod records;
pub mod settings; pub mod settings;
+1
View File
@@ -11,6 +11,7 @@ types = { path = "../types" }
trc = { path = "../trc" } trc = { path = "../trc" }
nlp = { path = "../nlp" } nlp = { path = "../nlp" }
registry = { path = "../registry" } registry = { path = "../registry" }
inbuxa-features = { path = "../features" }
calcard = { version = "0.3", features = ["rkyv"] } calcard = { version = "0.3", features = ["rkyv"] }
hashify = "0.2" hashify = "0.2"
rkyv = { version = "0.8.18", features = ["little_endian"] } rkyv = { version = "0.8.18", features = ["little_endian"] }
+9
View File
@@ -462,6 +462,15 @@ impl DestroyArchive<Archive<&ArchivedCalendarEvent>> {
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
) -> trc::Result<()> { ) -> trc::Result<()> {
let event = self.0; let event = self.0;
// inbuxa: UD-1: noted for undelete
crate::inbuxa::note_event(
batch,
account_id,
document_id,
&event
.deserialize::<CalendarEvent>()
.caused_by(trc::location!())?,
)?;
// Delete event // Delete event
batch batch
.with_account_id(account_id) .with_account_id(account_id)
+19
View File
@@ -234,6 +234,15 @@ impl DestroyArchive<Archive<&ArchivedContactCard>> {
) )
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
} else { } else {
// inbuxa: UD-1: noted for undelete
crate::inbuxa::note_card(
batch,
account_id,
document_id,
&card
.deserialize::<ContactCard>()
.caused_by(trc::location!())?,
)?;
// Delete card // Delete card
batch batch
.with_document(document_id) .with_document(document_id)
@@ -262,6 +271,16 @@ impl DestroyArchive<Archive<&ArchivedContactCard>> {
document_id: u32, document_id: u32,
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
) -> trc::Result<()> { ) -> trc::Result<()> {
// inbuxa: UD-1: noted for undelete
crate::inbuxa::note_card(
batch,
account_id,
document_id,
&self
.0
.deserialize::<ContactCard>()
.caused_by(trc::location!())?,
)?;
batch batch
.with_account_id(account_id) .with_account_id(account_id)
.with_collection(Collection::ContactCard) .with_collection(Collection::ContactCard)
+18
View File
@@ -82,6 +82,14 @@ impl DestroyArchive<Archive<&ArchivedFileNode>> {
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
path: String, path: String,
) -> trc::Result<()> { ) -> trc::Result<()> {
// inbuxa: UD-1: noted for undelete
crate::inbuxa::note_file(
batch,
account_id,
document_id,
&self.0.deserialize::<FileNode>().caused_by(trc::location!())?,
)?;
// Prepare write batch // Prepare write batch
batch batch
.with_account_id(account_id) .with_account_id(account_id)
@@ -143,6 +151,16 @@ impl DestroyArchive<Vec<u32>> {
)) ))
.await? .await?
{ {
// inbuxa: UD-1: noted for undelete
crate::inbuxa::note_file(
batch,
account_id,
document_id,
&node
.deserialize::<FileNode>()
.caused_by(trc::location!())?,
)?;
// Delete record // Delete record
batch batch
.with_document(document_id) .with_document(document_id)
+108
View File
@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Undelete notes for files, calendar events and contacts deleted for good
//! (`docs/spec/features/undelete.md`, UD-1). The rules are in
//! `inbuxa_features::undelete`.
use crate::{calendar::CalendarEvent, contact::ContactCard, file::FileNode};
use inbuxa_features::undelete::{
data::Extra,
groupware::{Kind, Note, note},
};
use registry::schema::{
enums::IndexDocumentType,
structs::{Task, TaskIndexDocument, TaskStatus},
};
use store::write::BatchBuilder;
/// A file (not a folder) deleted for good.
pub fn note_file(
batch: &mut BatchBuilder,
account_id: u32,
document_id: u32,
node: &FileNode,
) -> trc::Result<()> {
let Some(file) = &node.file else {
return Ok(());
};
// Files aren't search-indexed, so nothing else schedules the task that
// archives the note: schedule it here
batch.schedule_task(Task::UnindexDocument(TaskIndexDocument {
account_id: account_id.into(),
document_id: document_id.into(),
document_type: IndexDocumentType::File,
status: TaskStatus::now(),
}));
note(
batch,
Kind::File,
account_id,
document_id,
Note {
deleted_at: 0,
created_at: node.created,
content: None,
blob_hash: Some(file.blob_hash.as_slice().to_vec()),
extra: Extra::FileNode {
parent_id: Some(node.parent_id),
name: node.name.clone(),
media_type: file.media_type.clone(),
size: file.size,
},
},
)
}
/// A calendar event deleted for good.
pub fn note_event(
batch: &mut BatchBuilder,
account_id: u32,
document_id: u32,
event: &CalendarEvent,
) -> trc::Result<()> {
note(
batch,
Kind::CalendarEvent,
account_id,
document_id,
Note {
deleted_at: 0,
created_at: event.created,
content: Some(event.data.event.to_string()),
blob_hash: None,
extra: Extra::CalendarEvent {
calendar_ids: event.names.iter().map(|n| n.parent_id).collect(),
name: event.names.first().map(|n| n.name.clone()).unwrap_or_default(),
},
},
)
}
/// A contact deleted for good.
pub fn note_card(
batch: &mut BatchBuilder,
account_id: u32,
document_id: u32,
card: &ContactCard,
) -> trc::Result<()> {
note(
batch,
Kind::ContactCard,
account_id,
document_id,
Note {
deleted_at: 0,
created_at: card.created,
content: Some(card.card.to_string()),
blob_hash: None,
extra: Extra::ContactCard {
address_book_ids: card.names.iter().map(|n| n.parent_id).collect(),
name: card.names.first().map(|n| n.name.clone()).unwrap_or_default(),
},
},
)
}
+1
View File
@@ -16,6 +16,7 @@ pub mod cache;
pub mod calendar; pub mod calendar;
pub mod contact; pub mod contact;
pub mod file; pub mod file;
pub mod inbuxa; // inbuxa: undelete notes
pub mod scheduling; pub mod scheduling;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -0,0 +1,272 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Restoring archived files, calendar events, contacts and Sieve scripts
//! (`docs/spec/features/undelete.md`, UD-8 to UD-11). Email restores in
//! `restore_item.rs`.
use crate::task_manager::TaskResult;
use calcard::{Entry, Parser, common::timezone::Tz};
use common::{DavName, Server, auth::BuildAccessToken};
use email::sieve::{SieveScript, ingest::SieveScriptIngest};
use groupware::{
cache::GroupwareCache,
calendar::{CalendarEvent, CalendarEventData},
contact::ContactCard,
file::{FileNode, FileProperties},
};
use inbuxa_features::undelete::{self, data::Extra, groupware::candidates};
use registry::schema::structs::{ArchivedItem, TaskRestoreArchivedItem};
use store::write::BatchBuilder;
use trc::AddContext;
use types::{
collection::{Collection, SyncCollection},
id::Id,
};
/// The names already used under a parent in an account's resources.
fn names_under(resources: &common::DavResources, parent: Option<u32>) -> Vec<String> {
resources
.paths
.iter()
.filter(|path| path.parent_id == parent)
.map(|path| {
path.path
.rsplit('/')
.next()
.unwrap_or(&path.path)
.to_string()
})
.collect()
}
fn free(name: &str, taken: &[String]) -> String {
let name = if name.is_empty() { "restored" } else { name };
candidates(name)
.find(|candidate| !taken.iter().any(|t| t.eq_ignore_ascii_case(candidate)))
.unwrap()
}
/// Restores a file, event, contact or script. `Ok(None)` once restored;
/// `Ok(Some(reason))` when it can't be, and stays archived.
pub(crate) async fn restore_other(
server: &Server,
task: &TaskRestoreArchivedItem,
item_id: Id,
item: &ArchivedItem,
extra: Option<Extra>,
) -> trc::Result<Option<String>> {
let account_id = task.account_id.document_id();
let access_token = server.access_token(account_id).await?.build();
let account = server.account(account_id).await?;
let changed_by = access_token.account_tenant_ids();
let hash = &task.blob_id.hash;
let Some(bytes) = server
.blob_store()
.get_blob(hash.as_slice(), 0..usize::MAX)
.await?
else {
return Ok(Some("The kept copy is gone.".into()));
};
// UD-10: a restore counts against quota like anything new
if server
.has_available_quota(&account, bytes.len() as u64)
.await
.is_err()
{
return Ok(Some(
"Not restored: the account or its tenant is over quota.".into(),
));
}
let mut batch = BatchBuilder::new();
match (item, extra) {
(ArchivedItem::FileNode(archived), extra) => {
let (parent_id, name, media_type) = match extra {
Some(Extra::FileNode {
parent_id,
name,
media_type,
..
}) => (parent_id.unwrap_or(0), name, media_type),
_ => (0, archived.name.clone(), None),
};
let resources = server
.fetch_dav_resources(account_id, account_id, SyncCollection::FileNode)
.await?;
// Back to its folder if that still exists, else the root
let parent_id = if parent_id > 0
&& resources.container_resource_by_id(parent_id - 1).is_some()
{
parent_id
} else {
0
};
let parent = parent_id.checked_sub(1);
let name = free(&name, &names_under(&resources, parent));
let document_id = server
.store()
.assign_document_ids(account_id, Collection::FileNode, 1)
.await?;
FileNode {
parent_id,
name,
file: Some(FileProperties {
blob_hash: hash.clone(),
size: bytes.len() as u32,
media_type,
executable: false,
}),
created: archived.created_at.timestamp(),
..Default::default()
}
.insert(changed_by, account_id, document_id, false, true, &mut batch)?;
}
(ArchivedItem::CalendarEvent(_), extra) => {
let Entry::ICalendar(ical) = Parser::new(&String::from_utf8_lossy(&bytes)).entry()
else {
return Ok(Some("The kept event can't be read.".into()));
};
let (calendar_ids, name) = match extra {
Some(Extra::CalendarEvent { calendar_ids, name }) => (calendar_ids, name),
_ => (vec![], String::new()),
};
let resources = server
.fetch_dav_resources(account_id, account_id, SyncCollection::Calendar)
.await?;
// Back to its calendar if that still exists, else the default
let calendar_id = match calendar_ids
.into_iter()
.find(|id| resources.container_resource_by_id(*id).is_some())
{
Some(id) => id,
None => match server
.get_or_create_default_calendar(account_id, account_id)
.await?
{
Some(id) => id,
None => return Ok(Some("The account has no calendar.".into())),
},
};
let name = free(&name, &names_under(&resources, Some(calendar_id)));
let mut next_alarm = None;
let event = CalendarEvent {
names: vec![DavName {
name,
parent_id: calendar_id,
}],
data: CalendarEventData::new(
ical,
Tz::Floating,
server.core.groupware.max_ical_instances,
&mut next_alarm,
),
size: bytes.len() as u32,
..Default::default()
};
let document_id = server
.store()
.assign_document_ids(account_id, Collection::CalendarEvent, 1)
.await?;
event.insert(changed_by, account_id, document_id, next_alarm, &mut batch)?;
}
(ArchivedItem::ContactCard(_), extra) => {
let Entry::VCard(card) = Parser::new(&String::from_utf8_lossy(&bytes)).entry() else {
return Ok(Some("The kept contact can't be read.".into()));
};
let (address_book_ids, name) = match extra {
Some(Extra::ContactCard {
address_book_ids,
name,
}) => (address_book_ids, name),
_ => (vec![], String::new()),
};
let resources = server
.fetch_dav_resources(account_id, account_id, SyncCollection::AddressBook)
.await?;
// Back to its address book if that still exists, else the first
let book_id = match address_book_ids
.into_iter()
.find(|id| resources.container_resource_by_id(*id).is_some())
{
Some(id) => id,
None => match resources.document_ids(true).next() {
Some(id) => id,
None => match server.create_default_addressbook(&account, &account).await? {
Some(id) => id,
None => return Ok(Some("The account has no address book.".into())),
},
},
};
let name = free(&name, &names_under(&resources, Some(book_id)));
let document_id = server
.store()
.assign_document_ids(account_id, Collection::ContactCard, 1)
.await?;
ContactCard {
names: vec![DavName {
name,
parent_id: book_id,
}],
card,
size: bytes.len() as u32,
..Default::default()
}
.insert(changed_by, account_id, document_id, &mut batch)?;
}
(ArchivedItem::SieveScript(archived), _) => {
// Back as an inactive script, never activated (UD-8)
let mut name = None;
for candidate in candidates(&archived.name).take(100) {
if server
.sieve_script_get_by_name(account_id, &candidate)
.await?
.is_none()
{
name = Some(candidate);
break;
}
}
let Some(name) = name else {
return Ok(Some("No free script name.".into()));
};
let document_id = server
.store()
.assign_document_ids(account_id, Collection::SieveScript, 1)
.await?;
batch
.with_account_id(account_id)
.with_collection(Collection::SieveScript)
.with_document(document_id)
.custom(
common::storage::index::ObjectIndexBuilder::<(), _>::new()
.with_changes(
SieveScript::new(name, hash.clone()).with_size(bytes.len() as u32),
)
.with_changed_by(changed_by),
)
.caused_by(trc::location!())?;
}
(ArchivedItem::Email(_), _) => return Ok(Some("Not an email restore.".into())),
}
server.commit_batch(batch).await?;
// UD-9: the record goes once the item is back
undelete::records::remove(&server.core.storage.data, server.registry(), item_id, item)
.await?;
Ok(None)
}
/// The result of a restore that couldn't happen: the item stays archived,
/// and a new request can try again (UD-10).
pub(crate) async fn not_restored(server: &Server, item_id: Id, reason: String) -> trc::Result<TaskResult> {
let mut batch = BatchBuilder::new();
undelete::data::clear_restore_requested(&mut batch, item_id);
server.core.storage.data.write(batch.build_all()).await?;
Ok(TaskResult::permanent(reason))
}
+49
View File
@@ -10,6 +10,7 @@ use email::{
cache::MessageCacheFetch, cache::MessageCacheFetch,
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata}, message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
}; };
use inbuxa_features::undelete;
use types::blob_hash::BlobHash; 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::{
@@ -210,6 +211,20 @@ impl SearchIndexTask for Server {
IndexDocumentType::Contacts => 2, IndexDocumentType::Contacts => 2,
IndexDocumentType::File => 3, IndexDocumentType::File => 3,
}; };
// inbuxa: UD-1: a noted file, event or contact is archived
if let Some(kind) = match task.document_type {
IndexDocumentType::Calendar => Some(undelete::groupware::Kind::CalendarEvent),
IndexDocumentType::Contacts => Some(undelete::groupware::Kind::ContactCard),
IndexDocumentType::File => Some(undelete::groupware::Kind::File),
IndexDocumentType::Email => None,
} && let Err(err) = archive_noted(self, kind, account_id, document_id).await
{
trc::error!(
err.account_id(account_id)
.document_id(document_id)
.details("Failed to archive a deleted item")
);
}
document_deletions[idx] document_deletions[idx]
.entry(account_id) .entry(account_id)
@@ -571,6 +586,40 @@ async fn build_tracing_span_document(_: &Server, _: u64) -> trc::Result<Option<I
Ok(None) Ok(None)
} }
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
// deletion, when archiving is on; otherwise its note is dropped
async fn archive_noted(
server: &Server,
kind: undelete::groupware::Kind,
account_id: u32,
document_id: u32,
) -> trc::Result<()> {
let data = &server.core.storage.data;
let Some(note) = undelete::groupware::take(data, kind, account_id, document_id).await? else {
return Ok(());
};
let Some(retention) = undelete::settings::retention(server.registry()).await?.items else {
return Ok(());
};
let blob_hash = match (&note.content, &note.blob_hash) {
(Some(text), _) => {
server
.put_temporary_blob(account_id, text.as_bytes(), 600)
.await?
.0
}
(None, Some(hash)) => BlobHash::try_from_hash_slice(hash).map_err(|_| {
trc::StoreEvent::DataCorruption
.into_err()
.details("Invalid blob hash in undelete note")
})?,
(None, None) => return Ok(()),
};
undelete::groupware::archive(data, server.registry(), account_id, note, blob_hash, retention)
.await
.map(|_| ())
}
async fn delete_email_metadata( async fn delete_email_metadata(
server: &Server, server: &Server,
batch: &mut BatchBuilder, batch: &mut BatchBuilder,
+1
View File
@@ -21,6 +21,7 @@ pub mod destroy_account;
pub mod dkim; pub mod dkim;
pub mod dns; pub mod dns;
pub mod imip; pub mod imip;
pub mod inbuxa_restore; // inbuxa: undelete
pub mod index; pub mod index;
pub mod lock; pub mod lock;
pub mod maintenance; pub mod maintenance;
@@ -139,9 +139,32 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R
Err(err) => Err(err.caused_by(trc::location!())), Err(err) => Err(err.caused_by(trc::location!())),
} }
} }
// inbuxa: UD-1, UD-8: the other kinds the fork archives
ArchivedItemType::FileNode ArchivedItemType::FileNode
| ArchivedItemType::CalendarEvent | ArchivedItemType::CalendarEvent
| ArchivedItemType::ContactCard | ArchivedItemType::ContactCard
| ArchivedItemType::SieveScript => Ok(TaskResult::permanent("Not implemented")), | ArchivedItemType::SieveScript => {
let Some((item_id, item, extra)) = undelete::records::for_restore(
&server.core.storage.data,
server.registry(),
task.account_id.document_id(),
task.blob_id.hash.as_slice(),
)
.await?
else {
return Ok(TaskResult::Success(vec![]));
};
match crate::task_manager::inbuxa_restore::restore_other(
server, task, item_id, &item, extra,
)
.await?
{
None => Ok(TaskResult::Success(vec![])),
Some(reason) => {
crate::task_manager::inbuxa_restore::not_restored(server, item_id, reason)
.await
}
}
}
} }
} }
+131
View File
@@ -9,6 +9,7 @@
use crate::utils::{ use crate::utils::{
account::Account, account::Account,
webdav::DummyWebDavClient,
imap::{ImapConnection, Type}, imap::{ImapConnection, Type},
jmap::JmapUtils, jmap::JmapUtils,
pop3::{self, Pop3Connection}, pop3::{self, Pop3Connection},
@@ -27,10 +28,13 @@ use registry::{
}, },
types::duration::Duration, types::duration::Duration,
}; };
use hyper::StatusCode;
use serde_json::{Value, json}; use serde_json::{Value, json};
use types::id::Id; use types::id::Id;
const SECRET: &str = "undelete test user passphrase"; const SECRET: &str = "undelete test user passphrase";
const EVENT: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\nBEGIN:VEVENT\r\nUID:lunch-1\r\nDTSTAMP:20260918T100000Z\r\nDTSTART:20260918T120000Z\r\nDTEND:20260918T130000Z\r\nSUMMARY:Lunch\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
const CARD: &str = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:jane-1\r\nFN:Jane Doe\r\nEND:VCARD\r\n";
const DAY: u64 = 86_400; const DAY: u64 = 86_400;
pub async fn test(test: &mut TestServer) { pub async fn test(test: &mut TestServer) {
@@ -267,6 +271,133 @@ pub async fn test(test: &mut TestServer) {
"test 15" "test 15"
); );
// Acceptance test 6: a file, event, contact and script each come back to
// the right place (UD-1, UD-8)
let dav = DummyWebDavClient::new(
user.id().document_id(),
"[email protected]",
SECRET,
"[email protected]",
);
let home = "undelete%40example.org";
dav.mkcol("MKCOL", &format!("/dav/file/{home}/Folder/"), [], [])
.await
.with_status(StatusCode::CREATED);
let file = format!("/dav/file/{home}/Folder/notes.txt");
let event = format!("/dav/cal/{home}/default/lunch.ics");
let card = format!("/dav/card/{home}/default/jane.vcf");
for (path, body) in [
(&file, "Some notes.".to_string()),
(&event, EVENT.to_string()),
(&card, CARD.to_string()),
] {
dav.request("PUT", path, body)
.await
.with_status(StatusCode::CREATED);
}
let script = client
.sieve_script_create("Filters", b"keep;".to_vec(), true)
.await
.unwrap()
.take_id();
for path in [&file, &event, &card] {
dav.request("DELETE", path, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
client.sieve_script_deactivate().await.unwrap();
client.sieve_script_destroy(&script).await.unwrap();
test.wait_for_tasks().await;
// A new file takes the old name, so the restored one is renamed
dav.request("PUT", &file, "Newer notes.")
.await
.with_status(StatusCode::CREATED);
for kind in ["FileNode", "CalendarEvent", "ContactCard", "SieveScript"] {
let item = user
.archived()
.await
.into_iter()
.find(|item| item["@type"] == kind)
.unwrap_or_else(|| panic!("test 6: no archived {kind}"));
user.request_restore(item.object_id()).await;
}
test.wait_for_tasks().await;
dav.request("GET", &format!("/dav/file/{home}/Folder/notes%20%28restored%29.txt"), "")
.await
.with_status(StatusCode::OK)
.with_body("Some notes.");
dav.request("GET", &event, "")
.await
.with_status(StatusCode::OK);
dav.request("GET", &card, "").await.with_status(StatusCode::OK);
let restored = client
.sieve_script_query(
jmap_client::sieve::query::Filter::name("Filters").into(),
None::<Vec<_>>,
)
.await
.unwrap();
let restored = client
.sieve_script_get(restored.ids().first().expect("test 6: script"), None::<Vec<_>>)
.await
.unwrap()
.unwrap();
assert!(!restored.is_active(), "test 6: a restored script is inactive");
assert!(
user.archived()
.await
.iter()
.all(|item| item["@type"] == "Email"),
"test 6: records removed"
);
// Acceptance test 8: a restore past the account's quota is refused, and
// the item stays archived (UD-10)
let used = test
.server
.get_used_quota_account(user.id().document_id())
.await
.unwrap();
admin
.registry_update_object(
ObjectType::Account,
user.id(),
json!({ Property::Quotas: {"maxDiskQuota": used} }),
)
.await;
let item = user.archived_with_subject("Via POP3").await;
user.request_restore(item.object_id()).await;
test.wait_for_tasks_skip_failures().await;
assert_eq!(user.count_with_subject("Via POP3").await, 0, "test 8");
assert_eq!(
user.archived_with_subject("Via POP3").await["status"],
"archived",
"test 8: still archived"
);
// The refused restore stays in the queue as failed
admin.registry_destroy_all(ObjectType::Task).await;
admin
.registry_update_object(
ObjectType::Account,
user.id(),
json!({ Property::Quotas: {} }),
)
.await;
// Acceptance test 12: past its deadline an item isn't restorable
admin.set_retention(Some(1)).await;
let id = import(&client, "Short-lived", &[INBOX_ID], &[]).await;
client.email_destroy(&id).await.unwrap();
test.wait_for_tasks().await;
tokio::time::sleep(std::time::Duration::from_millis(2100)).await;
assert!(
user.archived()
.await
.iter()
.all(|item| item["subject"] != "Short-lived"),
"test 12"
);
// Clean up // Clean up
admin.set_retention(None).await; admin.set_retention(None).await;
for item in user.archived().await { for item in user.archived().await {