diff --git a/CHANGELOG.md b/CHANGELOG.md index eae8ddd8..2e36a543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,16 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [0.16.12] - 2026-06-XX +## [0.16.12] - 2026-07-06 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. ## Added - DKIM2 implementation ([draft-ietf-dkim-dkim2-spec-03](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/)). +- DMARCbis implementation: + - Domain-based Message Authentication, Reporting, and Conformance (DMARC) ([RFC 9989](https://datatracker.ietf.org/doc/html/rfc9989)) + - DMARC Aggregate Reporting ([RFC 9990](https://datatracker.ietf.org/doc/html/rfc9990)) + - DMARC Failure Reporting ([RFC 9991](https://datatracker.ietf.org/doc/html/rfc9991)) ## Changed diff --git a/Cargo.lock b/Cargo.lock index 0067227f..e069f21c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4418,9 +4418,9 @@ dependencies = [ [[package]] name = "mail-auth" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2a3c0ef3c8f70fc3a2ce043453605b4d5e9501f5ff2318aac92ab8d59fb78b" +checksum = "a88908ddf3a07fa2e76873f84d28476d3c802173fee0a44f6c5e5472523e6986" dependencies = [ "aws-lc-rs", "flate2", diff --git a/crates/jmap/src/registry/mapping/action.rs b/crates/jmap/src/registry/mapping/action.rs index 2c095893..f9e1b529 100644 --- a/crates/jmap/src/registry/mapping/action.rs +++ b/crates/jmap/src/registry/mapping/action.rs @@ -9,7 +9,6 @@ use common::{ Server, config::mailstore::spamfilter::SpamFilterAction, ipc::{BroadcastEvent, QueueEvent, RegistryChange}, - psl, }; use jmap_proto::error::set::{SetError, SetErrorType}; use jmap_tools::{JsonPointer, Key}; @@ -347,7 +346,6 @@ async fn classify_spam(server: &Server, mut request: SpamClassify) -> Option serde::Deserialize<'de> for DmarcAlignment { } } +impl EnumImpl for DmarcDiscovery { + fn parse(value: &str) -> Option { + hashify::tiny_map! { + value.as_bytes(), + b"psl" => DmarcDiscovery::Psl, + b"treewalk" => DmarcDiscovery::Treewalk, + b"unspecified" => DmarcDiscovery::Unspecified, + } + } + + fn as_str(&self) -> &'static str { + match self { + DmarcDiscovery::Psl => "psl", + DmarcDiscovery::Treewalk => "treewalk", + DmarcDiscovery::Unspecified => "unspecified", + } + } + + fn to_id(&self) -> u16 { + *self as u16 + } + + fn from_id(id: u16) -> Option { + match id { + 0 => Some(DmarcDiscovery::Psl), + 1 => Some(DmarcDiscovery::Treewalk), + 2 => Some(DmarcDiscovery::Unspecified), + _ => None, + } + } + + const COUNT: usize = 3; +} + +impl serde::Serialize for DmarcDiscovery { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for DmarcDiscovery { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = Cow::::deserialize(deserializer)?; + Self::parse(&s).ok_or_else(|| serde::de::Error::unknown_variant(&s, &[])) + } +} + impl EnumImpl for DmarcDisposition { fn parse(value: &str) -> Option { hashify::tiny_map! { @@ -2421,6 +2474,7 @@ impl EnumImpl for DmarcPolicyOverride { b"MailingList" => DmarcPolicyOverride::MailingList, b"LocalPolicy" => DmarcPolicyOverride::LocalPolicy, b"Other" => DmarcPolicyOverride::Other, + b"PolicyTestMode" => DmarcPolicyOverride::PolicyTestMode, } } @@ -2432,6 +2486,7 @@ impl EnumImpl for DmarcPolicyOverride { DmarcPolicyOverride::MailingList => "MailingList", DmarcPolicyOverride::LocalPolicy => "LocalPolicy", DmarcPolicyOverride::Other => "Other", + DmarcPolicyOverride::PolicyTestMode => "PolicyTestMode", } } @@ -2447,11 +2502,12 @@ impl EnumImpl for DmarcPolicyOverride { 3 => Some(DmarcPolicyOverride::MailingList), 4 => Some(DmarcPolicyOverride::LocalPolicy), 5 => Some(DmarcPolicyOverride::Other), + 6 => Some(DmarcPolicyOverride::PolicyTestMode), _ => None, } } - const COUNT: usize = 6; + const COUNT: usize = 7; } impl serde::Serialize for DmarcPolicyOverride { diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs index 3525f878..c307a98b 100644 --- a/crates/registry/src/schema/properties.rs +++ b/crates/registry/src/schema/properties.rs @@ -615,6 +615,7 @@ pub enum Property { FromName = 40, FutureRelease = 521, GenerateDkimKeys = 124, + Generator = 918, GeoUrls = 103, GetMaxResults = 436, GreetingTimeout = 508, @@ -874,11 +875,13 @@ pub enum Property { Policies = 846, PolicyAdkim = 250, PolicyAspf = 251, + PolicyDiscoveryMethod = 920, PolicyDisposition = 252, PolicyDomain = 248, PolicyFailureReportingOptions = 255, PolicyIdentifier = 237, PolicyIdentifiers = 840, + PolicyNp = 919, PolicyOverrideReasons = 262, PolicyStrings = 848, PolicySubdomainDisposition = 253, diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index 51ef6e47..fe4be8d3 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -768,6 +768,7 @@ impl EnumImpl for Property { b"fromName" => Property::FromName, b"futureRelease" => Property::FutureRelease, b"generateDkimKeys" => Property::GenerateDkimKeys, + b"generator" => Property::Generator, b"geoUrls" => Property::GeoUrls, b"getMaxResults" => Property::GetMaxResults, b"greetingTimeout" => Property::GreetingTimeout, @@ -1027,11 +1028,13 @@ impl EnumImpl for Property { b"policies" => Property::Policies, b"policyAdkim" => Property::PolicyAdkim, b"policyAspf" => Property::PolicyAspf, + b"policyDiscoveryMethod" => Property::PolicyDiscoveryMethod, b"policyDisposition" => Property::PolicyDisposition, b"policyDomain" => Property::PolicyDomain, b"policyFailureReportingOptions" => Property::PolicyFailureReportingOptions, b"policyIdentifier" => Property::PolicyIdentifier, b"policyIdentifiers" => Property::PolicyIdentifiers, + b"policyNp" => Property::PolicyNp, b"policyOverrideReasons" => Property::PolicyOverrideReasons, b"policyStrings" => Property::PolicyStrings, b"policySubdomainDisposition" => Property::PolicySubdomainDisposition, @@ -1691,6 +1694,7 @@ impl EnumImpl for Property { Property::FromName => "fromName", Property::FutureRelease => "futureRelease", Property::GenerateDkimKeys => "generateDkimKeys", + Property::Generator => "generator", Property::GeoUrls => "geoUrls", Property::GetMaxResults => "getMaxResults", Property::GreetingTimeout => "greetingTimeout", @@ -1950,11 +1954,13 @@ impl EnumImpl for Property { Property::Policies => "policies", Property::PolicyAdkim => "policyAdkim", Property::PolicyAspf => "policyAspf", + Property::PolicyDiscoveryMethod => "policyDiscoveryMethod", Property::PolicyDisposition => "policyDisposition", Property::PolicyDomain => "policyDomain", Property::PolicyFailureReportingOptions => "policyFailureReportingOptions", Property::PolicyIdentifier => "policyIdentifier", Property::PolicyIdentifiers => "policyIdentifiers", + Property::PolicyNp => "policyNp", Property::PolicyOverrideReasons => "policyOverrideReasons", Property::PolicyStrings => "policyStrings", Property::PolicySubdomainDisposition => "policySubdomainDisposition", @@ -2618,6 +2624,7 @@ impl EnumImpl for Property { 40 => Some(Property::FromName), 521 => Some(Property::FutureRelease), 124 => Some(Property::GenerateDkimKeys), + 918 => Some(Property::Generator), 103 => Some(Property::GeoUrls), 436 => Some(Property::GetMaxResults), 508 => Some(Property::GreetingTimeout), @@ -2877,11 +2884,13 @@ impl EnumImpl for Property { 846 => Some(Property::Policies), 250 => Some(Property::PolicyAdkim), 251 => Some(Property::PolicyAspf), + 920 => Some(Property::PolicyDiscoveryMethod), 252 => Some(Property::PolicyDisposition), 248 => Some(Property::PolicyDomain), 255 => Some(Property::PolicyFailureReportingOptions), 237 => Some(Property::PolicyIdentifier), 840 => Some(Property::PolicyIdentifiers), + 919 => Some(Property::PolicyNp), 262 => Some(Property::PolicyOverrideReasons), 848 => Some(Property::PolicyStrings), 253 => Some(Property::PolicySubdomainDisposition), @@ -3179,7 +3188,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 918; + const COUNT: usize = 921; } impl serde::Serialize for Property { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index 252b9623..108cc45f 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -1141,6 +1141,12 @@ pub struct DmarcReport { pub records: List, #[serde(rename = "extensions")] pub extensions: List, + #[serde(rename = "generator")] + pub generator: Option, + #[serde(rename = "policyNp")] + pub policy_np: DmarcDisposition, + #[serde(rename = "policyDiscoveryMethod")] + pub policy_discovery_method: DmarcDiscovery, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 6016f757..66e2b516 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -4048,7 +4048,7 @@ impl RegistryJsonPropertyPatch for BlockedIp { impl ObjectImpl for Bootstrap { const FLAGS: u64 = OBJ_SINGLETON; - const VERSION: u8 = 0; + const VERSION: u8 = 1; const OBJECT: ObjectType = ObjectType::Bootstrap; fn validate(&self, errors: &mut Vec) -> bool { @@ -8172,7 +8172,7 @@ impl RegistryJsonPropertyPatch for DmarcExtension { impl ObjectImpl for DmarcExternalReport { const FLAGS: u64 = OBJ_FILTER_TENANT; - const VERSION: u8 = 0; + const VERSION: u8 = 1; const OBJECT: ObjectType = ObjectType::DmarcExternalReport; fn validate(&self, errors: &mut Vec) -> bool { @@ -8297,7 +8297,7 @@ impl RegistryJsonPropertyPatch for DmarcExternalReport { impl ObjectImpl for DmarcInternalReport { const FLAGS: u64 = 0; - const VERSION: u8 = 0; + const VERSION: u8 = 1; const OBJECT: ObjectType = ObjectType::DmarcInternalReport; fn validate(&self, errors: &mut Vec) -> bool { @@ -8518,6 +8518,11 @@ impl DmarcReport { for value in value.values() { value.validate(errors); } + if let Some(value) = &self.generator { + if value.is_empty() { + errors.push(ValidationError::required(Property::Generator)); + } + } errors.len() == neb } } @@ -8542,6 +8547,9 @@ impl Pickle for DmarcReport { self.policy_failure_reporting_options.pickle(out); self.records.pickle(out); self.extensions.pickle(out); + self.generator.pickle(out); + self.policy_np.pickle(out); + self.policy_discovery_method.pickle(out); } fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { @@ -8564,6 +8572,15 @@ impl Pickle for DmarcReport { this.policy_failure_reporting_options = Pickle::unpickle(stream)?; this.records = Pickle::unpickle(stream)?; this.extensions = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.generator = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.policy_np = Pickle::unpickle(stream)?; + } + if stream.version() >= 1 { + this.policy_discovery_method = Pickle::unpickle(stream)?; + } Some(this) } } @@ -8589,13 +8606,16 @@ impl Default for DmarcReport { policy_failure_reporting_options: Default::default(), records: Default::default(), extensions: Default::default(), + generator: Default::default(), + policy_np: DmarcDisposition::Unspecified, + policy_discovery_method: DmarcDiscovery::Unspecified, } } } impl IntoValue for DmarcReport { fn into_value(self) -> JmapValue<'static> { - let mut map = jmap_tools::Map::with_capacity(20); + let mut map = jmap_tools::Map::with_capacity(23); map.insert_unchecked(Property::Version, self.version.into_value()); map.insert_unchecked(Property::OrgName, self.org_name.into_value()); map.insert_unchecked(Property::Email, self.email.into_value()); @@ -8629,6 +8649,12 @@ impl IntoValue for DmarcReport { ); map.insert_unchecked(Property::Records, self.records.into_value()); map.insert_unchecked(Property::Extensions, self.extensions.into_value()); + map.insert_unchecked(Property::Generator, self.generator.into_value()); + map.insert_unchecked(Property::PolicyNp, self.policy_np.into_value()); + map.insert_unchecked( + Property::PolicyDiscoveryMethod, + self.policy_discovery_method.into_value(), + ); JmapValue::Object(map) } } @@ -8664,6 +8690,11 @@ impl RegistryJsonPropertyPatch for DmarcReport { } Some(Property::Records) => self.records.patch(pointer, value), Some(Property::Extensions) => self.extensions.patch(pointer, value), + Some(Property::Generator) => self.generator.patch(pointer, value), + Some(Property::PolicyNp) => self.policy_np.patch(pointer, value), + Some(Property::PolicyDiscoveryMethod) => { + self.policy_discovery_method.patch(pointer, value) + } Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { property: Property::Type, value, diff --git a/crates/registry/src/utils/report.rs b/crates/registry/src/utils/report.rs index 0bac6411..6dca37f6 100644 --- a/crates/registry/src/utils/report.rs +++ b/crates/registry/src/utils/report.rs @@ -60,6 +60,26 @@ impl From for enums::DmarcDisposition { } } +impl From for Discovery { + fn from(value: enums::DmarcDiscovery) -> Self { + match value { + enums::DmarcDiscovery::Psl => Discovery::Psl, + enums::DmarcDiscovery::Treewalk => Discovery::Treewalk, + enums::DmarcDiscovery::Unspecified => Discovery::Unspecified, + } + } +} + +impl From for enums::DmarcDiscovery { + fn from(value: Discovery) -> Self { + match value { + Discovery::Psl => enums::DmarcDiscovery::Psl, + Discovery::Treewalk => enums::DmarcDiscovery::Treewalk, + Discovery::Unspecified => enums::DmarcDiscovery::Unspecified, + } + } +} + impl From for ActionDisposition { fn from(value: enums::DmarcActionDisposition) -> Self { match value { @@ -107,12 +127,15 @@ impl From for enums::DmarcResult { impl From for PolicyOverride { fn from(value: enums::DmarcPolicyOverride) -> Self { match value { - enums::DmarcPolicyOverride::Forwarded => PolicyOverride::Forwarded, - enums::DmarcPolicyOverride::SampledOut => PolicyOverride::SampledOut, enums::DmarcPolicyOverride::TrustedForwarder => PolicyOverride::TrustedForwarder, enums::DmarcPolicyOverride::MailingList => PolicyOverride::MailingList, enums::DmarcPolicyOverride::LocalPolicy => PolicyOverride::LocalPolicy, - enums::DmarcPolicyOverride::Other => PolicyOverride::Other, + enums::DmarcPolicyOverride::PolicyTestMode => PolicyOverride::PolicyTestMode, + // "forwarded" and "sampled_out" were removed in RFC 9990; map any + // legacy stored value to the closest surviving override type. + enums::DmarcPolicyOverride::Forwarded + | enums::DmarcPolicyOverride::SampledOut + | enums::DmarcPolicyOverride::Other => PolicyOverride::Other, } } } @@ -120,11 +143,10 @@ impl From for PolicyOverride { impl From for enums::DmarcPolicyOverride { fn from(value: PolicyOverride) -> Self { match value { - PolicyOverride::Forwarded => enums::DmarcPolicyOverride::Forwarded, - PolicyOverride::SampledOut => enums::DmarcPolicyOverride::SampledOut, PolicyOverride::TrustedForwarder => enums::DmarcPolicyOverride::TrustedForwarder, PolicyOverride::MailingList => enums::DmarcPolicyOverride::MailingList, PolicyOverride::LocalPolicy => enums::DmarcPolicyOverride::LocalPolicy, + PolicyOverride::PolicyTestMode => enums::DmarcPolicyOverride::PolicyTestMode, PolicyOverride::Other => enums::DmarcPolicyOverride::Other, } } @@ -357,6 +379,7 @@ impl From for Report { end: value.date_range_end.timestamp() as u64, }, error: value.errors.into_inner(), + generator: value.generator, }, policy_published: PolicyPublished { domain: value.policy_domain, @@ -365,6 +388,8 @@ impl From for Report { aspf: value.policy_aspf.into(), p: value.policy_disposition.into(), sp: value.policy_subdomain_disposition.into(), + np: value.policy_np.into(), + discovery_method: value.policy_discovery_method.into(), testing: value.policy_testing_mode, fo: failure_reporting_options_to_fo( value.policy_failure_reporting_options.as_slice(), @@ -390,15 +415,18 @@ impl From for structs::DmarcReport { errors: value.report_metadata.error.into(), extensions: List::from_iter(value.extensions.into_iter().map(Into::into)), extra_contact_info: value.report_metadata.extra_contact_info, + generator: value.report_metadata.generator, org_name: value.report_metadata.org_name, policy_adkim: value.policy_published.adkim.into(), policy_aspf: value.policy_published.aspf.into(), + policy_discovery_method: value.policy_published.discovery_method.into(), policy_disposition: value.policy_published.p.into(), policy_domain: value.policy_published.domain, policy_failure_reporting_options: fo_to_failure_reporting_options( &value.policy_published.fo, ) .into(), + policy_np: value.policy_published.np.into(), policy_subdomain_disposition: value.policy_published.sp.into(), policy_testing_mode: value.policy_published.testing, policy_version: value diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 0c7a6934..3bc6ded0 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -25,7 +25,6 @@ use common::{ }, }, network::SessionStream, - psl, scripts::ScriptModification, }; use mail_auth::{ @@ -370,9 +369,6 @@ impl Session { &self.data.helo_domain }, spf_output, - domain_suffix_fn: |domain| { - psl::domain_str(domain).unwrap_or(domain) - }, }, )) .await; diff --git a/crates/smtp/src/reporting/dmarc.rs b/crates/smtp/src/reporting/dmarc.rs index d46f4fd4..e36add76 100644 --- a/crates/smtp/src/reporting/dmarc.rs +++ b/crates/smtp/src/reporting/dmarc.rs @@ -57,13 +57,16 @@ impl Session { let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap(); let config = &self.server.core.smtp.report.dmarc; - // Send failure report - if let (Some(failure_rate), Some(report_options)) = ( - self.server - .eval_if::(&config.send, self, self.data.session_id) - .await, - dmarc_output.failure_report(), - ) { + // Send failure report. RFC 9991 Section 2: report generators MUST NOT + // honor "ruf" for policy records published with "psd=y". + if !matches!(dmarc_record.psd, dmarc::Psd::Yes) + && let (Some(failure_rate), Some(report_options)) = ( + self.server + .eval_if::(&config.send, self, self.data.session_id) + .await, + dmarc_output.failure_report(), + ) + { // Verify that any external reporting addresses are authorized let rcpts = match self .server @@ -197,12 +200,11 @@ impl Session { }; auth_failure - .with_identity_alignment(if dkim_failed && spf_failed { - IdentityAlignment::DkimSpf - } else if dkim_failed { - IdentityAlignment::Dkim - } else { - IdentityAlignment::Spf + .with_identity_alignment(match (dkim_failed, spf_failed) { + (true, true) => IdentityAlignment::DkimSpf, + (true, false) => IdentityAlignment::Dkim, + (false, true) => IdentityAlignment::Spf, + (false, false) => IdentityAlignment::None, }) .write_rfc5322( ( @@ -580,6 +582,8 @@ impl DmarcReporting for Server { } .into(), policy_subdomain_disposition: policy.sp.into(), + policy_np: policy.np.into(), + policy_discovery_method: policy.discovery_method.into(), policy_testing_mode: policy.testing, policy_version: None, version: 1.0.into(), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index db069c26..625a94bc 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 5b1bcc22..0b95777b 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -hFFGU64dDXCQt2y4dqKbd_2qzMdcWCa1eKu2PT1zzrM \ No newline at end of file +lV3eX4039eN4Y1KtcoXv5vnm6_-nGt_bHuLB6jlkFZQ \ No newline at end of file diff --git a/tests/src/smtp/reporting/analyze.rs b/tests/src/smtp/reporting/analyze.rs index 96b9a8c9..2b9a54e9 100644 --- a/tests/src/smtp/reporting/analyze.rs +++ b/tests/src/smtp/reporting/analyze.rs @@ -93,10 +93,23 @@ async fn report_analyze() { ac += 1; } } - tokio::time::sleep(Duration::from_millis(200)).await; + + // Report ingestion is asynchronous, poll until the reports are stored + let admin = test.account("admin"); + for _ in 0..50 { + if admin.registry_get_all::().await.len() + == total_reports_received["dmarc"] + && admin.registry_get_all::().await.len() + == total_reports_received["tls"] + && admin.registry_get_all::().await.len() + == total_reports_received["arf"] + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } // Purging the database shouldn't remove the reports - let admin = test.account("admin"); admin .registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance { maintenance_type: TaskStoreMaintenanceType::PurgeData,