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
+21 -1
View File
@@ -6,7 +6,11 @@
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult};
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 registry::{
schema::{
@@ -593,6 +597,22 @@ async fn delete_email_metadata(
.caused_by(trc::location!())?;
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 => {
trc::event!(
@@ -227,6 +227,14 @@ async fn store_maintenance(
.await
.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!(
Store(StoreEvent::DataStorePurged),
Elapsed = started.elapsed()
@@ -6,12 +6,15 @@
use common::{Server, auth::BuildAccessToken};
use email::{
mailbox::INBOX_ID,
cache::{MessageCacheFetch, mailbox::MailboxCacheAccess},
mailbox::{INBOX_ID, TRASH_ID},
message::ingest::{EmailIngest, IngestEmail, IngestSource},
};
use inbuxa_features::undelete;
use types::keyword::Keyword;
use mail_parser::MessageParser;
use registry::schema::{enums::ArchivedItemType, structs::TaskRestoreArchivedItem};
use store::write::{BatchBuilder, BlobLink, BlobOp};
use store::write::BatchBuilder;
use trc::AddContext;
use crate::task_manager::TaskResult;
@@ -48,6 +51,40 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R
.await
.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
.blob_store()
.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),
blob_hash: Some(&task.blob_id.hash),
access_token: &access_token.build(),
mailbox_ids: vec![INBOX_ID],
keywords: vec![],
mailbox_ids,
keywords,
received_at: (task.created_at.timestamp() as u64).into(),
source: IngestSource::Restore,
session_id: 0,
@@ -71,17 +108,22 @@ async fn restore_item(server: &Server, task: &TaskRestoreArchivedItem) -> trc::R
.await
{
Ok(_) => {
let mut batch = BatchBuilder::new();
batch.with_account_id(account_id).clear(BlobOp::Link {
hash: task.blob_id.hash.clone(),
to: BlobLink::Temporary {
until: task.archived_until.timestamp() as u64,
},
});
server.store().write(batch.build_all()).await?;
// inbuxa: UD-9: the archived record goes, and its copy is released
undelete::records::remove(data, server.registry(), item_id, &item).await?;
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)
if err.matches(trc::EventType::MessageIngest(
trc::MessageIngestEvent::Error,