diff --git a/Cargo.lock b/Cargo.lock index 1c26a28..d7c2a88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7762,11 +7762,13 @@ dependencies = [ "common", "dns-update", "email", + "futures", "groupware", "hkdf 0.13.0", "inbuxa-features", "jmap-tools", "jmap_proto", + "mail-auth", "mail-builder 1.0.0", "mail-parser", "memory-stats", diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index d696272..c4160d4 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -111,6 +111,9 @@ impl Server { Permission::SysLegalHoldCreate, Permission::SysLegalHoldUpdate, Permission::SysLegalHoldExport, + // inbuxa: DL-20: the lists and the check are the server's + Permission::SysDeliverabilityUpdate, + Permission::SysDeliverabilityCheck, ] { permissions.disabled.set(permission as usize); } @@ -304,6 +307,16 @@ impl Default for DefaultPermissions { default.superuser.push(permission); default.tenant.push(permission); } + // inbuxa: deliverability spec, DL-20: a tenant administrator + // reads its own domains' findings; the lists and the check + // itself are the server's + Permission::SysDeliverabilityGet => { + default.superuser.push(permission); + default.tenant.push(permission); + } + Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => { + default.superuser.push(permission); + } // inbuxa: DLP and mail flow rules, and held mail, are the // server's: never a tenant's (dlp-and-mail-flow-rules spec, // settled answer 3) diff --git a/crates/common/src/ipc.rs b/crates/common/src/ipc.rs index c79f935..030fc17 100644 --- a/crates/common/src/ipc.rs +++ b/crates/common/src/ipc.rs @@ -88,6 +88,8 @@ pub enum BroadcastEvent { QueueRefresh, // inbuxa: AL-3: end an account's open sessions on every node EndSessions(u32), + // inbuxa: deliverability spec, DL-15: every node checks itself now + DeliverabilityCheck, } #[derive(Debug, Clone, Copy)] diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index 49d38f0..a7884e2 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -31,8 +31,9 @@ use types::id::Id; /// Granted to the default administrator roles: "Explain this" /// (ai-explain spec, EX-4: superuser by default), the audit log, account /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and -/// the data inventory (personal-data catalog spec), and accepting security -/// to-do items (security to-do list spec). +/// the data inventory (personal-data catalog spec), accepting security +/// to-do items (security to-do list spec), and the deliverability check +/// (deliverability spec). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysJournalGet, Permission::SysJournalUpdate, Permission::SysSecurityAccept, + Permission::SysDeliverabilityGet, + Permission::SysDeliverabilityUpdate, + Permission::SysDeliverabilityCheck, ]; /// Granted to the server-level Compliance Officer role once it exists: @@ -73,7 +77,8 @@ const OFFICER_GRANTS: &[Permission] = &[ /// Granted to the default tenant administrator roles: reading and exporting /// the tenant's audit log (AU-9), locking and delegating its accounts -/// (AL-12), and the tenant's slice of the data inventory. +/// (AL-12), the tenant's slice of the data inventory, and its own domains' +/// deliverability findings (DL-20). const TENANT_GRANTS: &[Permission] = &[ Permission::SysAuditGet, Permission::SysAuditExport, @@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[ Permission::SysAccountLockUpdate, Permission::SysAccountLockDestroy, Permission::SysComplianceGet, + Permission::SysDeliverabilityGet, ]; #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/features/src/deliverability/lists.rs b/crates/features/src/deliverability/lists.rs new file mode 100644 index 0000000..84af2a0 --- /dev/null +++ b/crates/features/src/deliverability/lists.rs @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The blocklists a node asks about itself (deliverability spec, DL-6), and +//! how to read each one's answer. +//! +//! A list answers with an address in 127.0.0.0/8. Each list says which of +//! those mean "listed" and which mean "I won't answer you": Spamhaus, for +//! one, answers `127.255.255.254` to a query that came through a public +//! resolver. A refusal is never read as a listing (DL-4). + +use std::net::{IpAddr, Ipv4Addr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// Looked up by the reversed address: `2.0.0.127.zen.spamhaus.org`. + Ip, + /// Looked up by name: `example.org.dbl.spamhaus.org`. + Domain, +} + +#[derive(Debug, Clone, Copy)] +pub struct BlockList { + /// What the page and the settings call it. + pub name: &'static str, + pub zone: &'static str, + pub scope: Scope, + /// Where an administrator looks the address up and asks for removal. + pub lookup: &'static str, + /// Something the page says beside the list. + pub note: Option<&'static str>, + read: fn(Ipv4Addr) -> Answer, +} + +/// What a list's answer means. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Answer { + Listed(&'static str), + /// The list won't answer this resolver, or not now. + Refused(&'static str), + /// A code the list doesn't define: neither listed nor clean. + Unknown, +} + +impl BlockList { + pub fn read(&self, answer: Ipv4Addr) -> Answer { + (self.read)(answer) + } + + /// The name to look up for `subject`, or None when the subject doesn't + /// suit the list (a domain on an IP list, or an IPv6 address: none of + /// these lists publish IPv6 zones worth asking). + pub fn query(&self, subject: &Subject<'_>) -> Option { + match (self.scope, subject) { + (Scope::Ip, Subject::Ip(IpAddr::V4(ip))) => { + let [a, b, c, d] = ip.octets(); + Some(format!("{d}.{c}.{b}.{a}.{}.", self.zone)) + } + (Scope::Domain, Subject::Domain(domain)) => { + Some(format!("{}.{}.", domain.trim_end_matches('.'), self.zone)) + } + _ => None, + } + } +} + +pub enum Subject<'x> { + Ip(IpAddr), + Domain(&'x str), +} + +/// Spamhaus' error codes, the same on every Spamhaus zone. +fn spamhaus_refusal(ip: Ipv4Addr) -> Option { + match ip.octets() { + [127, 255, 255, 252] => Some(Answer::Refused("The query was malformed")), + [127, 255, 255, 254] => Some(Answer::Refused( + "Spamhaus doesn't answer public resolvers; use the server's own", + )), + [127, 255, 255, 255] => Some(Answer::Refused("Too many queries from this resolver")), + _ => None, + } +} + +fn zen(ip: Ipv4Addr) -> Answer { + if let Some(refused) = spamhaus_refusal(ip) { + return refused; + } + match ip.octets() { + [127, 0, 0, 2] => Answer::Listed("SBL: a known spam source"), + [127, 0, 0, 3] => Answer::Listed("CSS: sent spam recently"), + [127, 0, 0, 4..=7] => Answer::Listed("XBL: a compromised or infected host"), + [127, 0, 0, 9] => Answer::Listed("DROP: a hijacked or criminal network"), + [127, 0, 0, 10 | 11] => { + Answer::Listed("PBL: an address that isn't meant to send mail directly") + } + _ => Answer::Unknown, + } +} + +fn dbl(ip: Ipv4Addr) -> Answer { + if let Some(refused) = spamhaus_refusal(ip) { + return refused; + } + match ip.octets() { + [127, 0, 1, 2] => Answer::Listed("A spam domain"), + [127, 0, 1, 4] => Answer::Listed("A phishing domain"), + [127, 0, 1, 5] => Answer::Listed("A malware domain"), + [127, 0, 1, 6] => Answer::Listed("A botnet controller"), + [127, 0, 1, 102..=106] => Answer::Listed("A legitimate domain being abused"), + [127, 0, 1, 255] => Answer::Refused("The query was malformed"), + _ => Answer::Unknown, + } +} + +/// Most lists answer 127.0.0.2 for "listed" and define nothing else. +fn just_two(ip: Ipv4Addr) -> Answer { + match ip.octets() { + [127, 0, 0, 2] => Answer::Listed("Listed"), + _ => Answer::Unknown, + } +} + +fn surbl(ip: Ipv4Addr) -> Answer { + match ip.octets() { + [127, 0, 0, 1] => Answer::Refused("SURBL doesn't answer this resolver"), + [127, 0, 0, bits] if bits & (8 | 16 | 64 | 128) != 0 => { + Answer::Listed("Seen in phishing, malware, abuse or cracked sites") + } + _ => Answer::Unknown, + } +} + +fn uribl(ip: Ipv4Addr) -> Answer { + match ip.octets() { + [127, 0, 0, 1] => Answer::Refused("URIBL doesn't answer public resolvers"), + [127, 0, 0, bits] if bits & (2 | 8) != 0 => Answer::Listed("Seen in spam"), + [127, 0, 0, bits] if bits & 4 != 0 => { + Answer::Listed("Grey: seen in bulk mail some people don't want") + } + _ => Answer::Unknown, + } +} + +pub const LISTS: &[BlockList] = &[ + BlockList { + name: "Spamhaus ZEN", + zone: "zen.spamhaus.org", + scope: Scope::Ip, + lookup: "https://check.spamhaus.org/", + note: None, + read: zen, + }, + BlockList { + name: "SpamCop", + zone: "bl.spamcop.net", + scope: Scope::Ip, + lookup: "https://www.spamcop.net/bl.shtml", + note: None, + read: just_two, + }, + BlockList { + name: "Barracuda", + zone: "b.barracudacentral.org", + scope: Scope::Ip, + lookup: "https://www.barracudacentral.org/lookups", + note: Some( + "Barracuda answers only resolvers whose address is registered with it (free, at barracudacentral.org/rbl). Until then its lookups can't be checked.", + ), + read: just_two, + }, + BlockList { + name: "UCEPROTECT level 1", + zone: "dnsbl-1.uceprotect.net", + scope: Scope::Ip, + lookup: "https://www.uceprotect.net/en/rblcheck.php", + note: None, + read: just_two, + }, + BlockList { + name: "Mailspike", + zone: "bl.mailspike.net", + scope: Scope::Ip, + lookup: "https://mailspike.org/iplookup.html", + note: None, + read: just_two, + }, + BlockList { + name: "PSBL", + zone: "psbl.surriel.com", + scope: Scope::Ip, + lookup: "https://psbl.org/", + note: None, + read: just_two, + }, + BlockList { + name: "Spamhaus DBL", + zone: "dbl.spamhaus.org", + scope: Scope::Domain, + lookup: "https://check.spamhaus.org/", + note: None, + read: dbl, + }, + BlockList { + name: "SURBL", + zone: "multi.surbl.org", + scope: Scope::Domain, + lookup: "https://surbl.org/surbl-analysis", + note: None, + read: surbl, + }, + BlockList { + name: "URIBL", + zone: "multi.uribl.com", + scope: Scope::Domain, + lookup: "https://admin.uribl.com/", + note: None, + read: uribl, + }, +]; + +pub fn by_name(name: &str) -> Option<&'static BlockList> { + LISTS.iter().find(|list| list.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> Ipv4Addr { + s.parse().unwrap() + } + + #[test] + fn a_refusal_is_not_a_listing() { + let zen = by_name("Spamhaus ZEN").unwrap(); + assert!(matches!( + zen.read(ip("127.255.255.254")), + Answer::Refused(_) + )); + assert!(matches!(zen.read(ip("127.0.0.2")), Answer::Listed(_))); + assert!(matches!(zen.read(ip("127.0.0.10")), Answer::Listed(_))); + assert_eq!(zen.read(ip("127.0.0.200")), Answer::Unknown); + + let uribl = by_name("URIBL").unwrap(); + assert!(matches!(uribl.read(ip("127.0.0.1")), Answer::Refused(_))); + assert!(matches!(uribl.read(ip("127.0.0.2")), Answer::Listed(_))); + } + + #[test] + fn queries_are_built_per_scope() { + let zen = by_name("Spamhaus ZEN").unwrap(); + let dbl = by_name("Spamhaus DBL").unwrap(); + let v4 = Subject::Ip("192.0.2.10".parse().unwrap()); + let v6 = Subject::Ip("2001:db8::1".parse().unwrap()); + let domain = Subject::Domain("example.org"); + assert_eq!( + zen.query(&v4).as_deref(), + Some("10.2.0.192.zen.spamhaus.org.") + ); + assert_eq!(zen.query(&v6), None); + assert_eq!(zen.query(&domain), None); + assert_eq!( + dbl.query(&domain).as_deref(), + Some("example.org.dbl.spamhaus.org.") + ); + assert_eq!(dbl.query(&v4), None); + } + + #[test] + fn names_are_unique() { + for (i, a) in LISTS.iter().enumerate() { + assert!( + LISTS[i + 1..].iter().all(|b| b.name != a.name), + "{}", + a.name + ); + } + } +} diff --git a/crates/features/src/deliverability/mod.rs b/crates/features/src/deliverability/mod.rs new file mode 100644 index 0000000..578b527 --- /dev/null +++ b/crates/features/src/deliverability/mod.rs @@ -0,0 +1,410 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The deliverability check (deliverability spec): what other mail servers +//! see when this one sends. Not a rebuild of anything upstream ships. +//! +//! Every node that sends mail checks itself, because only it knows which +//! address it leaves from, and keeps one report. The report holds facts: an +//! address's reverse DNS, what each blocklist answered, what SPF said for +//! each address, whether a DKIM key in DNS matches the one signing. The +//! console grades them, so its wording can change without a server release. +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts +//! with `D`, then one byte for the kind: +//! +//! - `r` + node id (u64): that node's last report, as JSON. +//! - `s`: the settings, as JSON. +//! +//! Numbers are big-endian. + +pub mod lists; + +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass}, +}; +use trc::AddContext; + +const FEATURE: u8 = b'D'; +const KIND_REPORT: u8 = b'r'; +const KIND_SETTINGS: u8 = b's'; + +/// DL-15: **Check now** runs a node again only this long after its last run. +pub const MIN_INTERVAL_SECS: u64 = 600; + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Report { + /// The node's cluster id, as metric samples carry it. + pub node_id: u64, + pub hostname: String, + /// Seconds since the epoch. + pub checked_at: u64, + pub addresses: Vec
, + pub domains: Vec, + pub certificates: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Address { + pub ip: String, + /// DL-2: how the node came by the address. + pub source: AddressSource, + /// The connection strategy that sends from it. + pub strategy: String, + /// The name the node greets with from this address. + pub ehlo: String, + /// The PTR names, empty when there's none. + pub ptr: Vec, + /// Some PTR name resolves back to the address. + pub forward_confirmed: bool, + /// The forward-confirmed name is the EHLO name. + pub ehlo_matches: bool, + /// Set when the reverse lookup itself failed, rather than found nothing. + pub ptr_error: Option, + pub listings: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum AddressSource { + /// Set in the connection strategy's source addresses. + #[default] + Configured, + /// What the EHLO name resolves to. + Ehlo, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Listing { + /// The list's name, as in [`lists::LISTS`]. + pub list: String, + pub state: ListingState, + /// The address the list answered, when it answered one. + pub code: Option, + /// What the list says the answer means. + pub meaning: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListingState { + #[default] + Clean, + Listed, + /// The list wouldn't answer, or the lookup failed: neither listed nor clean. + Refused, + Error, + /// Switched off in the settings, so not asked. + Off, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DomainReport { + pub domain: String, + /// DL-20: a tenant administrator sees only their tenant's domains. + pub tenant_id: Option, + /// DL-7: what SPF says for each of the node's addresses. + pub spf: Vec, + /// DL-8: each DKIM key the domain signs with. + pub dkim: Vec, + /// DL-9: the DMARC record, if there's one. + pub dmarc: Option, + /// DL-10. + pub mta_sts: MtaSts, + /// DL-11: there's a `_smtp._tls` record. + pub tls_rpt: bool, + /// DL-12. + pub listings: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SpfResult { + pub ip: String, + /// `pass`, `fail`, `softFail`, `neutral`, `none`, `tempError` or `permError`. + pub result: String, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DkimKey { + pub selector: String, + pub state: DkimState, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub enum DkimState { + #[default] + Matches, + /// Nothing published at `._domainkey.`. + Missing, + /// Published, but a different key. + Different, + /// The lookup failed. + Error, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Dmarc { + /// `none`, `quarantine` or `reject`. + pub policy: String, + /// DKIM alignment: `relaxed` or `strict`. + pub adkim: String, + /// SPF alignment: `relaxed` or `strict`. + pub aspf: String, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct MtaSts { + /// The `_mta-sts` record's id; None when there's no record. + pub record_id: Option, + /// The policy was fetched and parsed. False with a record means the + /// fetch or the parse failed, and `error` says why. + pub fetched: bool, + pub error: Option, + /// `enforce`, `testing` or `none`. + pub mode: Option, + pub max_age: Option, + /// The domain's MX names no `mx:` line matches. + pub mx_not_covered: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Certificate { + /// The EHLO name, or an MX name that points at this node. + pub name: String, + /// The node holds a certificate for the name. + pub covered: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Settings { + /// DL-6: lists not to ask, by name. + pub disabled_lists: Vec, +} + +impl Settings { + pub fn is_off(&self, list: &str) -> bool { + self.disabled_lists.iter().any(|name| name == list) + } + + /// Only the built-in lists' names, once each. + pub fn validate(&self) -> Result<(), String> { + for (i, name) in self.disabled_lists.iter().enumerate() { + if lists::by_name(name).is_none() { + return Err(format!("There's no list called {name:?}.")); + } + if self.disabled_lists[..i].contains(name) { + return Err(format!("{name:?} is named twice.")); + } + } + Ok(()) + } +} + +impl Report { + /// DL-20: what a tenant administrator may see: their tenant's domains + /// and nothing about the node's addresses or certificates. + pub fn for_tenant(&self, tenant_id: u32) -> Report { + Report { + node_id: self.node_id, + hostname: self.hostname.clone(), + checked_at: self.checked_at, + addresses: Vec::new(), + domains: self + .domains + .iter() + .filter(|d| d.tenant_id == Some(tenant_id)) + .cloned() + .collect(), + certificates: Vec::new(), + } + } +} + +// --- Storage -------------------------------------------------------------- + +struct Json(T); + +impl Serialize for Json { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(&self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Failed to serialize deliverability data") + .reason(err) + }) + } +} + +impl SerdeDeserialize<'de> + Send + Sync> Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid deliverability data") + .reason(err) + }) + } +} + +fn class(kind: u8, node_id: Option) -> ValueClass { + let mut key = Vec::with_capacity(10); + key.push(FEATURE); + key.push(kind); + if let Some(node_id) = node_id { + key.extend_from_slice(&node_id.to_be_bytes()); + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +pub async fn report(data: &Store, node_id: u64) -> trc::Result> { + Ok(data + .get_value::>(ValueKey::from(class(KIND_REPORT, Some(node_id)))) + .await + .caused_by(trc::location!())? + .map(|Json(report)| report)) +} + +/// Every node's report, by node id. +pub async fn reports(data: &Store) -> trc::Result> { + let mut out = Vec::new(); + data.iterate( + IterateParams::new( + ValueKey::from(class(KIND_REPORT, Some(0))), + ValueKey::from(class(KIND_REPORT, Some(u64::MAX))), + ), + |_, value| { + if let Ok(Json(report)) = Json::::deserialize(value) { + out.push(report); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + out.sort_by_key(|r| r.node_id); + Ok(out) +} + +/// Replaces the node's report. +pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set( + class(KIND_REPORT, Some(report.node_id)), + Json(report).serialize()?, + ); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + +pub async fn settings(data: &Store) -> trc::Result { + Ok(data + .get_value::>(ValueKey::from(class(KIND_SETTINGS, None))) + .await + .caused_by(trc::location!())? + .map(|Json(settings)| settings) + .unwrap_or_default()) +} + +pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_name_only_built_in_lists_once() { + let ok = Settings { + disabled_lists: vec!["Barracuda".into(), "URIBL".into()], + }; + assert!(ok.validate().is_ok()); + assert!(ok.is_off("Barracuda")); + assert!(!ok.is_off("SpamCop")); + let unknown = Settings { + disabled_lists: vec!["My list".into()], + }; + assert!(unknown.validate().is_err()); + let twice = Settings { + disabled_lists: vec!["URIBL".into(), "URIBL".into()], + }; + assert!(twice.validate().is_err()); + } + + #[test] + fn a_tenant_sees_only_its_domains() { + let report = Report { + node_id: 2, + hostname: "mx2.example.org".into(), + checked_at: 1, + addresses: vec![Address { + ip: "192.0.2.10".into(), + ..Default::default() + }], + domains: vec![ + DomainReport { + domain: "a.example".into(), + tenant_id: Some(7), + ..Default::default() + }, + DomainReport { + domain: "b.example".into(), + tenant_id: Some(8), + ..Default::default() + }, + DomainReport { + domain: "server.example".into(), + tenant_id: None, + ..Default::default() + }, + ], + certificates: vec![Certificate { + name: "mx2.example.org".into(), + covered: true, + }], + }; + let seen = report.for_tenant(7); + assert!(seen.addresses.is_empty()); + assert!(seen.certificates.is_empty()); + assert_eq!( + seen.domains + .iter() + .map(|d| d.domain.as_str()) + .collect::>(), + ["a.example"] + ); + } + + #[test] + fn a_report_reads_back_with_missing_fields() { + let report: Report = serde_json::from_str(r#"{"nodeId": 3}"#).unwrap(); + assert_eq!(report.node_id, 3); + assert!(report.domains.is_empty()); + } +} diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index 1351d41..3b9fab9 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -21,6 +21,7 @@ pub mod ai; pub mod audit; pub mod branding; +pub mod deliverability; // inbuxa: the deliverability check (not a rebuild) pub mod hold; pub mod journal; pub mod lock; diff --git a/crates/jmap-proto/src/object/inbuxa_deliverability_report.rs b/crates/jmap-proto/src/object/inbuxa_deliverability_report.rs new file mode 100644 index 0000000..f0147a2 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_deliverability_report.rs @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DeliverabilityReport/get` and `/set` under `urn:inbuxa:jmap`: +//! each sending node's last deliverability check (deliverability spec). +//! One per node, written by the server. Creating one asks every node to +//! check itself now (DL-15); nothing is updated or destroyed. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct DeliverabilityReport; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeliverabilityReportProperty { + Id, + NodeId, + Hostname, + CheckedAt, + Addresses, + Domains, + Certificates, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeliverabilityReportValue { + Id(Id), +} + +impl Property for DeliverabilityReportProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the addresses, domains and certificates stay plain keys + match parent { + None => DeliverabilityReportProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeliverabilityReportProperty::Id => "id", + DeliverabilityReportProperty::NodeId => "nodeId", + DeliverabilityReportProperty::Hostname => "hostname", + DeliverabilityReportProperty::CheckedAt => "checkedAt", + DeliverabilityReportProperty::Addresses => "addresses", + DeliverabilityReportProperty::Domains => "domains", + DeliverabilityReportProperty::Certificates => "certificates", + } + .into() + } +} + +impl DeliverabilityReportProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => DeliverabilityReportProperty::Id, + b"nodeId" => DeliverabilityReportProperty::NodeId, + b"hostname" => DeliverabilityReportProperty::Hostname, + b"checkedAt" => DeliverabilityReportProperty::CheckedAt, + b"addresses" => DeliverabilityReportProperty::Addresses, + b"domains" => DeliverabilityReportProperty::Domains, + b"certificates" => DeliverabilityReportProperty::Certificates, + ) + } +} + +impl FromStr for DeliverabilityReportProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + DeliverabilityReportProperty::parse(s).ok_or(()) + } +} + +impl Element for DeliverabilityReportValue { + type Property = DeliverabilityReportProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(DeliverabilityReportProperty::Id) => { + Id::from_str(value).ok().map(DeliverabilityReportValue::Id) + } + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeliverabilityReportValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for DeliverabilityReport { + type Property = DeliverabilityReportProperty; + + type Element = DeliverabilityReportValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = DeliverabilityReportProperty::Id; +} + +impl From for DeliverabilityReportValue { + fn from(id: Id) -> Self { + DeliverabilityReportValue::Id(id) + } +} + +impl JmapObjectId for DeliverabilityReportValue { + fn as_id(&self) -> Option { + match self { + DeliverabilityReportValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + DeliverabilityReportValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = DeliverabilityReportValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for DeliverabilityReportProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/inbuxa_deliverability_settings.rs b/crates/jmap-proto/src/object/inbuxa_deliverability_settings.rs new file mode 100644 index 0000000..e020123 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_deliverability_settings.rs @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DeliverabilitySettings/get` and `/set` under `urn:inbuxa:jmap`: +//! which of the built-in blocklists the deliverability check leaves out +//! (deliverability spec, DL-6), and, read only, what the lists are. + +use crate::object::{AnyId, JmapObject, JmapObjectId}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct DeliverabilitySettings; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeliverabilitySettingsProperty { + Id, + DisabledLists, + Lists, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum DeliverabilitySettingsValue { + Id(Id), +} + +impl Property for DeliverabilitySettingsProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside the lists stay plain keys + match parent { + None => DeliverabilitySettingsProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeliverabilitySettingsProperty::Id => "id", + DeliverabilitySettingsProperty::DisabledLists => "disabledLists", + DeliverabilitySettingsProperty::Lists => "lists", + } + .into() + } +} + +impl DeliverabilitySettingsProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => DeliverabilitySettingsProperty::Id, + b"disabledLists" => DeliverabilitySettingsProperty::DisabledLists, + b"lists" => DeliverabilitySettingsProperty::Lists, + ) + } +} + +impl FromStr for DeliverabilitySettingsProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + DeliverabilitySettingsProperty::parse(s).ok_or(()) + } +} + +impl Element for DeliverabilitySettingsValue { + type Property = DeliverabilitySettingsProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(DeliverabilitySettingsProperty::Id) => Id::from_str(value) + .ok() + .map(DeliverabilitySettingsValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + DeliverabilitySettingsValue::Id(id) => id.to_string().into(), + } + } +} + +impl JmapObject for DeliverabilitySettings { + type Property = DeliverabilitySettingsProperty; + + type Element = DeliverabilitySettingsValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = (); + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = DeliverabilitySettingsProperty::Id; +} + +impl From for DeliverabilitySettingsValue { + fn from(id: Id) -> Self { + DeliverabilitySettingsValue::Id(id) + } +} + +impl JmapObjectId for DeliverabilitySettingsValue { + fn as_id(&self) -> Option { + match self { + DeliverabilitySettingsValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + DeliverabilitySettingsValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = DeliverabilitySettingsValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for DeliverabilitySettingsProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index fe691ba..b644b1e 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -31,6 +31,8 @@ pub mod inbuxa_audit; // inbuxa: the audit log pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items +pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check +pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check pub mod inbuxa_journal; // inbuxa: journaling pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export pub mod inbuxa_held_message; // inbuxa: mail held for review diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index 942dbf3..585dc51 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -91,6 +91,12 @@ impl Response<'_> { GetResponseMethod::SecurityAcceptance(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::DeliverabilityReport(response) => { + response.eval_jptr(path, &mut results) + } + GetResponseMethod::DeliverabilitySettings(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::Journal(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index bdca276..87bf1b3 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -56,6 +56,8 @@ impl Response<'_> { GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::MailRule(request) => request.resolve_references(self)?, GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?, + GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?, + GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?, GetRequestMethod::Journal(request) => request.resolve_references(self)?, GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?, GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?, @@ -140,6 +142,12 @@ impl Response<'_> { SetRequestMethod::SecurityAcceptance(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::DeliverabilityReport(request) => { + request.resolve_references(self, 1, false)? + } + SetRequestMethod::DeliverabilitySettings(request) => { + request.resolve_references(self, 1, false)? + } SetRequestMethod::Journal(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 4c86ae1..07dfd2e 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -70,6 +70,9 @@ pub enum MethodObject { MailRule, // inbuxa: accepted security to-do items SecurityAcceptance, + // inbuxa: the deliverability check + DeliverabilityReport, + DeliverabilitySettings, HeldMessage, // inbuxa: journaling Journal, @@ -119,6 +122,8 @@ impl MethodObject { | MethodObject::MailRule | MethodObject::SecurityAcceptance | MethodObject::HeldMessage + | MethodObject::DeliverabilityReport + | MethodObject::DeliverabilitySettings | MethodObject::Journal | MethodObject::JournalEntry | MethodObject::JournalExport @@ -323,6 +328,10 @@ impl MethodName { (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", (MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get", (MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set", + (MethodFunction::Get, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/get", + (MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set", + (MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get", + (MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set", (MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get", (MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set", (MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get", @@ -497,6 +506,10 @@ impl MethodName { "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), "inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get), "inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set), + "inbuxa:DeliverabilityReport/get" => (MethodObject::DeliverabilityReport, MethodFunction::Get), + "inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set), + "inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get), + "inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set), "inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get), "inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set), "inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get), @@ -580,6 +593,8 @@ impl Display for MethodObject { MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::MailRule => "inbuxa:MailRule", MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", + MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport", + MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings", MethodObject::Journal => "inbuxa:Journal", MethodObject::JournalEntry => "inbuxa:JournalEntry", MethodObject::JournalExport => "inbuxa:JournalExport", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index eb7be0c..bdfd46a 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -126,6 +126,8 @@ pub enum GetRequestMethod { LegalHold(Box>), MailRule(Box>), SecurityAcceptance(Box>), + DeliverabilityReport(Box>), + DeliverabilitySettings(Box>), Journal(Box>), JournalEntry(Box>), HeldMessage(Box>), @@ -172,6 +174,8 @@ pub enum SetRequestMethod<'x> { SecurityAcceptance( Box>, ), + DeliverabilityReport(Box>), + DeliverabilitySettings(Box>), Journal(Box>), JournalExport(Box>), JournalVerification(Box>), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index f4723a3..aa12645 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -686,6 +686,35 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: the deliverability check + (MethodFunction::Get, MethodObject::DeliverabilityReport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilityReport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::DeliverabilityReport) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilityReport(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Get, MethodObject::DeliverabilitySettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilitySettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::DeliverabilitySettings) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilitySettings(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: journaling (MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 28de17f..971f7c8 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -113,6 +113,8 @@ pub enum GetResponseMethod { LegalHold(GetResponse), MailRule(GetResponse), SecurityAcceptance(GetResponse), + DeliverabilityReport(GetResponse), + DeliverabilitySettings(GetResponse), Journal(GetResponse), JournalEntry(GetResponse), HeldMessage(GetResponse), @@ -159,6 +161,8 @@ pub enum SetResponseMethod { SecurityAcceptance( Box>, ), + DeliverabilityReport(Box>), + DeliverabilitySettings(Box>), Journal(Box>), JournalExport(Box>), JournalVerification(Box>), @@ -864,6 +868,31 @@ impl<'x> From> for } } +// inbuxa: the deliverability check +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::DeliverabilityReport(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::DeliverabilityReport(Box::new(value))) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::DeliverabilitySettings(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value))) + } +} + // inbuxa: accepted security to-do items impl<'x> From> for ResponseMethod<'x> diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 674c78e..3c4f2c4 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -123,6 +123,10 @@ impl JmapAuthorization for AccessToken { // inbuxa: accepted security items are read by whoever may // see the server's security settings GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet, + // inbuxa: deliverability spec; the lists are named on the + // page that shows the findings, so they read the same way + GetRequestMethod::DeliverabilityReport(_) + | GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet, // inbuxa: legacy protocols off. It takes listeners away and // puts them back, so it takes the listener's permissions GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet, @@ -335,6 +339,23 @@ impl JmapAuthorization for AccessToken { .details("You are not authorized to accept security items")) } } + // inbuxa: DL-15: a create runs the check; the handler + // refuses the rest + SetRequestMethod::DeliverabilityReport(s) => validate_set( + s, + self, + Permission::SysDeliverabilityCheck, + Permission::SysDeliverabilityCheck, + Permission::SysDeliverabilityCheck, + ), + // inbuxa: DL-6, which lists are asked + SetRequestMethod::DeliverabilitySettings(s) => validate_set( + s, + self, + Permission::SysDeliverabilityUpdate, + Permission::SysDeliverabilityUpdate, + Permission::SysDeliverabilityUpdate, + ), // inbuxa: LH-12, exporting held data SetRequestMethod::HoldExport(s) => validate_set( s, @@ -506,6 +527,8 @@ impl JmapAuthorization for AccessToken { | MethodObject::HoldExport | MethodObject::MailRule | MethodObject::SecurityAcceptance + | MethodObject::DeliverabilityReport + | MethodObject::DeliverabilitySettings | MethodObject::HeldMessage | MethodObject::Journal | MethodObject::JournalEntry diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index b847aed..351258c 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -293,6 +293,12 @@ impl RequestHandler for Server { SetResponseMethod::SecurityAcceptance(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::DeliverabilityReport(set_response) => { + set_response.update_created_ids(&mut response); + } + SetResponseMethod::DeliverabilitySettings(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::Journal(set_response) => { set_response.update_created_ids(&mut response); } @@ -539,6 +545,19 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: the deliverability check + GetRequestMethod::DeliverabilityReport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::deliverability::get_reports(self, access_token, *req) + .await? + .into() + } + GetRequestMethod::DeliverabilitySettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::deliverability::get_settings(self, access_token, *req) + .await? + .into() + } // inbuxa: journaling GetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -1060,6 +1079,35 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: DL-15, Check now; nothing it changes needs recording + SetRequestMethod::DeliverabilityReport(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::deliverability::set_reports(self, access_token, *req) + .await? + .into() + } + // inbuxa: DL-6; which lists are asked is in the audit log + SetRequestMethod::DeliverabilitySettings(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + None, + *req, + |req| { + Box::pin(crate::inbuxa::deliverability::set_settings( + self, + access_token, + req, + )) + }, + ) + .await? + .into() + } SetRequestMethod::Journal(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; let reason = req.arguments.reason.clone(); diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index d0ab35b..f52ff79 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -432,6 +432,8 @@ impl IntermediateChangesResponse { | MethodObject::HoldExport | MethodObject::MailRule | MethodObject::SecurityAcceptance + | MethodObject::DeliverabilityReport + | MethodObject::DeliverabilitySettings | MethodObject::Journal | MethodObject::JournalEntry | MethodObject::JournalExport diff --git a/crates/jmap/src/inbuxa/deliverability.rs b/crates/jmap/src/inbuxa/deliverability.rs new file mode 100644 index 0000000..23b2042 --- /dev/null +++ b/crates/jmap/src/inbuxa/deliverability.rs @@ -0,0 +1,352 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` +//! (deliverability spec). +//! +//! A report is one sending node's last check, written by that node. Reading +//! reports needs `sysDeliverabilityGet`; a tenant administrator gets only +//! their tenant's domains and nothing about the nodes (DL-20). Creating a +//! report asks every node to check itself now (DL-15): it needs +//! `sysDeliverabilityCheck`, returns at once with the node's last check +//! time, and the new report replaces the old one when it's done. The +//! settings say which built-in lists are left out (DL-6). + +use common::{Server, auth::AccessToken, ipc::BroadcastEvent}; +use inbuxa_features::deliverability::{ + self as model, Report, Settings, + lists::{self, Scope}, +}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::{ + inbuxa_deliverability_report::{ + DeliverabilityReport, DeliverabilityReportProperty as R, DeliverabilityReportValue, + }, + inbuxa_deliverability_settings::{ + DeliverabilitySettings, DeliverabilitySettingsProperty as S, + DeliverabilitySettingsValue, + }, + }, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Element, Key, Map, Property, Value}; +use std::borrow::Cow; +use types::id::Id; + +const REPORT: &[R] = &[ + R::Id, + R::NodeId, + R::Hostname, + R::CheckedAt, + R::Addresses, + R::Domains, + R::Certificates, +]; + +const SETTINGS: &[S] = &[S::Id, S::DisabledLists, S::Lists]; + +fn server_level(access_token: &AccessToken, what: &'static str) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden.into_err().details(what)) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> Value<'static, P, E> { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn date(seconds: u64) -> Value<'static, R, DeliverabilityReportValue> { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, DeliverabilityReportValue> { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + R::Id => Value::Element(DeliverabilityReportValue::Id(Id::from(report.node_id))), + R::NodeId => Value::Number(report.node_id.into()), + R::Hostname => Value::Str(report.hostname.clone().into()), + R::CheckedAt => date(report.checked_at), + R::Addresses => { + json_to_value(serde_json::to_value(&report.addresses).unwrap_or_default()) + } + R::Domains => json_to_value(serde_json::to_value(&report.domains).unwrap_or_default()), + R::Certificates => { + json_to_value(serde_json::to_value(&report.certificates).unwrap_or_default()) + } + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:DeliverabilityReport/get`: every sending node's last report. +pub async fn get_reports( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(REPORT); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let mut reports = model::reports(server.store()).await?; + // DL-20 + if let Some(tenant_id) = access_token.tenant_id() { + reports = reports.iter().map(|r| r.for_tenant(tenant_id)).collect(); + } + match ids { + None => { + response.list = reports + .iter() + .map(|r| report_value(r, &properties)) + .collect(); + } + Some(ids) => { + for id in ids { + match reports.iter().find(|r| r.node_id == id.id()) { + Some(report) => response.list.push(report_value(report, &properties)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:DeliverabilityReport/set`: a create asks every node to check +/// itself now (DL-15). Reports are the server's: nothing else is allowed. +pub async fn set_reports( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, DeliverabilityReport>, +) -> trc::Result> { + server_level(access_token, "The deliverability check is the server's.")?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let node_id = server.core.network.node_id; + + let mut asked = false; + for (client_id, _) in request.unwrap_create() { + if !asked { + asked = true; + services::inbuxa_deliverability::CHECK_NOW.notify_one(); + server + .cluster_broadcast(BroadcastEvent::DeliverabilityCheck) + .await; + } + // The node's last check, so the console knows when the new one lands + let last = model::report(server.store(), node_id).await?; + let mut out = Map::with_capacity(2); + out.insert_unchecked( + Key::Property(R::Id), + Value::Element(DeliverabilityReportValue::Id(Id::from(node_id))), + ); + out.insert_unchecked( + Key::Property(R::CheckedAt), + last.map(|r| date(r.checked_at)).unwrap_or(Value::Null), + ); + response.created.insert(client_id, Value::Object(out)); + } + for (id, _) in request.unwrap_update().into_valid() { + response.not_updated.append( + id, + SetError::forbidden().with_description("Reports are written by the check."), + ); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append( + id, + SetError::forbidden().with_description("Reports are written by the check."), + ); + } + Ok(response) +} + +fn lists_value() -> Value<'static, S, DeliverabilitySettingsValue> { + Value::Array( + lists::LISTS + .iter() + .map(|list| { + json_to_value(serde_json::json!({ + "name": list.name, + "zone": list.zone, + "scope": match list.scope { + Scope::Ip => "ip", + Scope::Domain => "domain", + }, + "lookup": list.lookup, + "note": list.note, + })) + }) + .collect(), + ) +} + +fn settings_value( + settings: &Settings, + properties: &[S], +) -> Value<'static, S, DeliverabilitySettingsValue> { + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + S::Id => Value::Element(DeliverabilitySettingsValue::Id(Id::singleton())), + S::DisabledLists => Value::Array( + settings + .disabled_lists + .iter() + .map(|name| Value::Str(name.clone().into())) + .collect(), + ), + S::Lists => lists_value(), + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// `inbuxa:DeliverabilitySettings/get`: which lists are left out, and the lists. +pub async fn get_settings( + server: &Server, + _access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + let properties = request.unwrap_properties(SETTINGS); + let (ids, not_found) = request.unwrap_ids(1)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let settings = model::settings(server.store()).await?; + match ids { + None => response.list.push(settings_value(&settings, &properties)), + Some(ids) => { + for id in ids { + if id.is_singleton() { + response.list.push(settings_value(&settings, &properties)); + } else { + response.push_not_found(id); + } + } + } + } + Ok(response) +} + +/// `inbuxa:DeliverabilitySettings/set`: updates the singleton. +pub async fn set_settings( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, DeliverabilitySettings>, +) -> trc::Result> { + server_level(access_token, "The blocklists checked are the server's.")?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + for (client_id, _) in request.unwrap_create() { + response + .not_created + .append(client_id, SetError::singleton()); + } + for id in request.unwrap_destroy().into_valid() { + response.not_destroyed.append(id, SetError::singleton()); + } + let data = server.store(); + for (id, value) in request.unwrap_update().into_valid() { + if !id.is_singleton() { + response.not_updated.append(id, SetError::not_found()); + continue; + } + let mut settings = model::settings(data).await?; + let mut error = None; + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(S::DisabledLists) => { + let names = value.as_array().map(|items| { + items + .iter() + .map(|item| item.as_str().map(|s| s.to_string())) + .collect::>>() + }); + match names { + Some(Some(names)) => settings.disabled_lists = names, + _ => { + error = Some( + SetError::invalid_properties() + .with_property(S::DisabledLists) + .with_description("A list of list names."), + ); + break; + } + } + } + Key::Property(property) => { + error = Some( + SetError::invalid_properties() + .with_property(property.clone()) + .with_description("The server sets this."), + ); + break; + } + _ => { + error = Some(SetError::invalid_properties().with_property(key.into_owned())); + break; + } + } + } + if error.is_none() + && let Err(why) = settings.validate() + { + error = Some( + SetError::invalid_properties() + .with_property(S::DisabledLists) + .with_description(why), + ); + } + match error { + Some(error) => response.not_updated.append(id, error), + None => { + model::put_settings(data, &settings).await?; + response.updated.append(id, None); + } + } + } + Ok(response) +} diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 2e420ef..7c4f2cc 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -12,6 +12,7 @@ pub mod account_lock; pub mod legal_hold; pub mod mail_rule; pub mod security_acceptance; +pub mod deliverability; // inbuxa: the deliverability check pub mod journal; pub mod journal_entry; pub mod held_message; diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index e3d8bca..ae05237 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1762,6 +1762,10 @@ pub enum Permission { SysJournalExport = 683, // inbuxa: the security to-do list, accepting an item SysSecurityAccept = 684, + // inbuxa: the deliverability check + SysDeliverabilityGet = 685, + SysDeliverabilityUpdate = 686, + SysDeliverabilityCheck = 687, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index a130de3..87edc65 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7102,6 +7102,9 @@ impl EnumImpl for Permission { b"sysJournalSearch" => Permission::SysJournalSearch, b"sysJournalExport" => Permission::SysJournalExport, b"sysSecurityAccept" => Permission::SysSecurityAccept, + b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet, + b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate, + b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7803,6 +7806,9 @@ impl EnumImpl for Permission { Permission::SysJournalSearch => "sysJournalSearch", Permission::SysJournalExport => "sysJournalExport", Permission::SysSecurityAccept => "sysSecurityAccept", + Permission::SysDeliverabilityGet => "sysDeliverabilityGet", + Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate", + Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8497,6 +8503,9 @@ impl EnumImpl for Permission { 682 => Some(Permission::SysJournalSearch), 683 => Some(Permission::SysJournalExport), 684 => Some(Permission::SysSecurityAccept), + 685 => Some(Permission::SysDeliverabilityGet), + 686 => Some(Permission::SysDeliverabilityUpdate), + 687 => Some(Permission::SysDeliverabilityCheck), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -8941,7 +8950,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 685; + const COUNT: usize = 688; } impl serde::Serialize for Permission { diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index d019a55..b434f51 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -34,6 +34,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "h base64 = "0.23" dns-update = { version = "0.5" } psl = "2" +# inbuxa: the deliverability check +mail-auth = { version = "0.13" } +futures = "0.3" [dev-dependencies] diff --git a/crates/services/src/broadcast/mod.rs b/crates/services/src/broadcast/mod.rs index 0c859f0..a006149 100644 --- a/crates/services/src/broadcast/mod.rs +++ b/crates/services/src/broadcast/mod.rs @@ -146,6 +146,10 @@ impl BroadcastBatch> { serialized.push(13u8); let _ = serialized.write_leb128(*account_id); } + // inbuxa: DL-15 + BroadcastEvent::DeliverabilityCheck => { + serialized.push(14u8); + } } } serialized @@ -284,6 +288,8 @@ where let account_id = self.messages.next_leb128().ok_or(())?; Ok(Some(BroadcastEvent::EndSessions(account_id))) } + // inbuxa: DL-15 + 14 => Ok(Some(BroadcastEvent::DeliverabilityCheck)), _ => Err(()), } } else { diff --git a/crates/services/src/broadcast/subscriber.rs b/crates/services/src/broadcast/subscriber.rs index 2ca21f9..20c901f 100644 --- a/crates/services/src/broadcast/subscriber.rs +++ b/crates/services/src/broadcast/subscriber.rs @@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc, mut shutdown_rx: watch::Rec .send(PushEvent::Revoke { account_id }) .await; } + // inbuxa: DL-15: this node checks + // itself too + BroadcastEvent::DeliverabilityCheck => { + crate::inbuxa_deliverability::CHECK_NOW + .notify_one(); + } BroadcastEvent::QueueRefresh => { if inner.shared_core.load().network.roles.outbound_mta { let _ = inner @@ -278,6 +284,7 @@ fn log_event(event: &BroadcastEvent) -> trc::Value { BroadcastEvent::EndSessions(account_id) => { trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()]) } + BroadcastEvent::DeliverabilityCheck => "DeliverabilityCheck".into(), BroadcastEvent::RegistryChange(change) => match change { RegistryChange::Insert(id) => trc::Value::Array(vec![ "RegistryInsert".into(), diff --git a/crates/services/src/inbuxa_deliverability.rs b/crates/services/src/inbuxa_deliverability.rs new file mode 100644 index 0000000..937c8d4 --- /dev/null +++ b/crates/services/src/inbuxa_deliverability.rs @@ -0,0 +1,566 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The deliverability check (deliverability spec, DL-1 to DL-16): every node +//! that sends mail asks what the rest of the internet sees of it, once a day +//! and when an administrator asks (**Check now**), and keeps one report. +//! +//! Each node checks itself, because only it knows which address it sends +//! from: a cluster's nodes can each leave from their own (DL-1, DL-2). The +//! report holds facts; the console grades them. + +use common::{ + BuildServer, Inner, Server, config::smtp::auth::Dkim1Signer, expr::functions::EmptyResolver, +}; +use futures::future::join_all; +use inbuxa_features::deliverability::{ + self as model, Address, AddressSource, Certificate, DkimKey, DkimState, Dmarc, DomainReport, + Listing, ListingState, MtaSts, Report, Settings, SpfResult, + lists::{self, Answer, BlockList, Subject}, +}; +use mail_auth::{ + AuthenticatedMessage, DkimResult, DnsError, Error, SpfResult as Spf, + common::headers::HeaderWriter, + dmarc::{self, Alignment}, + mta_sts::{MtaSts as MtaStsRecord, TlsRpt}, + spf::verify::SpfParameters, +}; +use registry::schema::{prelude::ObjectType, structs::Domain}; +use smtp::outbound::mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy}; +use std::{ + collections::BTreeSet, + future::Future, + net::IpAddr, + sync::{Arc, LazyLock}, + time::Duration, +}; +use store::{registry::RegistryQuery, write::now}; +use tokio::sync::Notify; +use types::id::Id; + +/// DL-16: no single lookup holds a run up for longer than this. +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); +/// DL-16: lookups in flight at once. +const PARALLEL: usize = 8; +const MTA_STS_TIMEOUT: Duration = Duration::from_secs(10); +const DAY: u64 = 86_400; +/// After a start, wait this long before a run that's overdue. +const SETTLE: Duration = Duration::from_secs(120); + +/// DL-15: wakes this node's check, from **Check now** here or on another node. +pub static CHECK_NOW: LazyLock = LazyLock::new(Notify::new); + +pub fn spawn_deliverability(inner: Arc) { + tokio::spawn(async move { + let mut first = true; + loop { + let server = inner.build_server(); + let wait = match due_in(&server).await { + Ok(wait) => wait, + Err(err) => { + trc::error!(err.details("Failed to read the deliverability report")); + Duration::from_secs(3600) + } + }; + let wait = if first { wait.max(SETTLE) } else { wait }; + first = false; + let asked = tokio::select! { + _ = tokio::time::sleep(wait) => false, + _ = CHECK_NOW.notified() => true, + }; + let server = inner.build_server(); + if !server.core.network.roles.outbound_mta { + continue; + } + // DL-15: asked again within ten minutes, the last report stands + if asked + && let Ok(Some(last)) = + model::report(server.store(), server.core.network.node_id).await + && now().saturating_sub(last.checked_at) < model::MIN_INTERVAL_SECS + { + continue; + } + if let Err(err) = run(&server).await { + trc::error!(err.details("Failed to run the deliverability check")); + } + } + }); +} + +/// DL-14: once a day, at a minute in the first hour of the day (UTC) that's +/// the node's own, so nodes and servers don't all ask the lists at once. +async fn due_in(server: &Server) -> trc::Result { + let node_id = server.core.network.node_id; + let last = model::report(server.store(), node_id) + .await? + .map(|r| r.checked_at) + .unwrap_or(0); + let slot = slot_for(&server.core.network.server_name, node_id); + let next = next_slot(last, slot); + Ok(Duration::from_secs(next.saturating_sub(now()))) +} + +fn slot_for(hostname: &str, node_id: u64) -> u64 { + let hash = hostname + .bytes() + .fold(node_id.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, b| { + h.rotate_left(5) ^ b as u64 + }); + hash % 3600 +} + +/// The first daily slot after `last`; 0 (never ran) is due now. +fn next_slot(last: u64, slot: u64) -> u64 { + if last == 0 { + return 0; + } + let mut next = last - last % DAY + slot; + if next <= last { + next += DAY; + } + next +} + +/// Runs the check on this node and keeps the report. +pub async fn run(server: &Server) -> trc::Result { + let settings = model::settings(server.store()).await?; + let addresses = addresses(server, &settings).await; + let mut domains = Vec::new(); + let ids = server + .registry() + .query::>(RegistryQuery::new(ObjectType::Domain)) + .await?; + for id in ids { + if let Some(domain) = server.registry().object::(id).await? { + domains.push(check_domain(server, &settings, &domain, &addresses).await?); + } + } + let certificates = certificates(server, &addresses).await; + let report = Report { + node_id: server.core.network.node_id, + hostname: server.core.network.server_name.clone(), + checked_at: now(), + addresses, + domains, + certificates, + }; + model::put_report(server.store(), &report).await?; + Ok(report) +} + +// --- DL-1, DL-2: the addresses --------------------------------------------- + +async fn addresses(server: &Server, settings: &Settings) -> Vec

{ + let queue = &server.core.smtp.queue; + // The strategy the scheduler picks for a message it knows nothing about: + // what an expression on the node's own name, as a cluster uses, gives. + let strategy = server + .eval_if::(&queue.connection, &EmptyResolver, 0) + .await + .unwrap_or_else(|| "default".to_string()); + let connection = server.get_connection_or_default(&strategy, 0); + let ehlo = connection + .ehlo_hostname + .clone() + .unwrap_or_else(|| server.core.network.server_name.clone()); + + let mut found: Vec<(IpAddr, AddressSource, String)> = connection + .source_ipv4 + .iter() + .chain(connection.source_ipv6.iter()) + .map(|source| { + ( + source.ip, + AddressSource::Configured, + source.host.clone().unwrap_or_else(|| ehlo.clone()), + ) + }) + .collect(); + if found.is_empty() { + for ip in resolve_name(server, &ehlo).await { + found.push((ip, AddressSource::Ehlo, ehlo.clone())); + } + } + + let mut out = Vec::with_capacity(found.len()); + for (ip, source, ehlo) in found { + let mut address = Address { + ip: ip.to_string(), + source, + strategy: strategy.clone(), + ehlo: ehlo.clone(), + ..Default::default() + }; + reverse_dns(server, ip, &ehlo, &mut address).await; + address.listings = listings(server, settings, Subject::Ip(ip)).await; + out.push(address); + } + out +} + +/// The IPv4 and IPv6 addresses `name` resolves to; none when it doesn't. +async fn resolve_name(server: &Server, name: &str) -> Vec { + let dns = &server.core.smtp.resolvers.dns; + let cache = &server.inner.cache; + let fqdn = fqdn(name); + let mut ips = Vec::new(); + if let Some(Ok(v4)) = timed(dns.ipv4_lookup(fqdn.as_str(), Some(&cache.dns_ipv4))).await { + ips.extend(v4.rrset.iter().copied().map(IpAddr::V4)); + } + if let Some(Ok(v6)) = timed(dns.ipv6_lookup(fqdn.as_str(), Some(&cache.dns_ipv6))).await { + ips.extend(v6.rrset.iter().copied().map(IpAddr::V6)); + } + ips +} + +/// DL-5: the PTR names, whether one resolves back, and whether that one is +/// the EHLO name. +async fn reverse_dns(server: &Server, ip: IpAddr, ehlo: &str, address: &mut Address) { + let dns = &server.core.smtp.resolvers.dns; + match timed(dns.ptr_lookup(ip, Some(&server.inner.cache.dns_ptr))).await { + Some(Ok(names)) => { + address.ptr = names.rrset.iter().map(|n| bare(n)).collect(); + } + Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {} + Some(Err(err)) => address.ptr_error = Some(err.to_string()), + None => address.ptr_error = Some("No answer in 5 seconds".into()), + } + for name in address.ptr.clone() { + if resolve_name(server, &name).await.contains(&ip) { + address.forward_confirmed = true; + if name.eq_ignore_ascii_case(&bare(ehlo)) { + address.ehlo_matches = true; + } + } + } +} + +// --- DL-4, DL-6, DL-12: blocklists ---------------------------------------- + +async fn listings(server: &Server, settings: &Settings, subject: Subject<'_>) -> Vec { + let mut out = Vec::new(); + let mut asked = Vec::new(); + for list in lists::LISTS { + let Some(name) = list.query(&subject) else { + continue; + }; + if settings.is_off(list.name) { + out.push(Listing { + list: list.name.into(), + state: ListingState::Off, + ..Default::default() + }); + } else { + asked.push((list, name)); + } + } + for chunk in asked.chunks(PARALLEL) { + out.extend(join_all(chunk.iter().map(|(list, name)| ask(server, list, name))).await); + } + // In the lists' own order, whether asked or off + out.sort_by_key(|l| lists::LISTS.iter().position(|list| list.name == l.list)); + out +} + +async fn ask(server: &Server, list: &BlockList, name: &str) -> Listing { + let dns = &server.core.smtp.resolvers.dns; + let mut listing = Listing { + list: list.name.into(), + ..Default::default() + }; + match timed(dns.ipv4_lookup(name, Some(&server.inner.cache.dns_ipv4))).await { + Some(Ok(answer)) => { + let Some(code) = answer.rrset.first().copied() else { + return listing; + }; + listing.code = Some(code.to_string()); + match list.read(code) { + Answer::Listed(meaning) => { + listing.state = ListingState::Listed; + listing.meaning = Some(meaning.into()); + } + Answer::Refused(meaning) => { + listing.state = ListingState::Refused; + listing.meaning = Some(meaning.into()); + } + Answer::Unknown => { + listing.state = ListingState::Refused; + listing.meaning = Some("An answer this list doesn't define".into()); + } + } + } + // Not on the list + Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {} + // A list that refuses the resolver often answers REFUSED or SERVFAIL + Some(Err(err)) => { + listing.state = ListingState::Error; + listing.meaning = Some(err.to_string()); + } + None => { + listing.state = ListingState::Error; + listing.meaning = Some("No answer in 5 seconds".into()); + } + } + listing +} + +// --- DL-7 to DL-12: per domain -------------------------------------------- + +async fn check_domain( + server: &Server, + settings: &Settings, + domain: &Domain, + addresses: &[Address], +) -> trc::Result { + let name = domain.name.to_lowercase(); + let mut report = DomainReport { + domain: name.clone(), + tenant_id: domain.member_tenant_id.map(|id| id.document_id()), + ..Default::default() + }; + let dns = &server.core.smtp.resolvers.dns; + let cache = &server.inner.cache; + + // DL-7: SPF for every address the node sends from + for address in addresses { + let Ok(ip) = address.ip.parse::() else { + continue; + }; + let sender = format!("postmaster@{name}"); + let output = dns + .check_host(cache.build_auth_parameters(SpfParameters::new( + ip, + &name, + &address.ehlo, + &server.core.network.server_name, + &sender, + ))) + .await; + report.spf.push(SpfResult { + ip: address.ip.clone(), + result: spf_name(output.result()).into(), + }); + } + + // DL-8: each key the domain signs with is the one published + report.dkim = dkim_keys(server, &name).await?; + + // DL-9: what DMARC asks of alignment; the console works it out + if let Some(Ok(record)) = + timed(dns.txt_lookup::(format!("_dmarc.{name}."), Some(&cache.dns_txt))).await + { + report.dmarc = Some(Dmarc { + policy: match record.p { + dmarc::Policy::None | dmarc::Policy::Unspecified => "none", + dmarc::Policy::Quarantine => "quarantine", + dmarc::Policy::Reject => "reject", + } + .into(), + adkim: alignment(&record.adkim).into(), + aspf: alignment(&record.aspf).into(), + }); + } + + // DL-10 + report.mta_sts = mta_sts(server, &name).await; + + // DL-11 + report.tls_rpt = matches!( + timed(dns.txt_lookup::(format!("_smtp._tls.{name}."), Some(&cache.dns_txt))).await, + Some(Ok(_)) + ); + + // DL-12 + report.listings = listings(server, settings, Subject::Domain(&name)).await; + + Ok(report) +} + +/// Signs a message that's never sent with each of the domain's DKIM keys, +/// and verifies it as a receiver would: a key that's missing from DNS, or +/// published but different, fails here before it fails anyone's mail. +async fn dkim_keys(server: &Server, domain: &str) -> trc::Result> { + let Some(signers) = server.dkim_signers(domain).await? else { + return Ok(Vec::new()); + }; + let message = format!( + "From: deliverability-check@{domain}\r\n\ + To: deliverability-check@{domain}\r\n\ + Subject: Deliverability check\r\n\ + Date: Mon, 5 Oct 2026 00:00:00 +0000\r\n\ + Message-ID: \r\n\ + \r\n\ + This message is signed to check the DKIM keys in DNS. It is never sent.\r\n" + ); + let mut keys = Vec::new(); + for signer in &signers.dkim1 { + let signature = match signer { + Dkim1Signer::RsaSha256(signer) => signer.sign(message.as_bytes()), + Dkim1Signer::Ed25519Sha256(signer) => signer.sign(message.as_bytes()), + }; + let Ok(signature) = signature else { + continue; + }; + let selector = signature.s.clone(); + let mut signed = Vec::with_capacity(message.len() + 512); + signature.write_header(&mut signed); + signed.extend_from_slice(message.as_bytes()); + let state = match AuthenticatedMessage::parse(&signed) { + Some(parsed) => { + let outputs = server + .core + .smtp + .resolvers + .dns + .verify_dkim(server.inner.cache.build_auth_parameters(&parsed)) + .await; + outputs + .first() + .map(|output| dkim_state(output.result())) + .unwrap_or(DkimState::Error) + } + None => DkimState::Error, + }; + keys.push(DkimKey { selector, state }); + } + Ok(keys) +} + +fn dkim_state(result: &DkimResult) -> DkimState { + match result { + DkimResult::Pass => DkimState::Matches, + DkimResult::PermError(Error::Dns(DnsError::RecordNotFound(_))) + | DkimResult::TempError(Error::Dns(DnsError::RecordNotFound(_))) => DkimState::Missing, + DkimResult::TempError(_) => DkimState::Error, + _ => DkimState::Different, + } +} + +async fn mta_sts(server: &Server, domain: &str) -> MtaSts { + let dns = &server.core.smtp.resolvers.dns; + let cache = &server.inner.cache; + let mut out = MtaSts::default(); + let Some(Ok(record)) = + timed(dns.txt_lookup::(format!("_mta-sts.{domain}."), Some(&cache.dns_txt))) + .await + else { + return out; + }; + out.record_id = Some(record.id.clone()); + match server.lookup_mta_sts_policy(domain, MTA_STS_TIMEOUT).await { + Ok(policy) => { + out.fetched = true; + out.mode = Some( + match policy.mode { + common::config::smtp::resolver::Mode::Enforce => "enforce", + common::config::smtp::resolver::Mode::Testing => "testing", + common::config::smtp::resolver::Mode::None => "none", + } + .into(), + ); + out.max_age = Some(policy.max_age); + if let Some(Ok(mxs)) = timed(dns.mx_lookup(domain, Some(&cache.dns_mx))).await { + for mx in mxs.rrset.iter() { + for exchange in mx.exchanges.iter() { + let host = bare(exchange); + if !policy.verify(&host) && !out.mx_not_covered.contains(&host) { + out.mx_not_covered.push(host); + } + } + } + } + } + Err(err) => out.error = Some(err.to_string()), + } + out +} + +// --- DL-13: certificates --------------------------------------------------- + +/// The EHLO names, and the server's MX names that point at this node, each +/// with whether the node holds a certificate for it. +async fn certificates(server: &Server, addresses: &[Address]) -> Vec { + let mine: Vec = addresses.iter().filter_map(|a| a.ip.parse().ok()).collect(); + let mut names: BTreeSet = addresses.iter().map(|a| bare(&a.ehlo)).collect(); + let default_host = server.core.network.server_name.as_str(); + for mx in &server.core.network.info.mxs { + let name = bare(mx.hostname.as_deref().unwrap_or(default_host)); + if !names.contains(&name) + && resolve_name(server, &name) + .await + .iter() + .any(|ip| mine.contains(ip)) + { + names.insert(name); + } + } + names + .into_iter() + .map(|name| Certificate { + covered: server.resolve_certificate(&name).is_some(), + name, + }) + .collect() +} + +// --- Helpers --------------------------------------------------------------- + +async fn timed(lookup: impl Future) -> Option { + tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await.ok() +} + +fn fqdn(name: &str) -> String { + format!("{}.", name.trim_end_matches('.')) +} + +fn bare(name: &str) -> String { + name.trim_end_matches('.').to_lowercase() +} + +fn spf_name(result: Spf) -> &'static str { + match result { + Spf::Pass => "pass", + Spf::Fail => "fail", + Spf::SoftFail => "softFail", + Spf::Neutral => "neutral", + Spf::TempError => "tempError", + Spf::PermError => "permError", + Spf::None => "none", + } +} + +fn alignment(alignment: &Alignment) -> &'static str { + match alignment { + Alignment::Relaxed => "relaxed", + Alignment::Strict => "strict", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_daily_slot_follows_the_last_run() { + let day = 20_000 * DAY; + // Never ran: due now + assert_eq!(next_slot(0, 600), 0); + // Ran at 14:00: next is tomorrow's slot + assert_eq!(next_slot(day + 14 * 3600, 600), day + DAY + 600); + // Ran just before today's slot: today's slot + assert_eq!(next_slot(day + 300, 600), day + 600); + // Ran at the slot: tomorrow's + assert_eq!(next_slot(day + 600, 600), day + DAY + 600); + } + + #[test] + fn slots_fall_in_the_first_hour_and_differ_by_node() { + let a = slot_for("mx2.example.org", 2); + let b = slot_for("mx3.example.org", 3); + assert!(a < 3600 && b < 3600); + assert_ne!(a, b); + } +} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 37cc38b..6ff6f70 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -26,6 +26,7 @@ pub mod broadcast; // inbuxa: AL-5, delegations end at their date pub mod inbuxa_lock_expiry; pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1 +pub mod inbuxa_deliverability; // inbuxa: the deliverability check pub mod state_manager; pub mod task_manager; @@ -74,6 +75,9 @@ impl SpawnServices for IpcReceivers { // inbuxa: personal-data catalog, D1: old log files go, per node inbuxa_log_retention::spawn_log_retention(inner.clone()); + // inbuxa: deliverability spec, DL-14: each node checks itself daily + inbuxa_deliverability::spawn_deliverability(inner.clone()); + // Spawn task scheduler spawn_task_scheduler(inner); } diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 0dfdc71..069ac11 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -364,6 +364,8 @@ is written. Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts `specs/security-score.md`). The console runs its checks; the server's part is `inbuxa:SecurityAcceptance`, the accepted items (`crates/jmap/src/inbuxa/security_acceptance.rs`), and the `sysSecurityAccept` permission. +Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions. + ## 5. The web front ends **Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs, diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 1d96c99..cc08ea7 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -166,6 +166,18 @@ reason = ["content"] file = "inbuxa_journal_entry.rs" default = "none" +# The deliverability check (deliverability spec): facts about the server's own +# addresses, names and domains. The blocklists it asks see those addresses and +# domains, as they would whenever anyone checks mail from the server; nothing +# about people is sent or kept. +[object."inbuxa:DeliverabilityReport"] +file = "inbuxa_deliverability_report.rs" +default = "none" + +[object."inbuxa:DeliverabilitySettings"] +file = "inbuxa_deliverability_settings.rs" +default = "none" + [object."inbuxa:LegalHold"] file = "inbuxa_legal_hold.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 2fc1ca2..16e6335 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 4a862a7..46cd6f7 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo \ No newline at end of file +EFFcJvTPjNnvBGmMdfIkunMwqbh6NS_rsmhmhF2OK3U \ No newline at end of file diff --git a/tests/src/system/deliverability.rs b/tests/src/system/deliverability.rs new file mode 100644 index 0000000..5f40a32 --- /dev/null +++ b/tests/src/system/deliverability.rs @@ -0,0 +1,389 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The deliverability check (deliverability spec): what a node finds about +//! its own addresses and the domains it sends for, which lists it leaves out, +//! what a tenant administrator sees of it, and who may ask for a check. + +use crate::utils::{ + account::Account, + dns::DnsCache, + server::{TestServer, TestServerBuilder}, +}; +use inbuxa_features::deliverability::{AddressSource, DkimState, ListingState}; +use mail_auth::{ + DnssecStatus, MX, common::parse::TxtRecordParser, dmarc::Dmarc, mta_sts::MtaSts, + mta_sts::TlsRpt, spf::Spf, +}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; +use smtp::outbound::mta_sts::lookup::STS_TEST_POLICY; +use std::{ + net::IpAddr, + time::{Duration, Instant}, +}; +use types::id::Id; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +async fn domain(admin: &Account, name: &str, tenant: Option) -> Id { + admin + .registry_create_object(Domain { + name: name.to_string(), + is_enabled: true, + member_tenant_id: tenant, + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await +} + +pub async fn test(test: &mut TestServer) { + println!("Running deliverability tests..."); + let admin = test.account("admin@example.com"); + let server = test.server.clone(); + let soon = Instant::now() + Duration::from_secs(600); + + // --- The settings: the lists, and leaving one out (DL-6) ------------- + let (_, response) = call( + &admin, + "inbuxa:DeliverabilitySettings/get", + json!({"ids": null}), + ) + .await; + let settings = &response["list"][0]; + assert_eq!(settings["disabledLists"], json!([]), "{response}"); + let lists = settings["lists"].as_array().unwrap(); + assert_eq!(lists.len(), 9, "{response}"); + let barracuda = lists.iter().find(|l| l["name"] == "Barracuda").unwrap(); + assert!( + barracuda["note"].as_str().unwrap().contains("registered"), + "{barracuda}" + ); + + let (_, response) = call( + &admin, + "inbuxa:DeliverabilitySettings/set", + json!({"update": {"singleton": {"disabledLists": ["My own list"]}}}), + ) + .await; + assert!( + response["notUpdated"]["singleton"].is_object(), + "an unknown list was taken: {response}" + ); + let (_, response) = call( + &admin, + "inbuxa:DeliverabilitySettings/set", + json!({"update": {"singleton": {"disabledLists": ["Barracuda"]}}}), + ) + .await; + assert!( + response["updated"]["singleton"].is_null() && response["updated"].is_object(), + "{response}" + ); + + // --- What the world says about this node ------------------------------ + let hostname = server.core.network.server_name.to_lowercase(); + let ip: IpAddr = "192.0.2.10".parse().unwrap(); + server.ipv4_add(hostname.as_str(), vec!["192.0.2.10".parse().unwrap()], soon); + server.ptr_add(ip, vec![format!("{hostname}.")], soon); + // Listed on ZEN, refused by SpamCop, an undefined answer from Mailspike + server.ipv4_add( + "10.2.0.192.zen.spamhaus.org", + vec!["127.0.0.2".parse().unwrap()], + soon, + ); + server.ipv4_add( + "10.2.0.192.bl.spamcop.net", + vec!["127.255.255.254".parse().unwrap()], + soon, + ); + server.ipv4_add( + "10.2.0.192.bl.mailspike.net", + vec!["127.0.0.200".parse().unwrap()], + soon, + ); + + // A tenant's domain that's in order, and the server's own that isn't + let tenant = admin + .registry_create_object(Tenant { + name: "Deliverability tenant".to_string(), + ..Default::default() + }) + .await; + domain(&admin, "good.example.org", Some(tenant)).await; + domain(&admin, "bad.example.org", None).await; + server.txt_add( + "good.example.org", + Spf::parse(b"v=spf1 ip4:192.0.2.10 -all").unwrap(), + soon, + ); + server.txt_add( + "bad.example.org", + Spf::parse(b"v=spf1 ip4:198.51.100.1 -all").unwrap(), + soon, + ); + server.txt_add( + "_dmarc.good.example.org", + Dmarc::parse(b"v=DMARC1; p=reject; adkim=s").unwrap(), + soon, + ); + server.txt_add( + "_smtp._tls.good.example.org", + TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:tls@good.example.org").unwrap(), + soon, + ); + server.txt_add( + "_mta-sts.good.example.org", + MtaSts::parse(b"v=STSv1; id=20261005").unwrap(), + soon, + ); + { + let mut policy = STS_TEST_POLICY.lock(); + policy.clear(); + policy.extend_from_slice( + b"version: STSv1\nmode: enforce\nmx: mx1.good.example.org\nmax_age: 86400\n", + ); + } + server.mx_add( + "good.example.org", + vec![ + MX { + exchanges: vec!["mx1.good.example.org.".into()].into_boxed_slice(), + preference: 10, + }, + MX { + exchanges: vec!["mx2.good.example.org.".into()].into_boxed_slice(), + preference: 20, + }, + ], + DnssecStatus::Insecure, + soon, + ); + server.ipv4_add( + "bad.example.org.dbl.spamhaus.org", + vec!["127.0.1.2".parse().unwrap()], + soon, + ); + + let report = services::inbuxa_deliverability::run(&server) + .await + .expect("the check runs"); + + // DL-2: no addresses set, so what the EHLO name resolves to + assert_eq!(report.addresses.len(), 1, "{report:#?}"); + let address = &report.addresses[0]; + assert_eq!(address.ip, "192.0.2.10"); + assert_eq!(address.source, AddressSource::Ehlo); + // DL-5 + assert_eq!(address.ptr, [hostname.clone()]); + assert!( + address.forward_confirmed && address.ehlo_matches, + "{address:#?}" + ); + // DL-4, DL-6 + let state = |list: &str| { + address + .listings + .iter() + .find(|l| l.list == list) + .unwrap_or_else(|| panic!("{list} not asked: {address:#?}")) + .state + }; + assert_eq!(state("Spamhaus ZEN"), ListingState::Listed); + assert_eq!(state("SpamCop"), ListingState::Refused); + assert_eq!(state("Mailspike"), ListingState::Refused); + assert_eq!(state("Barracuda"), ListingState::Off); + assert_eq!(state("PSBL"), ListingState::Clean); + assert!( + address.listings.iter().all(|l| l.list != "Spamhaus DBL"), + "a domain list was asked about an address" + ); + + let good = report + .domains + .iter() + .find(|d| d.domain == "good.example.org") + .unwrap(); + let bad = report + .domains + .iter() + .find(|d| d.domain == "bad.example.org") + .unwrap(); + // DL-7 + assert_eq!(good.spf[0].result, "pass", "{good:#?}"); + assert_eq!(bad.spf[0].result, "fail", "{bad:#?}"); + // DL-8: no keys of its own, so nothing to compare + assert!(good.dkim.iter().all(|k| k.state != DkimState::Different)); + // DL-9 + let dmarc = good.dmarc.as_ref().expect("the DMARC record"); + assert_eq!( + (dmarc.policy.as_str(), dmarc.adkim.as_str()), + ("reject", "strict") + ); + assert!(bad.dmarc.is_none()); + // DL-10: the policy is fetched, and one MX isn't in it + assert_eq!(good.mta_sts.record_id.as_deref(), Some("20261005")); + assert!(good.mta_sts.fetched, "{:#?}", good.mta_sts); + assert_eq!(good.mta_sts.mode.as_deref(), Some("enforce")); + assert_eq!(good.mta_sts.mx_not_covered, ["mx2.good.example.org"]); + assert!(bad.mta_sts.record_id.is_none()); + // DL-11 + assert!(good.tls_rpt && !bad.tls_rpt); + // DL-12 + let dbl = bad + .listings + .iter() + .find(|l| l.list == "Spamhaus DBL") + .unwrap(); + assert_eq!(dbl.state, ListingState::Listed); + // DL-13: the EHLO name is checked + assert!( + report.certificates.iter().any(|c| c.name == hostname), + "{:#?}", + report.certificates + ); + + // --- Over JMAP --------------------------------------------------------- + let (_, response) = call( + &admin, + "inbuxa:DeliverabilityReport/get", + json!({"ids": null}), + ) + .await; + let listed = response["list"].as_array().unwrap(); + assert_eq!(listed.len(), 1, "{response}"); + assert_eq!(listed[0]["addresses"][0]["ip"], "192.0.2.10", "{response}"); + // The two above and the test server's own + assert_eq!( + listed[0]["domains"].as_array().unwrap().len(), + report.domains.len(), + "{response}" + ); + assert!(listed[0]["checkedAt"].as_str().unwrap().ends_with('Z')); + + // Check now: queued, with when the node last checked (DL-15) + let (_, response) = call( + &admin, + "inbuxa:DeliverabilityReport/set", + json!({"create": {"now": {}}}), + ) + .await; + assert_eq!( + response["created"]["now"]["checkedAt"], listed[0]["checkedAt"], + "{response}" + ); + let (_, response) = call( + &admin, + "inbuxa:DeliverabilityReport/set", + json!({"destroy": [listed[0]["id"]]}), + ) + .await; + assert!(response["notDestroyed"].is_object(), "{response}"); + + // --- A tenant administrator (DL-20) ------------------------------------- + let t_admin = admin + .create_user_account( + "tadmin@good.example.org", + "tenant-admin-secret-5520", + "Tenant admin", + &[], + vec![], + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + t_admin.id(), + json!({Property::Roles: UserRoles::Admin}), + ) + .await; + let (_, response) = call( + &t_admin, + "inbuxa:DeliverabilityReport/get", + json!({"ids": null}), + ) + .await; + let seen = &response["list"][0]; + assert_eq!(seen["addresses"], json!([]), "{response}"); + assert_eq!(seen["certificates"], json!([]), "{response}"); + let domains = seen["domains"].as_array().unwrap(); + assert_eq!(domains.len(), 1, "{response}"); + assert_eq!(domains[0]["domain"], "good.example.org"); + + let (name, response) = call( + &t_admin, + "inbuxa:DeliverabilityReport/set", + json!({"create": {"now": {}}}), + ) + .await; + assert_eq!( + name, "error", + "a tenant administrator ran the check: {response}" + ); + let (name, response) = call( + &t_admin, + "inbuxa:DeliverabilitySettings/set", + json!({"update": {"singleton": {"disabledLists": []}}}), + ) + .await; + assert_eq!( + name, "error", + "a tenant administrator changed the lists: {response}" + ); + + // Cleared for the tests that follow + call( + &admin, + "inbuxa:DeliverabilitySettings/set", + json!({"update": {"singleton": {"disabledLists": []}}}), + ) + .await; +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn deliverability_tests() { + let mut test = TestServerBuilder::new("deliverability_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index d0f7d32..9b71645 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -17,6 +17,7 @@ pub mod legal_hold; // inbuxa: legal hold pub mod compliance; // inbuxa: the compliance roles pub mod mail_rules; // inbuxa: DLP and mail flow rules pub mod security_acceptances; // inbuxa: accepted security to-do items +pub mod deliverability; // inbuxa: the deliverability check pub mod journal; // inbuxa: journaling pub mod audit; // inbuxa: the audit log pub mod authorization;