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:
Maurus Decimus
2026-08-17 19:18:21 +02:00
parent 299caf3701
commit deb034bf9e
19 changed files with 450 additions and 71 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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?;
+1
View File
@@ -104,6 +104,7 @@ pub enum CacheInvalidation {
domain_id: u32,
local_part_hash: u32,
},
DomainNegative,
}
#[derive(Debug)]
+9 -2
View File
@@ -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()));
}
+7 -3
View File
@@ -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
+41 -2
View File
@@ -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
+60 -1
View File
@@ -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);
}
+2
View File
@@ -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(()),
});
}
+38 -21
View File
@@ -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);
+2 -2
View File
@@ -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)) => {
+1 -1
View File
@@ -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)) => {
+55 -11
View File
@@ -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()
}
+2
View File
@@ -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)]
+58 -7
View File
@@ -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 {
+46
View File
@@ -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,
+92 -17
View File
@@ -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())