mirror of
https://github.com/stalwartlabs/stalwart.git
synced 2026-10-30 16:15:46 +00:00
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
This commit is contained in:
+2
-1
@@ -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.
|
||||
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -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?;
|
||||
|
||||
@@ -104,6 +104,7 @@ pub enum CacheInvalidation {
|
||||
domain_id: u32,
|
||||
local_part_hash: u32,
|
||||
},
|
||||
DomainNegative,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -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<RcptResolution> {
|
||||
pub async fn rcpt_resolve(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
allow_catch_all: bool,
|
||||
session_id: u64,
|
||||
) -> trc::Result<RcptResolution> {
|
||||
// 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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3459,6 +3459,11 @@ impl ObjectType {
|
||||
IndexSchemaType::Search,
|
||||
IndexSchemaValueType::Text,
|
||||
),
|
||||
IndexSchema::new(
|
||||
Property::Aliases,
|
||||
IndexSchemaType::Unique,
|
||||
IndexSchemaValueType::Keyword,
|
||||
),
|
||||
IndexSchema::new(
|
||||
Property::MemberTenantId,
|
||||
IndexSchemaType::Search,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
|
||||
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(()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ impl<T: SessionStream> Session<T> {
|
||||
|
||||
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<T: SessionStream> Session<T> {
|
||||
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);
|
||||
|
||||
@@ -15,7 +15,7 @@ impl<T: SessionStream> Session<T> {
|
||||
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<T: SessionStream> Session<T> {
|
||||
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)) => {
|
||||
|
||||
@@ -622,7 +622,7 @@ impl MessageWrapper {
|
||||
pub async fn expand_and_add_recipient(&mut self, rcpt: impl AsRef<str>, 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)) => {
|
||||
|
||||
@@ -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<T: SearchableField + PsqlSearchField + 'static>(
|
||||
|
||||
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<Vec<String>> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<IndexDocument>) -> 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user