Files
inbuxa-server/crates/imap/src/op/expunge.rs
T
jcoffey-dev 447229f871
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 8m47s
Lock accounts: keep receiving mail, no sign-in, hand to delegates
A locked account can't sign in (it fails as a wrong password does), its
sessions end on every node, refresh tokens stop working, and its Sieve
scripts forward and reply to nothing. Mail keeps arriving.

Delegates get real ACL grants on the account's mailboxes, calendars,
address books and files at read, organize or full, with the rights they
replaced restored on unlock. Folders made later are granted after the
create and in a daily sweep. Organize delegates can't destroy; send-as
needs organize or full. The JMAP session marks delegated accounts in
urn:inbuxa:jmap.

New inbuxa:AccountLock object with get/set, permissions 665-668, and a
Compliance > Locked Accounts entry in the schema. Lock, unlock and
delegate changes need a reason and are audited; delegate access and
writes are audited too (audit-hold-lock spec AL-1 to AL-12).
2026-09-27 14:46:06 -07:00

327 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: the retention in force now
let retention = inbuxa_features::undelete::settings::retention(self.server.registry())
.await?
.items;
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 let Some(retention) = retention {
inbuxa_features::undelete::email::note(
batch,
retention,
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))
}
}