Deliverability check: each node asks what the internet sees of it #150

Merged
jcoffey-dev merged 1 commits from feat/deliverability into main 2026-10-05 23:29:28 +00:00
34 changed files with 2576 additions and 5 deletions

No files matched your search

Generated
+2
View File
@@ -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",
+13
View File
@@ -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)
+2
View File
@@ -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)]
+282
View File
@@ -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
);
}
}
}
+410
View File
@@ -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());
}
}
+1
View File
@@ -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
}
}
+2
View File
@@ -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
+6
View File
@@ -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)?
}
+15
View File
@@ -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",
+4
View File
@@ -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>>),
+29
View File
@@ -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)),
+29
View File
@@ -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>
+23
View File
@@ -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
+48
View File
@@ -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();
+2
View File
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
| MethodObject::HoldExport
| MethodObject::MailRule
| MethodObject::SecurityAcceptance
| MethodObject::DeliverabilityReport
| MethodObject::DeliverabilitySettings
| MethodObject::Journal
| MethodObject::JournalEntry
| MethodObject::JournalExport
+352
View File
@@ -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)
}
+1
View File
@@ -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;
+4
View File
@@ -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,
+10 -1
View File
@@ -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 {
+3
View File
@@ -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]
+6
View File
@@ -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);
}
}
+4
View File
@@ -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);
}
+2
View File
@@ -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,
+12
View File
@@ -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
View File
@@ -1 +1 @@
OjbTKzNuSVcQRNR2Mb1UVvGnXui9r05aIdRASwyOSmo
EFFcJvTPjNnvBGmMdfIkunMwqbh6NS_rsmhmhF2OK3U
+389
View File
@@ -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();
}
}
+1
View File
@@ -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;