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.
This commit is contained in:
1 parent
f791c78d17
commit
a24ed3b60a
34 files changed
+2576
-5
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String> {
|
||||
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<Answer> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Address>,
|
||||
pub domains: Vec<DomainReport>,
|
||||
pub certificates: Vec<Certificate>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 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<String>,
|
||||
pub listings: Vec<Listing>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// What the list says the answer means.
|
||||
pub meaning: Option<String>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
/// DL-7: what SPF says for each of the node's addresses.
|
||||
pub spf: Vec<SpfResult>,
|
||||
/// DL-8: each DKIM key the domain signs with.
|
||||
pub dkim: Vec<DkimKey>,
|
||||
/// DL-9: the DMARC record, if there's one.
|
||||
pub dmarc: Option<Dmarc>,
|
||||
/// DL-10.
|
||||
pub mta_sts: MtaSts,
|
||||
/// DL-11: there's a `_smtp._tls` record.
|
||||
pub tls_rpt: bool,
|
||||
/// DL-12.
|
||||
pub listings: Vec<Listing>,
|
||||
}
|
||||
|
||||
#[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 `<selector>._domainkey.<domain>`.
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
/// `enforce`, `testing` or `none`.
|
||||
pub mode: Option<String>,
|
||||
pub max_age: Option<u64>,
|
||||
/// The domain's MX names no `mx:` line matches.
|
||||
pub mx_not_covered: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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>(T);
|
||||
|
||||
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||
serde_json::to_vec(&self.0).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to serialize deliverability data")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
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<u64>) -> 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<Option<Report>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Report>>(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<Vec<Report>> {
|
||||
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::<Report>::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<Settings> {
|
||||
Ok(data
|
||||
.get_value::<Json<Settings>>(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::<Vec<_>>(),
|
||||
["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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Self> {
|
||||
// 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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
DeliverabilityReportProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DeliverabilityReportValue {
|
||||
type Property = DeliverabilityReportProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for DeliverabilityReportValue {
|
||||
fn from(id: Id) -> Self {
|
||||
DeliverabilityReportValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DeliverabilityReportValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
DeliverabilityReportValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
// 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<Self> {
|
||||
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<Self, Self::Err> {
|
||||
DeliverabilitySettingsProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DeliverabilitySettingsValue {
|
||||
type Property = DeliverabilitySettingsProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
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<Id> for DeliverabilitySettingsValue {
|
||||
fn from(id: Id) -> Self {
|
||||
DeliverabilitySettingsValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DeliverabilitySettingsValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
DeliverabilitySettingsValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
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<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -126,6 +126,8 @@ pub enum GetRequestMethod {
|
||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||
@@ -172,6 +174,8 @@ pub enum SetRequestMethod<'x> {
|
||||
SecurityAcceptance(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||
),
|
||||
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -113,6 +113,8 @@ pub enum GetResponseMethod {
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
||||
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||
@@ -159,6 +161,8 @@ pub enum SetResponseMethod {
|
||||
SecurityAcceptance(
|
||||
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||
),
|
||||
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||
@@ -864,6 +868,31 @@ impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: the deliverability check
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DeliverabilityReport(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::DeliverabilityReport(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DeliverabilitySettings(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: accepted security to-do items
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||
for ResponseMethod<'x>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::DeliverabilityReport
|
||||
| MethodObject::DeliverabilitySettings
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
|
||||
@@ -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<P: Property, E: Element>(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<DeliverabilityReport>,
|
||||
) -> trc::Result<GetResponse<DeliverabilityReport>> {
|
||||
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<SetResponse<DeliverabilityReport>> {
|
||||
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<DeliverabilitySettings>,
|
||||
) -> trc::Result<GetResponse<DeliverabilitySettings>> {
|
||||
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<SetResponse<DeliverabilitySettings>> {
|
||||
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::<Option<Vec<_>>>()
|
||||
});
|
||||
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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -146,6 +146,10 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
|
||||
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 {
|
||||
|
||||
@@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, 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(),
|
||||
|
||||
@@ -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<Notify> = LazyLock::new(Notify::new);
|
||||
|
||||
pub fn spawn_deliverability(inner: Arc<Inner>) {
|
||||
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<Duration> {
|
||||
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<Report> {
|
||||
let settings = model::settings(server.store()).await?;
|
||||
let addresses = addresses(server, &settings).await;
|
||||
let mut domains = Vec::new();
|
||||
let ids = server
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await?;
|
||||
for id in ids {
|
||||
if let Some(domain) = server.registry().object::<Domain>(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<Address> {
|
||||
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::<String, _>(&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<IpAddr> {
|
||||
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<Listing> {
|
||||
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<DomainReport> {
|
||||
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::<IpAddr>() 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::<dmarc::Dmarc>(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::<TlsRpt>(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<Vec<DkimKey>> {
|
||||
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: <deliverability-check@{domain}>\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::<MtaStsRecord>(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<Certificate> {
|
||||
let mine: Vec<IpAddr> = addresses.iter().filter_map(|a| a.ip.parse().ok()).collect();
|
||||
let mut names: BTreeSet<String> = 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<T>(lookup: impl Future<Output = T>) -> Option<T> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo
|
||||
EFFcJvTPjNnvBGmMdfIkunMwqbh6NS_rsmhmhF2OK3U
|
||||
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The deliverability check (deliverability spec): what a node finds about
|
||||
//! its own addresses and the domains it sends for, which lists it leaves out,
|
||||
//! what a tenant administrator sees of it, and who may ask for a check.
|
||||
|
||||
use crate::utils::{
|
||||
account::Account,
|
||||
dns::DnsCache,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
};
|
||||
use inbuxa_features::deliverability::{AddressSource, DkimState, ListingState};
|
||||
use mail_auth::{
|
||||
DnssecStatus, MX, common::parse::TxtRecordParser, dmarc::Dmarc, mta_sts::MtaSts,
|
||||
mta_sts::TlsRpt, spf::Spf,
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use smtp::outbound::mta_sts::lookup::STS_TEST_POLICY;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:inbuxa:jmap",
|
||||
"urn:inbuxa:jmap:registry",
|
||||
];
|
||||
|
||||
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
|
||||
if arguments.get("accountId").is_none() {
|
||||
arguments["accountId"] = account.id_string().into();
|
||||
}
|
||||
let response = account
|
||||
.jmap_request(USING, json!([[method, arguments, "0"]]))
|
||||
.await;
|
||||
let call = response
|
||||
.0
|
||||
.pointer("/methodResponses/0")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||
(
|
||||
call[0].as_str().unwrap_or_default().to_string(),
|
||||
call[1].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn domain(admin: &Account, name: &str, tenant: Option<Id>) -> 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("[email protected]");
|
||||
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:[email protected]").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(
|
||||
"[email protected]",
|
||||
"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("[email protected]").await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user