Files
inbuxa-server/crates/imap/src/op/expunge.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

325 lines
12 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::{ImapContext, ToModSeq};
use crate::core::{ImapId, SavedSearch, SelectedMailbox, Session, SessionData};
use ahash::AHashMap;
use common::{network::SessionStream, storage::index::ObjectIndexBuilder};
use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess},
message::{delete::EmailDeletion, metadata::MessageData},
};
use imap_proto::{
Command, ResponseCode, ResponseType, StatusResponse,
parser::parse_sequence_set,
receiver::{Request, Token},
};
use registry::schema::{
enums::{IndexDocumentType, Permission},
structs::{Task, TaskIndexDocument, TaskStatus},
};
use std::{sync::Arc, time::Instant};
use store::{roaring::RoaringBitmap, write::BatchBuilder};
use trc::AddContext;
use types::{
acl::Acl,
collection::{Collection, VanishedCollection},
keyword::Keyword,
};
impl<T: SessionStream> Session<T> {
pub async fn handle_expunge(
&mut self,
request: Request<Command>,
is_uid: bool,
) -> trc::Result<()> {
// Validate access
self.assert_has_permission(Permission::ImapExpunge)?;
let op_start = Instant::now();
let (data, mailbox) = self.state.select_data();
// Validate ACL
// inbuxa: AL-6: a delegate below full may move mail, never delete it
let may_destroy = data
.refresh_access_token()
.await
.imap_ctx(&request.tag, trc::location!())?
.delegation(mailbox.id.account_id)
.is_none_or(|delegation| delegation.access.may_destroy());
if !may_destroy
|| !data
.check_mailbox_acl(
mailbox.id.account_id,
mailbox.id.mailbox_id,
Acl::RemoveItems,
)
.await
.imap_ctx(&request.tag, trc::location!())?
{
return Err(trc::ImapEvent::Error
.into_err()
.details(concat!(
"You do not have the required permissions ",
"to remove messages from this mailbox."
))
.code(ResponseCode::NoPerm)
.id(request.tag));
}
// Parse sequence to operate on
let sequence = match request.tokens.into_iter().next() {
Some(Token::Argument(value)) if is_uid => {
let sequence = parse_sequence_set(&value).map_err(|err| {
trc::ImapEvent::Error
.into_err()
.details(err)
.ctx(trc::Key::Type, ResponseType::Bad)
.id(request.tag.clone())
})?;
Some(
mailbox
.sequence_to_ids(&sequence, true)
.await
.map_err(|err| err.id(request.tag.clone()))?,
)
}
_ => None,
};
// RFC 9738 limits UID EXPUNGE but never a plain EXPUNGE
let message_limit = if is_uid {
self.server.core.imap.max_messages_per_command
} else {
u32::MAX
};
// Expunge
let limited_uid = data
.expunge(mailbox.clone(), sequence, message_limit, op_start)
.await
.imap_ctx(&request.tag, trc::location!())?;
// Clear saved searches
*mailbox.saved_search.lock() = SavedSearch::None;
// Synchronize messages
let modseq = data
.write_mailbox_changes(&mailbox, self.is_qresync || self.is_uidonly)
.await
.imap_ctx(&request.tag, trc::location!())?;
let mut response =
StatusResponse::completed(Command::Expunge(is_uid)).with_tag(request.tag);
let mut untagged = Vec::new();
if let Some(uid) = limited_uid {
let code = ResponseCode::MessageLimit {
limit: message_limit,
uid: uid.into(),
};
if self.is_condstore {
untagged = StatusResponse::ok("Some messages were not expunged.")
.with_code(code)
.into_bytes();
} else {
response = response.with_code(code);
}
}
if self.is_condstore {
response = response.with_code(ResponseCode::HighestModseq {
modseq: modseq.to_modseq(),
});
}
self.write_bytes(response.serialize(untagged)).await
}
}
impl<T: SessionStream> SessionData<T> {
pub async fn expunge(
&self,
mailbox: Arc<SelectedMailbox>,
sequence: Option<AHashMap<u32, ImapId>>,
message_limit: u32,
op_start: Instant,
) -> trc::Result<Option<u32>> {
// Obtain message ids
let account_id = mailbox.id.account_id;
// inbuxa: AL-6: nothing is deleted for a delegate below full (CLOSE
// expunges quietly, so it deletes nothing, quietly)
if self
.refresh_access_token()
.await?
.delegation(account_id)
.is_some_and(|delegation| !delegation.access.may_destroy())
{
return Ok(None);
}
let mut deleted_ids = RoaringBitmap::from_iter(
self.server
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?
.in_mailbox_with_keyword(mailbox.id.mailbox_id, &Keyword::Deleted)
.map(|m| m.document_id),
);
// Filter by sequence
if let Some(sequence) = &sequence {
deleted_ids &= RoaringBitmap::from_iter(sequence.keys());
}
// RFC 9738 requires the highest UIDs to be processed first when truncating.
// Only messages the session has a UID for can be ordered, so the count that
// decides whether to truncate has to come from that same set.
let mut limited_uid = None;
if deleted_ids.len() > message_limit as u64 {
let mut uids = {
let state = mailbox.state.lock();
deleted_ids
.iter()
.filter_map(|id| state.id_to_imap.get(&id).map(|imap_id| (imap_id.uid, id)))
.collect::<Vec<_>>()
};
if uids.len() > message_limit as usize {
let cutoff = uids.len() - message_limit as usize;
let (below, lowest, _) = uids.select_nth_unstable(cutoff);
limited_uid = Some(lowest.0);
for (_, id) in below {
deleted_ids.remove(*id);
}
}
}
// Delete ids
let mut batch = BatchBuilder::new();
let (fully_deleted, thread_ids) = self
.email_untag_or_delete(account_id, mailbox.id.mailbox_id, &deleted_ids, &mut batch)
.await
.caused_by(trc::location!())?;
self.server
.log_emptied_threads(account_id, &mut batch, thread_ids, &fully_deleted)
.await
.caused_by(trc::location!())?;
trc::event!(
Imap(trc::ImapEvent::Expunge),
SpanId = self.session_id,
AccountId = account_id,
MailboxId = mailbox.id.mailbox_id,
DocumentId = deleted_ids.iter().map(trc::Value::from).collect::<Vec<_>>(),
Elapsed = op_start.elapsed()
);
// Write changes on source account
if !batch.is_empty() {
self.server
.commit_batch(batch)
.await
.caused_by(trc::location!())?;
self.server.notify_task_queue();
}
Ok(limited_uid)
}
pub async fn email_untag_or_delete(
&self,
account_id: u32,
mailbox_id: u32,
deleted_ids: &RoaringBitmap,
batch: &mut BatchBuilder,
) -> trc::Result<(RoaringBitmap, RoaringBitmap)> {
batch
.with_account_id(account_id)
.with_collection(Collection::Email);
let mut fully_deleted = 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.server.keeping(account_id).await?;
self.server
.archives(
account_id,
Collection::Email,
deleted_ids,
|document_id, data_| {
let metadata = data_
.to_unarchived::<MessageData>()
.caused_by(trc::location!())?;
if let Some(message_uid) = metadata.inner.message_uid(mailbox_id) {
// Add vanished items
batch.with_document(document_id);
batch.log_vanished_item(
VanishedCollection::Email,
(mailbox_id, message_uid),
);
if metadata.inner.mailboxes.len() == 1 {
// Delete message
fully_deleted.insert(document_id);
thread_ids.insert(metadata.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(
batch,
&keeping,
account_id,
document_id,
metadata.inner.size.to_native() as u64,
metadata.inner.mailboxes.iter().map(|m| m.mailbox_id.to_native()).collect(),
inbuxa_features::undelete::email::keywords_to_keep(
metadata.inner.keywords.iter().map(|k| k.to_string()),
),
)?;
}
batch
.custom(
ObjectIndexBuilder::<_, ()>::new()
.with_changed_by(self.access_token.account_tenant_ids())
.with_current(metadata),
)
.caused_by(trc::location!())?
.schedule_task(Task::UnindexDocument(TaskIndexDocument {
account_id: account_id.into(),
document_id: document_id.into(),
document_type: IndexDocumentType::Email,
status: TaskStatus::now(),
}))
.commit_point();
} else {
// Untag message from this mailbox and remove Deleted flag
let mut new_metadata = metadata.inner.to_builder();
new_metadata.remove_mailbox(mailbox_id);
new_metadata.remove_keyword(&Keyword::Deleted);
// Write changes
batch
.custom(
ObjectIndexBuilder::new()
.with_current(metadata)
.with_changes(new_metadata.seal()),
)
.caused_by(trc::location!())?
.commit_point();
}
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok((fully_deleted, thread_ids))
}
}