Deliverability check: each node asks what the internet sees of it
Deliverability spec (inbuxa-drafts specs/deliverability.md), the server side. Every node that sends mail checks itself once a day, at its own minute in the first hour (UTC), and when an administrator asks: - its outgoing addresses (the connection strategy's, or what its EHLO name resolves to), their reverse DNS and whether it resolves back, and nine blocklists, read by each list's own codes so a refused query is never taken for a listing (DL-1 to DL-6); - for every domain: SPF for each address, each DKIM key (by signing a message that's never sent and verifying it as a receiver would), DMARC, the MTA-STS policy against the MX, TLS reporting, and the domain blocklists (DL-7 to DL-12); - whether it holds a certificate for its EHLO and MX names (DL-13). It keeps one report per node, facts only; the console grades them. - inbuxa:DeliverabilityReport: /get, and a create that asks every node to check now, broadcast as DeliverabilityCheck (DL-15). A tenant administrator gets their own domains only (DL-20). - inbuxa:DeliverabilitySettings: which built-in lists are left out, and the lists themselves (DL-6). - sysDeliverabilityGet, sysDeliverabilityUpdate, sysDeliverabilityCheck; a tenant ceiling always turns the last two off.
This commit is contained in:
1 parent
f791c78d17
commit
a24ed3b60a
34 files changed
+2576
-5
No files matched your search
@@ -123,6 +123,10 @@ impl JmapAuthorization for AccessToken {
|
||||
// inbuxa: accepted security items are read by whoever may
|
||||
// see the server's security settings
|
||||
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
||||
// inbuxa: deliverability spec; the lists are named on the
|
||||
// page that shows the findings, so they read the same way
|
||||
GetRequestMethod::DeliverabilityReport(_)
|
||||
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -335,6 +339,23 @@ impl JmapAuthorization for AccessToken {
|
||||
.details("You are not authorized to accept security items"))
|
||||
}
|
||||
}
|
||||
// inbuxa: DL-15: a create runs the check; the handler
|
||||
// refuses the rest
|
||||
SetRequestMethod::DeliverabilityReport(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDeliverabilityCheck,
|
||||
Permission::SysDeliverabilityCheck,
|
||||
Permission::SysDeliverabilityCheck,
|
||||
),
|
||||
// inbuxa: DL-6, which lists are asked
|
||||
SetRequestMethod::DeliverabilitySettings(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDeliverabilityUpdate,
|
||||
Permission::SysDeliverabilityUpdate,
|
||||
Permission::SysDeliverabilityUpdate,
|
||||
),
|
||||
// inbuxa: LH-12, exporting held data
|
||||
SetRequestMethod::HoldExport(s) => validate_set(
|
||||
s,
|
||||
@@ -506,6 +527,8 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::DeliverabilityReport
|
||||
| MethodObject::DeliverabilitySettings
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
|
||||
@@ -293,6 +293,12 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::DeliverabilityReport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::DeliverabilitySettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Journal(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -539,6 +545,19 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: the deliverability check
|
||||
GetRequestMethod::DeliverabilityReport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::deliverability::get_reports(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
GetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::deliverability::get_settings(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: journaling
|
||||
GetRequestMethod::Journal(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -1060,6 +1079,35 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: DL-15, Check now; nothing it changes needs recording
|
||||
SetRequestMethod::DeliverabilityReport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::deliverability::set_reports(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: DL-6; which lists are asked is in the audit log
|
||||
SetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
None,
|
||||
*req,
|
||||
|req| {
|
||||
Box::pin(crate::inbuxa::deliverability::set_settings(
|
||||
self,
|
||||
access_token,
|
||||
req,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::Journal(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone();
|
||||
|
||||
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::DeliverabilityReport
|
||||
| MethodObject::DeliverabilitySettings
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings`
|
||||
//! (deliverability spec).
|
||||
//!
|
||||
//! A report is one sending node's last check, written by that node. Reading
|
||||
//! reports needs `sysDeliverabilityGet`; a tenant administrator gets only
|
||||
//! their tenant's domains and nothing about the nodes (DL-20). Creating a
|
||||
//! report asks every node to check itself now (DL-15): it needs
|
||||
//! `sysDeliverabilityCheck`, returns at once with the node's last check
|
||||
//! time, and the new report replaces the old one when it's done. The
|
||||
//! settings say which built-in lists are left out (DL-6).
|
||||
|
||||
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||
use inbuxa_features::deliverability::{
|
||||
self as model, Report, Settings,
|
||||
lists::{self, Scope},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::{
|
||||
inbuxa_deliverability_report::{
|
||||
DeliverabilityReport, DeliverabilityReportProperty as R, DeliverabilityReportValue,
|
||||
},
|
||||
inbuxa_deliverability_settings::{
|
||||
DeliverabilitySettings, DeliverabilitySettingsProperty as S,
|
||||
DeliverabilitySettingsValue,
|
||||
},
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Map, Property, Value};
|
||||
use std::borrow::Cow;
|
||||
use types::id::Id;
|
||||
|
||||
const REPORT: &[R] = &[
|
||||
R::Id,
|
||||
R::NodeId,
|
||||
R::Hostname,
|
||||
R::CheckedAt,
|
||||
R::Addresses,
|
||||
R::Domains,
|
||||
R::Certificates,
|
||||
];
|
||||
|
||||
const SETTINGS: &[S] = &[S::Id, S::DisabledLists, S::Lists];
|
||||
|
||||
fn server_level(access_token: &AccessToken, what: &'static str) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden.into_err().details(what))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(n) = n.as_u64() {
|
||||
Value::Number(n.into())
|
||||
} else if let Some(n) = n.as_i64() {
|
||||
Value::Number(n.into())
|
||||
} else {
|
||||
Value::Number(n.as_f64().unwrap_or_default().into())
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
|
||||
serde_json::Value::Array(items) => {
|
||||
Value::Array(items.into_iter().map(json_to_value).collect())
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, value) in map {
|
||||
out.insert_unchecked(Key::Owned(key), json_to_value(value));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn date(seconds: u64) -> Value<'static, R, DeliverabilityReportValue> {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, DeliverabilityReportValue> {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
R::Id => Value::Element(DeliverabilityReportValue::Id(Id::from(report.node_id))),
|
||||
R::NodeId => Value::Number(report.node_id.into()),
|
||||
R::Hostname => Value::Str(report.hostname.clone().into()),
|
||||
R::CheckedAt => date(report.checked_at),
|
||||
R::Addresses => {
|
||||
json_to_value(serde_json::to_value(&report.addresses).unwrap_or_default())
|
||||
}
|
||||
R::Domains => json_to_value(serde_json::to_value(&report.domains).unwrap_or_default()),
|
||||
R::Certificates => {
|
||||
json_to_value(serde_json::to_value(&report.certificates).unwrap_or_default())
|
||||
}
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:DeliverabilityReport/get`: every sending node's last report.
|
||||
pub async fn get_reports(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<DeliverabilityReport>,
|
||||
) -> trc::Result<GetResponse<DeliverabilityReport>> {
|
||||
let properties = request.unwrap_properties(REPORT);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let mut reports = model::reports(server.store()).await?;
|
||||
// DL-20
|
||||
if let Some(tenant_id) = access_token.tenant_id() {
|
||||
reports = reports.iter().map(|r| r.for_tenant(tenant_id)).collect();
|
||||
}
|
||||
match ids {
|
||||
None => {
|
||||
response.list = reports
|
||||
.iter()
|
||||
.map(|r| report_value(r, &properties))
|
||||
.collect();
|
||||
}
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match reports.iter().find(|r| r.node_id == id.id()) {
|
||||
Some(report) => response.list.push(report_value(report, &properties)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:DeliverabilityReport/set`: a create asks every node to check
|
||||
/// itself now (DL-15). Reports are the server's: nothing else is allowed.
|
||||
pub async fn set_reports(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, DeliverabilityReport>,
|
||||
) -> trc::Result<SetResponse<DeliverabilityReport>> {
|
||||
server_level(access_token, "The deliverability check is the server's.")?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let node_id = server.core.network.node_id;
|
||||
|
||||
let mut asked = false;
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
if !asked {
|
||||
asked = true;
|
||||
services::inbuxa_deliverability::CHECK_NOW.notify_one();
|
||||
server
|
||||
.cluster_broadcast(BroadcastEvent::DeliverabilityCheck)
|
||||
.await;
|
||||
}
|
||||
// The node's last check, so the console knows when the new one lands
|
||||
let last = model::report(server.store(), node_id).await?;
|
||||
let mut out = Map::with_capacity(2);
|
||||
out.insert_unchecked(
|
||||
Key::Property(R::Id),
|
||||
Value::Element(DeliverabilityReportValue::Id(Id::from(node_id))),
|
||||
);
|
||||
out.insert_unchecked(
|
||||
Key::Property(R::CheckedAt),
|
||||
last.map(|r| date(r.checked_at)).unwrap_or(Value::Null),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Reports are written by the check."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Reports are written by the check."),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn lists_value() -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||
Value::Array(
|
||||
lists::LISTS
|
||||
.iter()
|
||||
.map(|list| {
|
||||
json_to_value(serde_json::json!({
|
||||
"name": list.name,
|
||||
"zone": list.zone,
|
||||
"scope": match list.scope {
|
||||
Scope::Ip => "ip",
|
||||
Scope::Domain => "domain",
|
||||
},
|
||||
"lookup": list.lookup,
|
||||
"note": list.note,
|
||||
}))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn settings_value(
|
||||
settings: &Settings,
|
||||
properties: &[S],
|
||||
) -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
S::Id => Value::Element(DeliverabilitySettingsValue::Id(Id::singleton())),
|
||||
S::DisabledLists => Value::Array(
|
||||
settings
|
||||
.disabled_lists
|
||||
.iter()
|
||||
.map(|name| Value::Str(name.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
S::Lists => lists_value(),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:DeliverabilitySettings/get`: which lists are left out, and the lists.
|
||||
pub async fn get_settings(
|
||||
server: &Server,
|
||||
_access_token: &AccessToken,
|
||||
mut request: GetRequest<DeliverabilitySettings>,
|
||||
) -> trc::Result<GetResponse<DeliverabilitySettings>> {
|
||||
let properties = request.unwrap_properties(SETTINGS);
|
||||
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
let settings = model::settings(server.store()).await?;
|
||||
match ids {
|
||||
None => response.list.push(settings_value(&settings, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response.list.push(settings_value(&settings, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:DeliverabilitySettings/set`: updates the singleton.
|
||||
pub async fn set_settings(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, DeliverabilitySettings>,
|
||||
) -> trc::Result<SetResponse<DeliverabilitySettings>> {
|
||||
server_level(access_token, "The blocklists checked are the server's.")?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
response
|
||||
.not_created
|
||||
.append(client_id, SetError::singleton());
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(id, SetError::singleton());
|
||||
}
|
||||
let data = server.store();
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
if !id.is_singleton() {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
let mut settings = model::settings(data).await?;
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match &key {
|
||||
Key::Property(S::DisabledLists) => {
|
||||
let names = value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
});
|
||||
match names {
|
||||
Some(Some(names)) => settings.disabled_lists = names,
|
||||
_ => {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(S::DisabledLists)
|
||||
.with_description("A list of list names."),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Key::Property(property) => {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description("The server sets this."),
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if error.is_none()
|
||||
&& let Err(why) = settings.validate()
|
||||
{
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(S::DisabledLists)
|
||||
.with_description(why),
|
||||
);
|
||||
}
|
||||
match error {
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
model::put_settings(data, &settings).await?;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod mail_rule;
|
||||
pub mod security_acceptance;
|
||||
pub mod deliverability; // inbuxa: the deliverability check
|
||||
pub mod journal;
|
||||
pub mod journal_entry;
|
||||
pub mod held_message;
|
||||
|
||||
Reference in new issue
Block a user