Deliverability check: each node asks what the internet sees of it
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 7m6s

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:
jcoffey-dev committed 2026-10-05 16:17:33 -07:00
1 parent f791c78d17
commit a24ed3b60a
34 files changed
+2576 -5

No files matched your search

+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;