From 64619e4e3e98785e57b747afadb3a609e0144720 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:57:02 +0200 Subject: [PATCH] Fix JMAP: `Thread/changes` never emits a container delete when a thread becomes empty --- CHANGELOG.md | 1 + crates/email/src/mailbox/destroy.rs | 10 ++++++- crates/email/src/message/delete.rs | 45 ++++++++++++++++++++++++++++- crates/imap/src/op/expunge.rs | 17 ++++++++--- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de003a6d..91f5a207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/email/src/mailbox/destroy.rs b/crates/email/src/mailbox/destroy.rs index 9e57b85e..aac155a8 100644 --- a/crates/email/src/mailbox/destroy.rs +++ b/crates/email/src/mailbox/destroy.rs @@ -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)); } diff --git a/crates/email/src/message/delete.rs b/crates/email/src/message/delete.rs index 6180ef95..6e437d06 100644 --- a/crates/email/src/message/delete.rs +++ b/crates/email/src/message/delete.rs @@ -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> + Send; + + fn log_emptied_threads( + &self, + account_id: u32, + batch: &mut BatchBuilder, + thread_ids: RoaringBitmap, + deleted_ids: &RoaringBitmap, + ) -> impl Future> + Send; } impl EmailDeletion for Server { @@ -54,6 +63,7 @@ impl EmailDeletion for Server { document_ids: RoaringBitmap, ) -> trc::Result { 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 { diff --git a/crates/imap/src/op/expunge.rs b/crates/imap/src/op/expunge.rs index 44993fb5..97af97d3 100644 --- a/crates/imap/src/op/expunge.rs +++ b/crates/imap/src/op/expunge.rs @@ -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 SessionData { // 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 SessionData { 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 SessionData { 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 SessionData { .await .caused_by(trc::location!())?; - Ok(()) + Ok((fully_deleted, thread_ids)) } }