Files
inbuxa-server/crates/email/src/mailbox/destroy.rs
T
jcoffey-dev 7b97efbb7f Legal holds, step 3: deleted items in a held account are kept
Every way of deleting mail (JMAP, IMAP EXPUNGE, POP3, mailbox removal,
Trash emptying) and Sieve scripts, events, contacts and files now asks
how the account's deletions are kept: a hold keeps them with no expiry
(archivedUntil 9999-12-31), even with undelete off; otherwise undelete's
period applies as before (LH-4).

A hold's date range decides by the item's own date (LH-3). Mail is noted
as held at deletion and settled when it's archived, once its received
date is known; outside the range it gets undelete's deadline or isn't
kept. Events go by their start, with a day's slack for time zones;
recurring events, contacts, files and scripts are held whole.

A groupware item's note now stays until its archive succeeds, and a
failure retries the task instead of being logged and lost (LH-5).
2026-09-27 19:33:07 -07:00

256 lines
9.9 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::*;
use crate::{
cache::{MessageCacheFetch, email::MessageCacheAccess},
message::{delete::EmailDeletion, metadata::MessageData},
};
use common::{
Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder,
};
use registry::schema::{
enums::IndexDocumentType,
structs::{Task, TaskIndexDocument, TaskStatus},
};
use store::{
ValueKey,
write::{AlignedBytes, Archive},
};
use store::{roaring::RoaringBitmap, write::BatchBuilder};
use trc::AddContext;
use types::{
acl::Acl,
collection::{Collection, VanishedCollection},
field::MailboxField,
};
pub trait MailboxDestroy: Sync + Send {
fn mailbox_destroy(
&self,
account_id: u32,
document_id: u32,
access_token: &AccessToken,
remove_emails: bool,
) -> impl Future<Output = trc::Result<Result<Option<u64>, MailboxDestroyError>>> + Send;
}
pub enum MailboxDestroyError {
CannotDestroy,
Forbidden,
HasChildren,
HasEmails,
NotFound,
AssertionFailed,
}
impl MailboxDestroy for Server {
async fn mailbox_destroy(
&self,
account_id: u32,
document_id: u32,
access_token: &AccessToken,
remove_emails: bool,
) -> trc::Result<Result<Option<u64>, MailboxDestroyError>> {
// Internal folders cannot be deleted
#[cfg(not(feature = "test_mode"))]
if [INBOX_ID, TRASH_ID, JUNK_ID].contains(&document_id) {
return Ok(Err(MailboxDestroyError::CannotDestroy));
}
// Verify that this mailbox does not have sub-mailboxes
let cache = self
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
if cache
.mailboxes
.items
.iter()
.any(|item| item.parent_id == document_id)
{
return Ok(Err(MailboxDestroyError::HasChildren));
}
// Verify that the mailbox is empty
let mut batch = BatchBuilder::new();
batch.with_account_id(account_id);
let message_ids =
RoaringBitmap::from_iter(cache.in_mailbox(document_id).map(|m| m.document_id));
if !message_ids.is_empty() {
if remove_emails {
// If the message is in multiple mailboxes, untag it from the current mailbox,
// otherwise delete it.
let mut deleted_ids = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new();
// inbuxa: UD-1, UD-6a, LH-4: how this account's deletions are kept
let keeping = self.keeping(account_id).await?;
self.archives(
account_id,
Collection::Email,
&message_ids,
|message_id, message_data_| {
// Remove mailbox from list
let prev_message_data = message_data_
.to_unarchived::<MessageData>()
.caused_by(trc::location!())?;
if !prev_message_data
.inner
.mailboxes
.iter()
.any(|id| id.mailbox_id == document_id)
{
return Ok(true);
}
if prev_message_data.inner.mailboxes.len() == 1 {
// Delete message
for mailbox in prev_message_data.inner.mailboxes.iter() {
batch.log_vanished_item(
VanishedCollection::Email,
(mailbox.mailbox_id.to_native(), mailbox.uid.to_native()),
);
}
deleted_ids.insert(message_id);
thread_ids.insert(prev_message_data.inner.thread_id.to_native());
// inbuxa: UD-1, UD-4: a deleted message is noted for archiving
if keeping.keeps_anything() {
inbuxa_features::undelete::email::note(
&mut batch,
&keeping,
account_id,
message_id,
prev_message_data.inner.size.to_native() as u64,
prev_message_data.inner.mailboxes.iter().map(|m| m.mailbox_id.to_native()).collect(),
inbuxa_features::undelete::email::keywords_to_keep(
prev_message_data.inner.keywords.iter().map(|k| k.to_string()),
),
)?;
}
batch
.with_collection(Collection::Email)
.with_document(message_id)
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_changed_by(access_token.account_tenant_ids())
.with_current(prev_message_data),
)
.caused_by(trc::location!())?
.schedule_task(Task::UnindexDocument(TaskIndexDocument {
account_id: account_id.into(),
document_id: message_id.into(),
document_type: IndexDocumentType::Email,
status: TaskStatus::now(),
}))
.commit_point();
} else {
let new_message_data = MessageData {
mailboxes: prev_message_data
.inner
.mailboxes
.iter()
.filter(|m| m.mailbox_id != document_id)
.map(|m| m.to_native())
.collect(),
keywords: prev_message_data
.inner
.keywords
.iter()
.map(|k| k.to_native())
.collect(),
thread_id: prev_message_data.inner.thread_id.to_native(),
size: prev_message_data.inner.size.to_native(),
};
// Untag message from mailbox
batch
.with_collection(Collection::Email)
.with_document(message_id)
.custom(
ObjectIndexBuilder::new()
.with_changed_by(access_token.account_tenant_ids())
.with_changes(new_message_data)
.with_current(prev_message_data),
)
.caused_by(trc::location!())?
.commit_point();
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
self.log_emptied_threads(account_id, &mut batch, thread_ids, &deleted_ids)
.await
.caused_by(trc::location!())?;
} else {
return Ok(Err(MailboxDestroyError::HasEmails));
}
}
// Obtain mailbox
if let Some(mailbox_) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::Mailbox,
document_id,
))
.await
.caused_by(trc::location!())?
{
let mailbox = mailbox_
.to_unarchived::<Mailbox>()
.caused_by(trc::location!())?;
// Validate ACLs
if access_token.is_shared(account_id) {
let acl = mailbox.inner.acls.effective_acl(access_token);
if !acl.contains(Acl::Delete) || (remove_emails && !acl.contains(Acl::RemoveItems))
{
return Ok(Err(MailboxDestroyError::Forbidden));
}
}
batch
.with_account_id(account_id)
.with_collection(Collection::Mailbox)
.with_document(document_id)
.clear(MailboxField::UidCounter)
.custom(ObjectIndexBuilder::<_, ()>::new().with_current(mailbox))
.caused_by(trc::location!())?;
} else {
return Ok(Err(MailboxDestroyError::NotFound));
};
if !batch.is_empty() {
match self
.commit_batch(batch)
.await
.and_then(|ids| ids.last_change_id(account_id))
{
Ok(change_id) => {
self.notify_task_queue();
Ok(Ok(Some(change_id)))
}
Err(err) if err.is_assertion_failure() => {
Ok(Err(MailboxDestroyError::AssertionFailed))
}
Err(err) => Err(err.caused_by(trc::location!())),
}
} else {
Ok(Ok(None))
}
}
}