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
@@ -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,
message::metadata::{MESSAGE_RECEIVED_MASK, MessageMetadata},
};
use inbuxa_features::undelete;
use types::blob_hash::BlobHash;
use groupware::{cache::GroupwareCache, calendar::CalendarEvent, contact::ContactCard};
use registry::{
@@ -210,6 +211,20 @@ impl SearchIndexTask for Server {
IndexDocumentType::Contacts => 2,
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]
.entry(account_id)
@@ -571,6 +586,40 @@ async fn build_tracing_span_document(_: &Server, _: u64) -> trc::Result<Option<I
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(
server: &Server,
batch: &mut BatchBuilder,
+1
View File
@@ -21,6 +21,7 @@ pub mod destroy_account;
pub mod dkim;
pub mod dns;
pub mod imip;
pub mod inbuxa_restore; // inbuxa: undelete
pub mod index;
pub mod lock;
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!())),
}
}
// inbuxa: UD-1, UD-8: the other kinds the fork archives
ArchivedItemType::FileNode
| ArchivedItemType::CalendarEvent
| 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
}
}
}
}
}