Fix JMAP: Thread/changes never emits a container delete when a thread becomes empty

This commit is contained in:
Maurus Decimus
2026-06-18 16:57:02 +02:00
parent 63284599d7
commit 64619e4e3e
4 changed files with 67 additions and 6 deletions

View File

@@ -23,6 +23,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If
- `SearchSnippet/get` response structure.
- `VacationResponse` singleton handling.
- `EmailSubmission/set` must return `sendAt` and `undoStatus` in the created response.
- `Thread/changes` never emits a container delete when a thread becomes empty.
- OIDC: Add default domain name to groups that are not email addresses.
- RocksDB: Enable blob garbage collection to reclaim disk space from deleted blobs.

View File

@@ -7,7 +7,7 @@
use super::*;
use crate::{
cache::{MessageCacheFetch, email::MessageCacheAccess},
message::metadata::MessageData,
message::{delete::EmailDeletion, metadata::MessageData},
};
use common::{
Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder,
@@ -88,6 +88,8 @@ impl MailboxDestroy for Server {
// 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();
self.archives(
account_id,
Collection::Email,
@@ -114,6 +116,8 @@ impl MailboxDestroy for Server {
(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());
batch
.with_collection(Collection::Email)
.with_document(message_id)
@@ -168,6 +172,10 @@ impl MailboxDestroy for Server {
)
.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));
}

View File

@@ -5,6 +5,7 @@
*/
use super::metadata::MessageData;
use crate::cache::{MessageCacheFetch, email::MessageCacheAccess};
use common::{Server, storage::index::ObjectIndexBuilder};
use groupware::calendar::storage::ItipAutoExpunge;
use registry::schema::enums::IndexDocumentType;
@@ -18,7 +19,7 @@ use store::{
write::{BatchBuilder, ValueClass},
};
use trc::AddContext;
use types::collection::{Collection, VanishedCollection};
use types::collection::{Collection, SyncCollection, VanishedCollection};
use types::field::{EmailField, EmailSubmissionField};
pub trait EmailDeletion: Sync + Send {
@@ -43,6 +44,14 @@ pub trait EmailDeletion: Sync + Send {
account_id: u32,
hold_period: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
fn log_emptied_threads(
&self,
account_id: u32,
batch: &mut BatchBuilder,
thread_ids: RoaringBitmap,
deleted_ids: &RoaringBitmap,
) -> impl Future<Output = trc::Result<()>> + Send;
}
impl EmailDeletion for Server {
@@ -54,6 +63,7 @@ impl EmailDeletion for Server {
document_ids: RoaringBitmap,
) -> trc::Result<RoaringBitmap> {
let mut deleted_ids = RoaringBitmap::new();
let mut thread_ids = RoaringBitmap::new();
batch
.with_account_id(account_id)
.with_collection(Collection::Email);
@@ -72,6 +82,7 @@ impl EmailDeletion for Server {
(mailbox.mailbox_id.to_native(), mailbox.uid.to_native()),
);
}
thread_ids.insert(metadata.inner.thread_id.to_native());
batch
.with_document(document_id)
.custom(
@@ -95,6 +106,9 @@ impl EmailDeletion for Server {
)
.await?;
self.log_emptied_threads(account_id, batch, thread_ids, &deleted_ids)
.await?;
let not_destroyed = if document_ids.len() == deleted_ids.len() {
RoaringBitmap::new()
} else {
@@ -105,6 +119,35 @@ impl EmailDeletion for Server {
Ok(not_destroyed)
}
async fn log_emptied_threads(
&self,
account_id: u32,
batch: &mut BatchBuilder,
thread_ids: RoaringBitmap,
deleted_ids: &RoaringBitmap,
) -> trc::Result<()> {
if !thread_ids.is_empty() {
let cache = self
.get_cached_messages(account_id)
.await
.caused_by(trc::location!())?;
for thread_id in &thread_ids {
if cache
.in_thread(thread_id)
.all(|message| deleted_ids.contains(message.document_id))
{
batch
.with_account_id(account_id)
.with_collection(Collection::Thread)
.with_document(thread_id)
.log_container_delete(SyncCollection::Thread);
}
}
}
Ok(())
}
async fn purge_account(&self, account_id: u32) -> trc::Result<()> {
// Auto-expunge deleted and junk messages
if let Some(hold_period) = self.core.email.mail_autoexpunge_after {

View File

@@ -10,7 +10,7 @@ use ahash::AHashMap;
use common::{network::SessionStream, storage::index::ObjectIndexBuilder};
use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess},
message::metadata::MessageData,
message::{delete::EmailDeletion, metadata::MessageData},
};
use imap_proto::{
Command, ResponseCode, ResponseType, StatusResponse,
@@ -134,7 +134,12 @@ impl<T: SessionStream> SessionData<T> {
// Delete ids
let mut batch = BatchBuilder::new();
self.email_untag_or_delete(account_id, mailbox.id.mailbox_id, &deleted_ids, &mut batch)
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!())?;
@@ -165,11 +170,13 @@ impl<T: SessionStream> SessionData<T> {
mailbox_id: u32,
deleted_ids: &RoaringBitmap,
batch: &mut BatchBuilder,
) -> trc::Result<()> {
) -> 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();
self.server
.archives(
account_id,
@@ -190,6 +197,8 @@ impl<T: SessionStream> SessionData<T> {
if metadata.inner.mailboxes.len() == 1 {
// Delete message
fully_deleted.insert(document_id);
thread_ids.insert(metadata.inner.thread_id.to_native());
batch
.custom(
ObjectIndexBuilder::<_, ()>::new()
@@ -228,6 +237,6 @@ impl<T: SessionStream> SessionData<T> {
.await
.caused_by(trc::location!())?;
Ok(())
Ok((fully_deleted, thread_ids))
}
}