From deb034bf9e01b4c98ee4206ce297fe8dee25f27b Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:18:21 +0200 Subject: [PATCH] Fix Directory: Mail addressed to a domain alias is rejected with `550 Relay not allowed`, unless the domains primary name happened to be resolved earlier and is still cached --- CHANGELOG.md | 3 +- crates/common/src/cache/invalidate.rs | 11 ++ crates/common/src/cache/principals.rs | 18 ++- crates/common/src/ipc.rs | 1 + crates/common/src/network/mta.rs | 11 +- crates/jmap/src/registry/get.rs | 10 +- crates/jmap/src/registry/mapping/domain.rs | 43 ++++++- crates/nlp/src/language/mod.rs | 61 +++++++++- crates/registry/src/schema/properties_impl.rs | 5 + crates/registry/src/schema/structs_impl.rs | 3 + crates/services/src/broadcast/mod.rs | 2 + crates/smtp/src/inbound/rcpt.rs | 59 ++++++---- crates/smtp/src/inbound/vrfy.rs | 4 +- crates/smtp/src/queue/spool.rs | 2 +- crates/store/src/backend/postgres/main.rs | 66 +++++++++-- crates/store/src/backend/postgres/mod.rs | 2 + crates/store/src/backend/postgres/search.rs | 65 +++++++++-- tests/src/system/delivery.rs | 46 ++++++++ tests/src/system/directory.rs | 109 +++++++++++++++--- 19 files changed, 450 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 099aaf101..2339993e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [0.16.18] - 2026-08-XX +## [0.16.18] - 2026-08-17 If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. @@ -47,6 +47,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - Directory: - Local group membership is cleared when the external directory is configured with a group claim or attribute that it does not return. - LDAP: Directories that store aliases as additional values of the primary address attribute provision no aliases. + - Mail addressed to a domain alias is rejected with `550 Relay not allowed`, unless the domain's primary name happened to be resolved earlier and is still cached. - RocksDB: `bufferSize` setting was applied to the unused default column family and had no effect. - Sieve: `include` statements fail to find system and user global scripts whose name contains uppercase characters. - Task manager: `totalDeadline` is measured from the time a task was created instead of its first failed attempt. diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index d3d94f498..84f706180 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -113,6 +113,7 @@ impl CacheInvalidationBuilder { (ObjectInner::Domain(current), ObjectInner::Domain(new)) => { if (current.name != new.name) + || (current.aliases != new.aliases) || (current.directory_id != new.directory_id) || (current.member_tenant_id != new.member_tenant_id) || (current.catch_all_address != new.catch_all_address) @@ -123,6 +124,10 @@ impl CacheInvalidationBuilder { self.invalidate(CacheInvalidation::Domain(id)); } + if (current.name != new.name) || (current.aliases != new.aliases) { + self.invalidate(CacheInvalidation::DomainNegative); + } + if current.logo != new.logo { self.invalidate(CacheInvalidation::DomainLogo(id)); } @@ -209,6 +214,9 @@ impl CacheInvalidationBuilder { } pub fn process_create(&mut self, object: &Object) { + if matches!(&object.inner, ObjectInner::Domain(_)) { + self.invalidate(CacheInvalidation::DomainNegative); + } self.invalidate_negative_email(&object.inner); } @@ -424,6 +432,9 @@ impl Server { } => { negative_emails.insert((*domain_id, *local_part_hash)); } + CacheInvalidation::DomainNegative => { + cache.domain_names_negative.clear(); + } } } diff --git a/crates/common/src/cache/principals.rs b/crates/common/src/cache/principals.rs index e0a8419b2..7b2390b3c 100644 --- a/crates/common/src/cache/principals.rs +++ b/crates/common/src/cache/principals.rs @@ -68,7 +68,7 @@ impl Server { } else { let domain_names_negative = &self.inner.cache.domain_names_negative; if domain_names_negative.get(domain).is_none() { - if let Some(domain) = self + let mut object = self .registry() .primary_key( ObjectType::Domain.into(), @@ -76,8 +76,20 @@ impl Server { domain.as_bytes().to_vec(), ) .await - .caused_by(trc::location!())? - { + .caused_by(trc::location!())?; + if object.is_none() { + object = self + .registry() + .primary_key( + ObjectType::Domain.into(), + Property::Aliases, + domain.as_bytes().to_vec(), + ) + .await + .caused_by(trc::location!())?; + } + + if let Some(domain) = object { // Cache positive result let domain_id = domain.id().document_id(); let domain = self.domain_by_id(domain_id).await?; diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index dc8e4f549..ff8f40530 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -104,6 +104,7 @@ pub enum CacheInvalidation { domain_id: u32, local_part_hash: u32, }, + DomainNegative, } #[derive(Debug)] diff --git a/crates/common/src/network/mta.rs b/crates/common/src/network/mta.rs index ba7afa6df..cf526ceda 100644 --- a/crates/common/src/network/mta.rs +++ b/crates/common/src/network/mta.rs @@ -38,7 +38,12 @@ use trc::{AddContext, SpamEvent}; use types::id::Id; impl Server { - pub async fn rcpt_resolve(&self, rcpt: &str, session_id: u64) -> trc::Result { + pub async fn rcpt_resolve( + &self, + rcpt: &str, + allow_catch_all: bool, + session_id: u64, + ) -> trc::Result { // Obtain domain settings let Some((local_part, domain_part)) = rcpt.rsplit_once('@') else { return Ok(RcptResolution::UnknownDomain); @@ -172,7 +177,9 @@ impl Server { } // Catch-all resolution - if let Some(catch_all) = &domain.catch_all { + if allow_catch_all + && let Some(catch_all) = &domain.catch_all + { return Ok(RcptResolution::Rewrite(catch_all.to_string())); } diff --git a/crates/jmap/src/registry/get.rs b/crates/jmap/src/registry/get.rs index 432b71799..303be0108 100644 --- a/crates/jmap/src/registry/get.rs +++ b/crates/jmap/src/registry/get.rs @@ -307,10 +307,14 @@ impl RegistryGet for Server { || get.properties.contains(&Property::Description) => { let mut description = obj.directory.clone(); - if let Some(contact) = obj.contact.as_slice().first() { + let account = obj + .account_uri + .rsplit('/') + .find(|segment| !segment.is_empty()) + .unwrap_or(obj.account_uri.as_str()); + if !account.is_empty() { description.push_str(" ("); - description - .push_str(contact.strip_prefix("mailto:").unwrap_or(contact)); + description.push_str(account); description.push(')'); } extra_properties diff --git a/crates/jmap/src/registry/mapping/domain.rs b/crates/jmap/src/registry/mapping/domain.rs index a9a0ab1f9..abd58c27e 100644 --- a/crates/jmap/src/registry/mapping/domain.rs +++ b/crates/jmap/src/registry/mapping/domain.rs @@ -8,11 +8,11 @@ use crate::registry::mapping::{ ObjectResponse, RegistrySetResponse, ValidationResult, principal::validate_tenant_quota, }; use common::network::{dkim::generate_dkim_selector, dns::update::DnsUpdater}; -use jmap_proto::error::set::SetError; +use jmap_proto::error::set::{SetError, SetErrorType}; use registry::{ schema::{ enums::{AcmeChallengeType, DkimSignatureType, DnsRecordType, TenantStorageQuota}, - prelude::Property, + prelude::{ObjectType, Property}, structs::{ AcmeProvider, CertificateManagement, DkimManagement, DkimManagementProperties, DnsManagement, DnsServer, Domain, Task, TaskDnsManagement, TaskDomainManagement, @@ -61,6 +61,45 @@ pub(crate) async fn validate_domain( .with_description(err))); } + // Validate that names and aliases do not collide with another domain + let registry = set.server.registry(); + if old_domain.is_none_or(|old| old.name != domain.name) + && let Some(existing) = registry + .primary_key( + ObjectType::Domain.into(), + Property::Aliases, + domain.name.as_bytes().to_vec(), + ) + .await? + { + return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(Property::Name) + .with_object_id(existing))); + } + + for alias in domain.aliases.iter() { + if alias == &domain.name + || old_domain.is_some_and(|old| old.aliases.contains(alias) || &old.name == alias) + { + continue; + } + + for index in [Property::Name, Property::Aliases] { + if let Some(existing) = registry + .primary_key( + ObjectType::Domain.into(), + index, + alias.as_bytes().to_vec(), + ) + .await? + { + return Ok(Err(SetError::new(SetErrorType::PrimaryKeyViolation) + .with_property(Property::Aliases) + .with_object_id(existing))); + } + } + } + // Schedule DNS update task let will_trigger_dkim = matches!(domain.dkim_management, DkimManagement::Automatic(_)) && old_domain diff --git a/crates/nlp/src/language/mod.rs b/crates/nlp/src/language/mod.rs index 50348ac78..8fd7a56c1 100644 --- a/crates/nlp/src/language/mod.rs +++ b/crates/nlp/src/language/mod.rs @@ -185,7 +185,25 @@ impl Language { "ca" => Language::Catalan, "el" => Language::Greek, "sv" => Language::Swedish, - "pl" => Language::Polish + "pl" => Language::Polish, + "nl" => Language::Dutch, + "fi" => Language::Finnish, + "hu" => Language::Hungarian, + "tr" => Language::Turkish, + "ro" => Language::Romanian, + "nb" => Language::Bokmal, + "no" => Language::Bokmal, + "yi" => Language::Yiddish, + "eo" => Language::Esperanto, + "uk" => Language::Ukrainian, + "cs" => Language::Czech, + "sk" => Language::Slovak, + "bg" => Language::Bulgarian, + "be" => Language::Belarusian, + "th" => Language::Thai, + "mr" => Language::Marathi, + "kn" => Language::Kannada, + "jv" => Language::Javanese ) .copied() } @@ -206,3 +224,44 @@ impl Language { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_639_codes() { + for (code, expected) in [ + ("nl", Language::Dutch), + ("fi", Language::Finnish), + ("hu", Language::Hungarian), + ("tr", Language::Turkish), + ("ro", Language::Romanian), + ("nb", Language::Bokmal), + ("no", Language::Bokmal), + ("yi", Language::Yiddish), + ("eo", Language::Esperanto), + ("uk", Language::Ukrainian), + ("cs", Language::Czech), + ("sk", Language::Slovak), + ("bg", Language::Bulgarian), + ("be", Language::Belarusian), + ("th", Language::Thai), + ("mr", Language::Marathi), + ("kn", Language::Kannada), + ("jv", Language::Javanese), + ("en", Language::English), + ("pl", Language::Polish), + ] { + assert_eq!(Language::from_iso_639(code), Some(expected), "{code}"); + } + } + + #[test] + fn iso_639_locale_suffixes() { + for code in ["nl_NL", "nl_BE", "nl_NL@euro", "nl-NL"] { + assert_eq!(Language::from_iso_639(code), Some(Language::Dutch), "{code}"); + } + assert_eq!(Language::from_iso_639("xx_XX"), None); + } +} diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index fcceb2c1b..cea140d50 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -3459,6 +3459,11 @@ impl ObjectType { IndexSchemaType::Search, IndexSchemaValueType::Text, ), + IndexSchema::new( + Property::Aliases, + IndexSchemaType::Unique, + IndexSchemaValueType::Keyword, + ), IndexSchema::new( Property::MemberTenantId, IndexSchemaType::Search, diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 854f0f458..4915af28a 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -19702,6 +19702,9 @@ impl ObjectImpl for Domain { fn index<'x>(&'x self, i: &mut IndexBuilder<'x>) { i.unique(Property::Name, &self.name); i.text(Property::Text, &self.name); + for value in self.aliases.iter() { + i.unique(Property::Aliases, value); + } for value in self.aliases.iter() { i.text(Property::Text, value); } diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index d804ea933..108b4130c 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -116,6 +116,7 @@ impl BroadcastBatch> { let _ = serialized.write_leb128(*local_part_hash); continue; } + CacheInvalidation::DomainNegative => (11u8, 0), }; serialized.push(marker); @@ -260,6 +261,7 @@ where local_part_hash, } } + 11 => CacheInvalidation::DomainNegative, _ => return Err(()), }); } diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index 15a22d91b..32a290bf4 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -193,7 +193,7 @@ impl Session { match self .server - .rcpt_resolve(&rcpt.address_lcase, self.data.session_id) + .rcpt_resolve(&rcpt.address_lcase, true, self.data.session_id) .await { Ok(RcptResolution::Accept) => {} @@ -355,33 +355,50 @@ impl Session { let list_addr = self.data.rcpt_to.pop().unwrap(); let orcpt = format!("rfc822;{}", list_addr.address_lcase); for member in members.as_ref() { - let mut member_addr = SessionAddress::new(member.to_string()); - if !self.data.rcpt_to.contains(&member_addr) - && member_addr.address_lcase != list_addr.address_lcase + let member_lcase = member.to_lowercase(); + let is_local = match self + .server + .account_id_from_email(&member_lcase, false) + .await { - // Force external directory synchronization - if let Ok(Some(member_domain)) = self.server.domain(&member_addr.domain).await - && self - .server - .get_directory_for_cached_domain(&member_domain) - .is_some_and(|directory| directory.can_lookup_recipients()) - && matches!( - self.server - .account_id_from_email(&member_addr.address_lcase, false) - .await, - Ok(None) - ) - && let Err(err) = self - .server - .rcpt_resolve(&member_addr.address_lcase, self.data.session_id) - .await - { + Ok(account_id) => account_id.is_some(), + Err(err) => { trc::error!( err.span_id(self.data.session_id) .caused_by(trc::location!()) + .details("Failed to look up mailing list member.") + .ctx(trc::Key::To, member.to_string()) ); + false } + }; + let address = if is_local { + member.to_string() + } else { + match self + .server + .rcpt_resolve(&member_lcase, false, self.data.session_id) + .await + { + Ok(RcptResolution::Rewrite(address)) => address, + Ok(_) => member.to_string(), + Err(err) => { + trc::error!( + err.span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to resolve mailing list member.") + .ctx(trc::Key::To, member.to_string()) + ); + member.to_string() + } + } + }; + + let mut member_addr = SessionAddress::new(address); + if !self.data.rcpt_to.contains(&member_addr) + && member_addr.address_lcase != list_addr.address_lcase + { member_addr.dsn_info = orcpt.clone().into(); member_addr.flags = list_addr.flags; self.data.rcpt_to.push(member_addr); diff --git a/crates/smtp/src/inbound/vrfy.rs b/crates/smtp/src/inbound/vrfy.rs index 29bbc1347..2ca550651 100644 --- a/crates/smtp/src/inbound/vrfy.rs +++ b/crates/smtp/src/inbound/vrfy.rs @@ -15,7 +15,7 @@ impl Session { if self.params.can_vrfy { match self .server - .rcpt_resolve(&address.to_lowercase_address(true), self.data.session_id) + .rcpt_resolve(&address.to_lowercase_address(true), true, self.data.session_id) .await { Ok(RcptResolution::Accept | RcptResolution::Rewrite(_)) => { @@ -67,7 +67,7 @@ impl Session { if self.params.can_expn { match self .server - .rcpt_resolve(&address.to_lowercase_address(true), self.data.session_id) + .rcpt_resolve(&address.to_lowercase_address(true), true, self.data.session_id) .await { Ok(RcptResolution::Expand(addresses)) => { diff --git a/crates/smtp/src/queue/spool.rs b/crates/smtp/src/queue/spool.rs index 9581196d7..0f05acc5f 100644 --- a/crates/smtp/src/queue/spool.rs +++ b/crates/smtp/src/queue/spool.rs @@ -622,7 +622,7 @@ impl MessageWrapper { pub async fn expand_and_add_recipient(&mut self, rcpt: impl AsRef, server: &Server) { let rcpt = rcpt.as_ref(); match server - .rcpt_resolve(&rcpt.to_lowercase(), self.span_id) + .rcpt_resolve(&rcpt.to_lowercase(), true, self.span_id) .await { Ok(RcptResolution::Rewrite(rewritten)) => { diff --git a/crates/store/src/backend/postgres/main.rs b/crates/store/src/backend/postgres/main.rs index 0e6dad6e3..b1ec30781 100644 --- a/crates/store/src/backend/postgres/main.rs +++ b/crates/store/src/backend/postgres/main.rs @@ -6,7 +6,11 @@ use super::{PostgresStore, into_error}; use crate::{ - backend::postgres::{PsqlSearchField, into_pool_error, tls::MakeRustlsConnect}, + backend::postgres::{ + PsqlSearchField, into_pool_error, + search::{PG_FALLBACK_LANG, PG_LANGS, PG_UNSTEMMED_LANG}, + tls::MakeRustlsConnect, + }, search::{ CalendarSearchField, ContactSearchField, EmailSearchField, SearchableField, TracingSearchField, @@ -14,7 +18,8 @@ use crate::{ *, }; use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs}; -use deadpool_postgres::{Config, ManagerConfig, Object, PoolConfig, RecyclingMethod, Runtime}; +use ahash::AHashSet; +use deadpool_postgres::{Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime}; use tokio_postgres::NoTls; use utils::tls::rustls_client_config; @@ -39,6 +44,17 @@ impl PostgresStore { cfg.pool = PoolConfig::new(max_conn as usize).into(); } + let primary_pool = if config.use_tls { + cfg.create_pool( + Some(Runtime::Tokio1), + MakeRustlsConnect::new(rustls_client_config(config.allow_invalid_certs)?), + ) + } else { + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + } + .map_err(|e| format!("Failed to create connection pool: {e}"))?; + let ts_configs = discover_ts_configs(&primary_pool).await; + let mut replicas = vec![]; for replica in config.read_replicas { let mut cfg = cfg.clone(); @@ -58,19 +74,13 @@ impl PostgresStore { cfg.create_pool(Some(Runtime::Tokio1), NoTls) } .map_err(|e| format!("Failed to create connection pool: {e}"))?, + ts_configs: ts_configs.clone(), }))); } let primary = Store::PostgreSQL(Arc::new(PostgresStore { - conn_pool: if config.use_tls { - cfg.create_pool( - Some(Runtime::Tokio1), - MakeRustlsConnect::new(rustls_client_config(config.allow_invalid_certs)?), - ) - } else { - cfg.create_pool(Some(Runtime::Tokio1), NoTls) - } - .map_err(|e| format!("Failed to create connection pool: {e}"))?, + conn_pool: primary_pool, + ts_configs, })); // SPDX-SnippetBegin @@ -225,3 +235,37 @@ async fn create_search_tables( Ok(()) } + +async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> { + let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]); + + match probe_ts_configs(pool).await { + Ok(available) => { + for name in available { + if let Some(config) = PG_LANGS.iter().copied().find(|config| *config == name) { + ts_configs.insert(config); + } + } + } + Err(err) => { + trc::event!( + Store(trc::StoreEvent::PostgresqlError), + Details = "Failed to query pg_ts_config, assuming english only", + Reason = err.to_string(), + ); + } + } + + ts_configs +} + +async fn probe_ts_configs(pool: &Pool) -> trc::Result> { + let conn = pool.get().await.map_err(into_pool_error)?; + + conn.query("SELECT cfgname::text FROM pg_ts_config", &[]) + .await + .map_err(into_error)? + .into_iter() + .map(|row| row.try_get::<_, String>(0).map_err(into_error)) + .collect() +} diff --git a/crates/store/src/backend/postgres/mod.rs b/crates/store/src/backend/postgres/mod.rs index ed8fa7b0c..2efb1296a 100644 --- a/crates/store/src/backend/postgres/mod.rs +++ b/crates/store/src/backend/postgres/mod.rs @@ -11,6 +11,7 @@ use crate::{ }, write::SearchIndex, }; +use ahash::AHashSet; use deadpool_postgres::Pool; pub mod blob; @@ -23,6 +24,7 @@ pub mod write; pub struct PostgresStore { pub(crate) conn_pool: Pool, + pub(crate) ts_configs: AHashSet<&'static str>, } #[inline(always)] diff --git a/crates/store/src/backend/postgres/search.rs b/crates/store/src/backend/postgres/search.rs index 8c5405f7c..84e216786 100644 --- a/crates/store/src/backend/postgres/search.rs +++ b/crates/store/src/backend/postgres/search.rs @@ -20,6 +20,12 @@ use tokio_postgres::{ }; impl PostgresStore { + fn ts_config(&self, language: &Language) -> &'static str { + pg_lang(language) + .filter(|config| self.ts_configs.contains(config)) + .unwrap_or(PG_UNSTEMMED_LANG) + } + pub async fn index(&self, documents: Vec) -> trc::Result<()> { let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?; let trx = conn @@ -60,9 +66,9 @@ impl PostgresStore { let value_ref = format!("${}", values.len() + 1); let (text_len, language) = if let SearchValue::Text { value, language } = value { - (value.len(), pg_lang(language).unwrap_or("simple")) + (value.len(), self.ts_config(language)) } else { - (0, "simple") + (0, PG_UNSTEMMED_LANG) }; if field.is_text() { @@ -187,16 +193,29 @@ impl PostgresStore { query.push(' '); let language = match &value { - SearchValue::Text { language, .. } => { - pg_lang(language).unwrap_or("simple") - } - _ => "simple", + SearchValue::Text { language, .. } => *language, + _ => Language::None, }; + let config = self.ts_config(&language); let method = match op { SearchOperator::Equal => "phraseto_tsquery", _ => "plainto_tsquery", }; - let _ = write!(query, "@@ {method}('{language}', ${value_pos})"); + + if matches!(language, Language::None) { + let _ = write!(query, "@@ {method}('{config}', ${value_pos})"); + } else { + let _ = write!(query, "@@ ({method}('{config}', ${value_pos})"); + for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] { + if fallback != config && self.ts_configs.contains(fallback) { + let _ = write!( + query, + " || {method}('{fallback}', ${value_pos})" + ); + } + } + query.push(')'); + } values.push(value as &(dyn ToSql + Sync)); } else if let SearchValue::KeyValues(kv) = value { query.push_str(field.column()); @@ -412,6 +431,38 @@ impl SearchOperator { } } +pub(super) const PG_FALLBACK_LANG: &str = "english"; +pub(super) const PG_UNSTEMMED_LANG: &str = "simple"; + +pub(super) const PG_LANGS: &[&str] = &[ + "arabic", + "armenian", + "catalan", + "danish", + "dutch", + "english", + "finnish", + "french", + "german", + "greek", + "hindi", + "hungarian", + "indonesian", + "italian", + "lithuanian", + "nepali", + "norwegian", + "portuguese", + "romanian", + "russian", + "serbian", + "spanish", + "swedish", + "tamil", + "turkish", + "yiddish", +]; + #[inline(always)] fn pg_lang(lang: &Language) -> Option<&'static str> { match lang { diff --git a/tests/src/system/delivery.rs b/tests/src/system/delivery.rs index 84ed3956d..47ee5cb88 100644 --- a/tests/src/system/delivery.rs +++ b/tests/src/system/delivery.rs @@ -577,6 +577,52 @@ END:VCARD } } + // Sub-addressed local members must resolve when the list is expanded + admin + .registry_create_object(MailingList { + name: "tps-reports".to_string(), + recipients: Map::new(vec!["bill+tps@example.org".to_string()]), + domain_id, + ..Default::default() + }) + .await; + + let bill_messages = test + .server + .get_cached_messages(bill.id().document_id()) + .await + .unwrap() + .emails + .items + .len(); + + lmtp.ingest( + "john.doe@example.org", + &["tps-reports@example.org"], + concat!( + "From: john.doe@example.org\r\n", + "To: tps-reports@example.org\r\n", + "Subject: Cover sheet\r\n", + "\r\n", + "Did you get the memo about the new cover sheet?" + ), + ) + .await; + + tokio::time::sleep(Duration::from_millis(200)).await; + + assert_eq!( + test.server + .get_cached_messages(bill.id().document_id()) + .await + .unwrap() + .emails + .items + .len(), + bill_messages + 1, + "sub-addressed mailing list member was not delivered" + ); + // Remove test data john.registry_destroy( ObjectType::MaskedEmail, diff --git a/tests/src/system/directory.rs b/tests/src/system/directory.rs index 13c650d99..c649c2705 100644 --- a/tests/src/system/directory.rs +++ b/tests/src/system/directory.rs @@ -72,6 +72,44 @@ pub async fn test(test: &TestServer) { .await .assert_type(SetErrorType::PrimaryKeyViolation); + // An alias matching another domain's name should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.net".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + aliases: Map::new(vec!["example.com".to_string()]), + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // An alias matching another domain's alias should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "example.net".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + aliases: Map::new(vec!["beispiel.de".to_string()]), + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + + // A domain name matching another domain's alias should not be allowed + account + .registry_create_object_expect_err(Domain { + name: "beispiel.de".to_string(), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await + .assert_type(SetErrorType::PrimaryKeyViolation); + // Invalid local part should not be allowed account .registry_create_object_expect_err(Account::User(UserAccount { @@ -317,6 +355,29 @@ pub async fn test(test: &TestServer) { ])) ); + // Verify that alias domains resolve without the primary name warming the cache + for address in [ + "johndoe@beispiel.de", + "sales@beispiel.de", + "newsletter@beispiel.de", + ] { + test.server.invalidate_all_local_caches(); + assert!( + test.server.domain("beispiel.de").await.unwrap().is_some(), + "Alias domain failed to resolve on a cold cache" + ); + + test.server.invalidate_all_local_caches(); + assert!( + test.server + .rcpt_id_from_email(address) + .await + .unwrap() + .is_some(), + "Cold cache resolution failed for {address}" + ); + } + // Verify RCPT expansion for (address, expected) in [ ( @@ -401,47 +462,47 @@ pub async fn test(test: &TestServer) { })) .await; assert_eq!( - test.server.rcpt_resolve("unknown", 0).await.unwrap(), + test.server.rcpt_resolve("unknown", true, 0).await.unwrap(), RcptResolution::UnknownDomain ); assert_eq!( test.server - .rcpt_resolve("unknown@unknown.org", 0) + .rcpt_resolve("unknown@unknown.org", true, 0) .await .unwrap(), RcptResolution::UnknownDomain ); assert_eq!( test.server - .rcpt_resolve("jdoe@example.com", 0) + .rcpt_resolve("jdoe@example.com", true, 0) .await .unwrap(), RcptResolution::Accept ); assert_eq!( test.server - .rcpt_resolve("johndoe@beispiel.de", 0) + .rcpt_resolve("johndoe@beispiel.de", true, 0) .await .unwrap(), RcptResolution::Accept ); assert_eq!( test.server - .rcpt_resolve("sales@example.com", 0) + .rcpt_resolve("sales@example.com", true, 0) .await .unwrap(), RcptResolution::Accept ); assert_eq!( test.server - .rcpt_resolve("jdoe+promotions@example.com", 0) + .rcpt_resolve("jdoe+promotions@example.com", true, 0) .await .unwrap(), RcptResolution::Rewrite("jdoe@example.com".into()) ); assert_eq!( test.server - .rcpt_resolve("newsletter@example.com", 0) + .rcpt_resolve("newsletter@example.com", true, 0) .await .unwrap(), RcptResolution::Expand(Arc::from(Box::from_iter([ @@ -451,28 +512,42 @@ pub async fn test(test: &TestServer) { ); assert_eq!( test.server - .rcpt_resolve("unknown@example.com", 0) + .rcpt_resolve("unknown@example.com", true, 0) .await .unwrap(), RcptResolution::Rewrite("catchy@example.com".into()) ); assert_eq!( test.server - .rcpt_resolve("subaddresser.ignoreme@another-example.com", 0) - .await - .unwrap(), - RcptResolution::Rewrite("subaddresser@another-example.com".into()) - ); - assert_eq!( - test.server - .rcpt_resolve("unknown@another-example.com", 0) + .rcpt_resolve("unknown@example.com", false, 0) .await .unwrap(), RcptResolution::UnknownRecipient ); assert_eq!( test.server - .rcpt_resolve(masked_email.as_str(), 0) + .rcpt_resolve("jdoe+promotions@example.com", false, 0) + .await + .unwrap(), + RcptResolution::Rewrite("jdoe@example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("subaddresser.ignoreme@another-example.com", true, 0) + .await + .unwrap(), + RcptResolution::Rewrite("subaddresser@another-example.com".into()) + ); + assert_eq!( + test.server + .rcpt_resolve("unknown@another-example.com", true, 0) + .await + .unwrap(), + RcptResolution::UnknownRecipient + ); + assert_eq!( + test.server + .rcpt_resolve(masked_email.as_str(), true, 0) .await .unwrap(), RcptResolution::Rewrite("johndoe@example.com".into())