From 097e3ffe94fd8fa567f0e4793689ef473eccdb90 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:38:59 +0200 Subject: [PATCH] Fix Cache: Invalidate negative email caches when an account is created --- CHANGELOG.md | 1 + crates/common/src/cache/directory.rs | 22 ++++----- crates/common/src/cache/invalidate.rs | 71 +++++++++++++++++++++++++-- crates/common/src/ipc.rs | 4 ++ crates/jmap/src/registry/set.rs | 1 + crates/services/src/broadcast/mod.rs | 17 +++++++ crates/utils/src/cache.rs | 5 ++ tests/src/smtp/inbound/rcpt.rs | 4 +- 8 files changed, 110 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecd1021..7b0d0b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - OIDC: Add default domain name to groups that are not email addresses. - RocksDB: Enable blob garbage collection to reclaim disk space from deleted blobs. - Sieve: `include` statements ignore capitalisation of sub-script names (#1643) +- Cache: Invalidate negative email caches when an account is created. - Troubleshoot tool: Use the configured source IP address when connecting to remote servers (#2867). ## [0.16.9] - 2026-06-15 diff --git a/crates/common/src/cache/directory.rs b/crates/common/src/cache/directory.rs index 0c68498e..c2e88cf7 100644 --- a/crates/common/src/cache/directory.rs +++ b/crates/common/src/cache/directory.rs @@ -4,9 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{ - Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder, ipc::BroadcastEvent, -}; +use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder}; use registry::{ schema::{ prelude::{Object, ObjectType}, @@ -169,8 +167,6 @@ impl Server { enabled: true, description: None, }); - - self.invalidate_local_negative_account_cache(local, alias_domain.id); } } let mut member_group_ids = Vec::with_capacity(account.groups.len()); @@ -222,9 +218,11 @@ impl Server { .caused_by(trc::location!())? { RegistryWriteResult::Success(id) => { - self.invalidate_local_negative_account_cache(local, domain.id); - self.cluster_broadcast(BroadcastEvent::CacheInvalidateNegative) - .await; + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_create(&account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; Ok(AccountWithId { id: id.document_id(), @@ -343,8 +341,6 @@ impl Server { enabled: true, description: None, }); - - self.invalidate_local_negative_account_cache(local, alias_domain.id); } } @@ -377,7 +373,11 @@ impl Server { .caused_by(trc::location!())? { RegistryWriteResult::Success(id) => { - self.invalidate_local_negative_account_cache(local, domain.id); + let mut invalidator = CacheInvalidationBuilder::default(); + invalidator.process_create(&account); + self.invalidate_caches(invalidator) + .await + .caused_by(trc::location!())?; Ok(id.document_id()) } diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index 59b31adc..d3d94f49 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -13,7 +13,7 @@ use ahash::AHashSet; use registry::{ schema::{ prelude::{Object, ObjectInner, ObjectType}, - structs::Account, + structs::{Account, EmailAlias}, }, types::id::ObjectId, }; @@ -57,6 +57,10 @@ impl CacheInvalidationBuilder { self.invalidate(CacheInvalidation::Account(id)); } + if was_renamed || aliases_changed { + self.invalidate_negative_email(&new_object.inner); + } + if tenant_changed || groups_changed || credentials_changed @@ -94,6 +98,10 @@ impl CacheInvalidationBuilder { self.invalidate(CacheInvalidation::Account(id)); } + if was_renamed || aliases_changed { + self.invalidate_negative_email(&new_object.inner); + } + if tenant_changed || roles_changed || permissions_changed { self.invalidate(CacheInvalidation::AccessToken(id)); } @@ -158,6 +166,12 @@ impl CacheInvalidationBuilder { || (current.domain_id != new.domain_id) => { self.invalidate(CacheInvalidation::List(id)); + if (current.aliases != new.aliases) + || (current.name != new.name) + || (current.domain_id != new.domain_id) + { + self.invalidate_negative_email(&new_object.inner); + } } _ => {} } @@ -194,6 +208,34 @@ impl CacheInvalidationBuilder { } } + pub fn process_create(&mut self, object: &Object) { + self.invalidate_negative_email(&object.inner); + } + + fn invalidate_negative_email(&mut self, object: &ObjectInner) { + let (name, domain_id, aliases) = match object { + ObjectInner::Account(Account::User(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::Account(Account::Group(account)) => { + (&account.name, account.domain_id, &account.aliases) + } + ObjectInner::MailingList(list) => (&list.name, list.domain_id, &list.aliases), + _ => return, + }; + + self.invalidate(CacheInvalidation::EmailNegative { + domain_id: domain_id.document_id(), + local_part_hash: hash_local_part(name), + }); + for alias in aliases.iter().filter(|alias: &&EmailAlias| alias.enabled) { + self.invalidate(CacheInvalidation::EmailNegative { + domain_id: alias.domain_id.document_id(), + local_part_hash: hash_local_part(&alias.name), + }); + } + } + pub fn invalidate(&mut self, change: CacheInvalidation) { self.changes.insert(change); } @@ -298,15 +340,21 @@ impl Server { self.inner.cache.emails_negative.clear(); } - pub fn invalidate_local_negative_account_cache(&self, local_part: &str, domain_id: u32) { + pub fn invalidate_local_negative_account_cache( + &self, + local_part: &str, + domain_id: u32, + ) -> bool { self.inner .cache .emails_negative - .remove(&EmailAddressRef::new(local_part, domain_id)); + .remove(&EmailAddressRef::new(local_part, domain_id)) + .is_some() } pub async fn invalidate_local_caches(&self, changes: &[CacheInvalidation]) { let cache = &self.inner.cache; + let mut negative_emails: AHashSet<(u32, u32)> = AHashSet::new(); for change in changes { match change { @@ -370,11 +418,28 @@ impl Server { .lock() .retain(|_, v| v.tenant_id != Some(*id)); } + CacheInvalidation::EmailNegative { + domain_id, + local_part_hash, + } => { + negative_emails.insert((*domain_id, *local_part_hash)); + } } } + + if !negative_emails.is_empty() { + cache.emails_negative.retain(|key| { + !negative_emails.contains(&(key.domain_id, hash_local_part(&key.local_part))) + }); + } } } +#[inline(always)] +fn hash_local_part(local_part: &str) -> u32 { + xxhash_rust::xxh3::xxh3_64(local_part.as_bytes()) as u32 +} + impl From for CacheInvalidationBuilder { fn from(invalidation: CacheInvalidation) -> Self { let mut builder = CacheInvalidationBuilder::default(); diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index 5e15d7af..8371dd7a 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -99,6 +99,10 @@ pub enum CacheInvalidation { List(u32), DomainLogo(u32), TenantLogo(u32), + EmailNegative { + domain_id: u32, + local_part_hash: u32, + }, } #[derive(Debug)] diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index 46399da0..7ef6ca9c 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -602,6 +602,7 @@ impl RegistrySet for Server { Modification::Create { client_id, .. }, RegistryWriteResult::Success(id), ) => { + cache_invalidator.process_create(&new_object); response.object.insert(Property::Id, RegistryValue::Id(id)); set.response .created diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index 9abcb186..8955e2ca 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -107,6 +107,15 @@ impl BroadcastBatch> { CacheInvalidation::List(id) => (7u8, *id), CacheInvalidation::DomainLogo(id) => (8u8, *id), CacheInvalidation::TenantLogo(id) => (9u8, *id), + CacheInvalidation::EmailNegative { + domain_id, + local_part_hash, + } => { + serialized.push(10u8); + let _ = serialized.write_leb128(*domain_id); + let _ = serialized.write_leb128(*local_part_hash); + continue; + } }; serialized.push(marker); @@ -240,6 +249,14 @@ where 7 => CacheInvalidation::List(id), 8 => CacheInvalidation::DomainLogo(id), 9 => CacheInvalidation::TenantLogo(id), + 10 => { + let local_part_hash = + self.messages.next_leb128::().ok_or(())?; + CacheInvalidation::EmailNegative { + domain_id: id, + local_part_hash, + } + } _ => return Err(()), }); } diff --git a/crates/utils/src/cache.rs b/crates/utils/src/cache.rs index 04cacceb..3c3f540f 100644 --- a/crates/utils/src/cache.rs +++ b/crates/utils/src/cache.rs @@ -183,6 +183,11 @@ impl CacheWithTtl bool) { + self.0.retain(|key, _| f(key)); + } + #[inline(always)] pub fn clear(&self) { self.0.clear(); diff --git a/tests/src/smtp/inbound/rcpt.rs b/tests/src/smtp/inbound/rcpt.rs index d582cd25..e83fe6c6 100644 --- a/tests/src/smtp/inbound/rcpt.rs +++ b/tests/src/smtp/inbound/rcpt.rs @@ -211,5 +211,7 @@ async fn rcpt() { session.ehlo("mx1.foobar.org").await; session.mail_from("idn2@example.net", "250").await; session.rcpt_to("nobody@straß6.de", "550 5.1.2").await; - session.rcpt_to("nobody@xn--stra6-oqa.de", "550 5.1.2").await; + session + .rcpt_to("nobody@xn--stra6-oqa.de", "550 5.1.2") + .await; }