From a24ed3b60af0bb5644e13e9f3b3a19f4adcc4339 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 16:17:33 -0700 Subject: [PATCH] Deliverability check: each node asks what the internet sees of it Deliverability spec (inbuxa-drafts specs/deliverability.md), the server side. Every node that sends mail checks itself once a day, at its own minute in the first hour (UTC), and when an administrator asks: - its outgoing addresses (the connection strategy's, or what its EHLO name resolves to), their reverse DNS and whether it resolves back, and nine blocklists, read by each list's own codes so a refused query is never taken for a listing (DL-1 to DL-6); - for every domain: SPF for each address, each DKIM key (by signing a message that's never sent and verifying it as a receiver would), DMARC, the MTA-STS policy against the MX, TLS reporting, and the domain blocklists (DL-7 to DL-12); - whether it holds a certificate for its EHLO and MX names (DL-13). It keeps one report per node, facts only; the console grades them. - inbuxa:DeliverabilityReport: /get, and a create that asks every node to check now, broadcast as DeliverabilityCheck (DL-15). A tenant administrator gets their own domains only (DL-20). - inbuxa:DeliverabilitySettings: which built-in lists are left out, and the lists themselves (DL-6). - sysDeliverabilityGet, sysDeliverabilityUpdate, sysDeliverabilityCheck; a tenant ceiling always turns the last two off. --- Cargo.lock | 2 + crates/common/src/auth/permissions.rs | 13 + crates/common/src/ipc.rs | 2 + .../common/src/manager/granted_permissions.rs | 12 +- crates/features/src/deliverability/lists.rs | 282 +++++++++ crates/features/src/deliverability/mod.rs | 410 +++++++++++++ crates/features/src/lib.rs | 1 + .../object/inbuxa_deliverability_report.rs | 173 ++++++ .../object/inbuxa_deliverability_settings.rs | 160 +++++ crates/jmap-proto/src/object/mod.rs | 2 + crates/jmap-proto/src/references/eval.rs | 6 + crates/jmap-proto/src/references/resolve.rs | 8 + crates/jmap-proto/src/request/method.rs | 15 + crates/jmap-proto/src/request/mod.rs | 4 + crates/jmap-proto/src/request/parser.rs | 29 + crates/jmap-proto/src/response/mod.rs | 29 + crates/jmap/src/api/auth.rs | 23 + crates/jmap/src/api/request.rs | 48 ++ crates/jmap/src/changes/get.rs | 2 + crates/jmap/src/inbuxa/deliverability.rs | 352 +++++++++++ crates/jmap/src/inbuxa/mod.rs | 1 + crates/registry/src/schema/enums.rs | 4 + crates/registry/src/schema/enums_impl.rs | 11 +- crates/services/Cargo.toml | 3 + crates/services/src/broadcast/mod.rs | 6 + crates/services/src/broadcast/subscriber.rs | 7 + crates/services/src/inbuxa_deliverability.rs | 566 ++++++++++++++++++ crates/services/src/lib.rs | 4 + docs/spec/SPEC.md | 2 + resources/privacy/catalog.toml | 12 + resources/schema/schema.json.gz | Bin 153512 -> 153565 bytes resources/schema/schema.json.sha256 | 2 +- tests/src/system/deliverability.rs | 389 ++++++++++++ tests/src/system/mod.rs | 1 + 34 files changed, 2576 insertions(+), 5 deletions(-) create mode 100644 crates/features/src/deliverability/lists.rs create mode 100644 crates/features/src/deliverability/mod.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_deliverability_report.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_deliverability_settings.rs create mode 100644 crates/jmap/src/inbuxa/deliverability.rs create mode 100644 crates/services/src/inbuxa_deliverability.rs create mode 100644 tests/src/system/deliverability.rs 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 2fc1ca2e8fba7d031173a1cba25f5efaeed661ad..16e6335dced279e28c2d341353b7923dee5a7249 100644 GIT binary patch delta 19918 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 M3ki|<@Jaa&001cg^#A|> delta 19865 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 (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; -- 2.54.0