Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820ef5df2c |
@@ -70,7 +70,6 @@ pub mod cache;
|
||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
||||
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
|
||||
pub mod reachability; // inbuxa: whether the outside world reaches each node's ports
|
||||
pub mod config;
|
||||
pub mod expr;
|
||||
pub mod i18n;
|
||||
@@ -130,8 +129,6 @@ pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21;
|
||||
pub const KV_LOCK_TASK: u8 = 23;
|
||||
pub const KV_LOCK_DAV: u8 = 25;
|
||||
pub const KV_SIEVE_ID: u8 = 26;
|
||||
// inbuxa: far above upstream's prefixes, so a new one of theirs never collides
|
||||
pub const KV_PORT_REACHABILITY: u8 = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
|
||||
@@ -409,10 +409,6 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||
bp.registry.write(RegistryWrite::insert(&object)).await?;
|
||||
}
|
||||
|
||||
// D5: the blocklist sent hashed email addresses starts off; the
|
||||
// rules load later, from a task, which acts on this note
|
||||
super::spam_rules::mark_new_install(&bp.data_store).await?;
|
||||
|
||||
// D1: rotated log files are kept 30 days (a fork-owned setting,
|
||||
// since x:TracerLog is also stored inside x:Bootstrap)
|
||||
use inbuxa_features::security::log_files;
|
||||
|
||||
@@ -118,78 +118,6 @@ pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()>
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// The blocklists a new install starts with switched off (personal-data
|
||||
/// catalog spec, default D5, settled 2026-09-28): the one that is sent a
|
||||
/// hash of every email address it's asked about.
|
||||
pub const NEW_INSTALL_OFF: &[&str] = &["STWT_MSBL_EBL_EMAIL"];
|
||||
|
||||
fn new_install_key() -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: b"Sn".to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Notes, on a new install's first boot, that [`NEW_INSTALL_OFF`] is to be
|
||||
/// switched off once the rules are in: they load later, from a task.
|
||||
pub async fn mark_new_install(data: &Store) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(new_install_key(), b"D5".to_vec());
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// After rules load: on a new install, switches [`NEW_INSTALL_OFF`] off and
|
||||
/// forgets the note, so it happens once. Returns whether anything changed.
|
||||
/// An existing server has no note, and keeps every blocklist as it is.
|
||||
pub async fn apply_new_install(
|
||||
registry: &store::RegistryStore,
|
||||
data: &Store,
|
||||
) -> trc::Result<bool> {
|
||||
use registry::schema::{prelude::Object, structs::SpamDnsblServer};
|
||||
use store::registry::write::RegistryWrite;
|
||||
|
||||
if data
|
||||
.get_value::<String>(ValueKey::from(new_install_key()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let mut changed = false;
|
||||
for server in registry.list::<SpamDnsblServer>().await? {
|
||||
let mut updated = server.object.clone();
|
||||
let SpamDnsblServer::Email(email) = &mut updated else {
|
||||
continue;
|
||||
};
|
||||
if !NEW_INSTALL_OFF.contains(&email.name.as_str()) || !email.enable {
|
||||
continue;
|
||||
}
|
||||
email.enable = false;
|
||||
let old = Object {
|
||||
inner: server.object.into(),
|
||||
revision: server.revision,
|
||||
};
|
||||
let new = Object {
|
||||
inner: updated.into(),
|
||||
revision: server.revision,
|
||||
};
|
||||
registry
|
||||
.write(RegistryWrite::update(types::id::Id::from(server.id.id()), &new, &old))
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(new_install_key());
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -75,22 +72,11 @@ impl Server {
|
||||
.acme_certificate_renewal_due(&domains, renew_before, now())
|
||||
.await?
|
||||
{
|
||||
// INBUXA: a certificate already covering these names (one stored by
|
||||
// hand before the domain was switched to automatic, say) isn't a
|
||||
// failure: schedule the renewal for when it falls due. Returning
|
||||
// NotDue here ended the task for good, and nothing renewed the
|
||||
// certificate before it expired.
|
||||
trc::event!(
|
||||
Acme(trc::AcmeEvent::RenewBackoff),
|
||||
Domain = domain.name.clone(),
|
||||
Hostname = domains.as_slice(),
|
||||
Details = "A valid certificate already covers these names",
|
||||
NextRetry = trc::Value::Timestamp(renew_at),
|
||||
);
|
||||
return Ok(vec![Task::AcmeRenewal(TaskDomainManagement {
|
||||
domain_id,
|
||||
status: TaskStatus::at(renew_at as i64),
|
||||
})]);
|
||||
return Err(AcmeError::NotDue(format!(
|
||||
"Certificate for domain {} is still valid; renewal is not due until {}",
|
||||
domain.name,
|
||||
UTCDateTime::from_timestamp(renew_at as i64)
|
||||
)));
|
||||
}
|
||||
|
||||
let dns_parameters = match &domain.dns_management {
|
||||
|
||||
@@ -88,31 +88,13 @@ fn days(duration: Option<&Duration>) -> Days {
|
||||
}
|
||||
}
|
||||
|
||||
/// The zones a DNSBL's zone expression can query: each quoted literal that
|
||||
/// starts with a dot, in any branch (`ip_reverse + '.zen.spamhaus.org'`).
|
||||
fn zone_hosts(value: &Value) -> Vec<String> {
|
||||
let mut hosts = Vec::new();
|
||||
let mut texts = Vec::new();
|
||||
fn collect<'a>(value: &'a Value, texts: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Value::String(s) => texts.push(s),
|
||||
Value::Array(items) => items.iter().for_each(|v| collect(v, texts)),
|
||||
Value::Object(map) => map.values().for_each(|v| collect(v, texts)),
|
||||
_ => {}
|
||||
}
|
||||
/// An expression's text, if it is a plain constant (a zone, a switch).
|
||||
fn expression_text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Object(o) => o.get("else").and_then(|v| v.as_str()).map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
collect(value, &mut texts);
|
||||
for text in texts {
|
||||
for literal in text.split('\'').skip(1).step_by(2) {
|
||||
if let Some(zone) = literal.strip_prefix('.')
|
||||
&& zone.contains('.')
|
||||
&& !hosts.iter().any(|h| h == zone)
|
||||
{
|
||||
hosts.push(zone.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -281,7 +263,7 @@ impl Server {
|
||||
let value = serde_json::to_value(&server.object).unwrap_or_default();
|
||||
if value.get("enable").and_then(Value::as_bool).unwrap_or(false) {
|
||||
dnsbl_on = true;
|
||||
for zone in value.get("zone").map(zone_hosts).unwrap_or_default() {
|
||||
if let Some(zone) = value.get("zone").and_then(expression_text) {
|
||||
endpoint(&mut facts, "spam-dnsbl", zone);
|
||||
}
|
||||
}
|
||||
@@ -415,16 +397,6 @@ mod tests {
|
||||
assert_eq!(remote_host(&json!({"@type": "S3", "bucket": "mail"})), Some("S3".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zones_come_from_every_branch() {
|
||||
let zone = json!({"else": "false", "match": {"0": {"if": "location == 'tcp'",
|
||||
"then": "ip_reverse + '.rep.mailspike.net'"}}});
|
||||
assert_eq!(zone_hosts(&zone), vec!["rep.mailspike.net"]);
|
||||
let zone = json!({"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}});
|
||||
assert_eq!(zone_hosts(&zone), vec!["ebl.msbl.org"], "not 'sha1'");
|
||||
assert!(zone_hosts(&json!({"else": "false"})).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_round_up() {
|
||||
assert_eq!(days(Some(&Duration::from_millis(86_400_000))), Days::Days(1));
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Whether the outside world can reach each node's ports (settings-reorg,
|
||||
//! Ports: the reachability check).
|
||||
//!
|
||||
//! A server can't answer this about itself: a connection to its own public
|
||||
//! address never leaves the machine, so it passes whatever the firewall in
|
||||
//! front says. In a cluster the other nodes are outside that machine. Every
|
||||
//! ten minutes each node resolves every other active node's hostname, as a
|
||||
//! sender would, and tries a TCP connection to each listener port on each
|
||||
//! address. What it saw goes in the shared in-memory store for an hour, under
|
||||
//! (target, prober), so whichever node the admin asks can report it all.
|
||||
//!
|
||||
//! A single server has no one outside to ask. It reports only whether each
|
||||
//! port is listening, and says so.
|
||||
//!
|
||||
//! A connection is all that's tried: nothing is sent, so no protocol logs a
|
||||
//! session and no rate limit counts it.
|
||||
|
||||
use crate::{KV_PORT_REACHABILITY, Server};
|
||||
use registry::schema::{enums::ClusterNodeStatus, structs::NetworkListener};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use store::{dispatch::lookup::KeyValue, write::now};
|
||||
|
||||
/// How often each node probes the others.
|
||||
pub const PROBE_INTERVAL: Duration = Duration::from_secs(600);
|
||||
/// How long one node's view of another is kept: long enough to span a missed round.
|
||||
const KEEP_FOR: u64 = 3600;
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Probe {
|
||||
pub port: u16,
|
||||
pub address: String,
|
||||
pub ok: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
/// Unix seconds.
|
||||
pub checked_at: u64,
|
||||
pub probes: Vec<Probe>,
|
||||
/// The hostname didn't resolve, so nothing could be tried.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// The ports a sender or client could reach: every listener's port, leaving
|
||||
/// out listeners bound only to loopback, which are private by design.
|
||||
pub fn public_ports<'x>(listeners: impl IntoIterator<Item = &'x NetworkListener>) -> Vec<u16> {
|
||||
listeners
|
||||
.into_iter()
|
||||
.flat_map(|l| l.bind.iter())
|
||||
.map(|addr| addr.0)
|
||||
.filter(|addr| !addr.ip().is_loopback())
|
||||
.map(|addr| addr.port())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn key(target: &str, prober: &str) -> Vec<u8> {
|
||||
format!("{target}\n{prober}").into_bytes()
|
||||
}
|
||||
|
||||
async fn connect(address: SocketAddr) -> Result<(), String> {
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(address)).await {
|
||||
Ok(Ok(_)) => Ok(()),
|
||||
Ok(Err(err)) => Err(err.to_string()),
|
||||
Err(_) => Err("no answer within 5 seconds".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries each port on each address `hostname` resolves to.
|
||||
pub async fn probe_host(hostname: &str, ports: &[u16]) -> Report {
|
||||
let checked_at = now();
|
||||
let addresses = match tokio::net::lookup_host((hostname, 0)).await {
|
||||
Ok(found) => found.map(|a| a.ip()).collect::<BTreeSet<_>>(),
|
||||
Err(err) => {
|
||||
return Report {
|
||||
checked_at,
|
||||
probes: vec![],
|
||||
error: Some(format!("{hostname} doesn't resolve: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
let tries = addresses.iter().flat_map(|ip| {
|
||||
ports.iter().map(move |port| {
|
||||
let address = SocketAddr::new(*ip, *port);
|
||||
async move {
|
||||
let result = connect(address).await;
|
||||
Probe {
|
||||
port: *port,
|
||||
address: ip.to_string(),
|
||||
ok: result.is_ok(),
|
||||
error: result.err(),
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
Report {
|
||||
checked_at,
|
||||
probes: futures::future::join_all(tries).await,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn listeners(server: &Server) -> trc::Result<Vec<NetworkListener>> {
|
||||
Ok(server
|
||||
.registry()
|
||||
.list::<NetworkListener>()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|l| l.object)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Where to knock to see a port listening on this machine: the bound
|
||||
/// address, or loopback of the same family for a wildcard bind.
|
||||
pub fn local_targets<'x>(
|
||||
listeners: impl IntoIterator<Item = &'x NetworkListener>,
|
||||
) -> Vec<SocketAddr> {
|
||||
listeners
|
||||
.into_iter()
|
||||
.flat_map(|l| l.bind.iter())
|
||||
.map(|addr| addr.0)
|
||||
.filter(|addr| !addr.ip().is_loopback())
|
||||
.map(|addr| match addr.ip() {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => {
|
||||
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port())
|
||||
}
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => {
|
||||
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), addr.port())
|
||||
}
|
||||
_ => addr,
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One round: this node probes every other active node and records what it saw.
|
||||
pub async fn probe_peers(server: &Server) -> trc::Result<()> {
|
||||
let nodes = server.registry().cluster_node_list().await?;
|
||||
let me = server.registry().node_id() as u64;
|
||||
let Some(prober) = nodes
|
||||
.iter()
|
||||
.find(|n| n.node_id == me)
|
||||
.map(|n| n.hostname.clone())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let ports = public_ports(&listeners(server).await?);
|
||||
for target in nodes.iter().filter(|n| {
|
||||
n.node_id != me && n.status == ClusterNodeStatus::Active && n.hostname != prober
|
||||
}) {
|
||||
let report = probe_host(&target.hostname, &ports).await;
|
||||
server
|
||||
.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_PORT_REACHABILITY,
|
||||
key(&target.hostname, &prober),
|
||||
serde_json::to_vec(&report).unwrap_or_default(),
|
||||
)
|
||||
.expires(KEEP_FOR),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What `GET /api/ports/check` answers.
|
||||
pub async fn report(server: &Server) -> trc::Result<Value> {
|
||||
let listeners = listeners(server).await?;
|
||||
let ports = public_ports(&listeners);
|
||||
let nodes = if server.core.storage.coordinator.is_enabled() {
|
||||
server.registry().cluster_node_list().await?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let active = nodes
|
||||
.iter()
|
||||
.filter(|n| n.status == ClusterNodeStatus::Active)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if active.len() < 2 {
|
||||
// No one outside to ask: only whether each port is listening here.
|
||||
let started = Instant::now();
|
||||
let listening = futures::future::join_all(local_targets(&listeners).into_iter().map(
|
||||
|address| async move {
|
||||
let result = connect(address).await;
|
||||
json!({ "port": address.port(), "address": address.ip().to_string(), "listening": result.is_ok() })
|
||||
},
|
||||
))
|
||||
.await;
|
||||
return Ok(json!({
|
||||
"mode": "local",
|
||||
"ports": ports,
|
||||
"listening": listening,
|
||||
"ms": started.elapsed().as_millis() as u64,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for target in &active {
|
||||
let mut seen_by = Vec::new();
|
||||
for prober in active.iter().filter(|p| p.node_id != target.node_id) {
|
||||
let stored = server
|
||||
.in_memory_store()
|
||||
.key_get::<String>(KeyValue::<()>::build_key(
|
||||
KV_PORT_REACHABILITY,
|
||||
key(&target.hostname, &prober.hostname),
|
||||
))
|
||||
.await?;
|
||||
let report = stored.and_then(|raw| serde_json::from_str::<Report>(&raw).ok());
|
||||
seen_by.push(json!({ "prober": prober.hostname, "report": report }));
|
||||
}
|
||||
out.push(json!({ "hostname": target.hostname, "seenBy": seen_by }));
|
||||
}
|
||||
Ok(json!({
|
||||
"mode": "cluster",
|
||||
"ports": ports,
|
||||
"intervalSeconds": PROBE_INTERVAL.as_secs(),
|
||||
"nodes": out,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn listener(binds: &[&str]) -> NetworkListener {
|
||||
NetworkListener {
|
||||
bind: registry::schema::prelude::Map::new(
|
||||
binds.iter().map(|b| b.parse().unwrap()).collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_ports_leave_out_loopback_only_listeners() {
|
||||
let listeners = [
|
||||
listener(&["[::]:25"]),
|
||||
listener(&["0.0.0.0:993", "[::]:993"]),
|
||||
listener(&["127.0.0.1:8080"]),
|
||||
listener(&["203.0.113.5:465"]),
|
||||
];
|
||||
assert_eq!(public_ports(listeners.iter()), vec![25, 465, 993]);
|
||||
assert_eq!(
|
||||
local_targets(listeners.iter())
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["127.0.0.1:993", "203.0.113.5:465", "[::1]:25", "[::1]:993"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_host_reports_open_and_closed_ports() {
|
||||
let open = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let open_port = open.local_addr().unwrap().port();
|
||||
let closed_port = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
let report = probe_host("127.0.0.1", &[open_port, closed_port]).await;
|
||||
assert_eq!(report.error, None);
|
||||
let ok = |port| report.probes.iter().find(|p| p.port == port).unwrap().ok;
|
||||
assert!(ok(open_port));
|
||||
assert!(!ok(closed_port));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_host_says_when_a_name_does_not_resolve() {
|
||||
let report = probe_host("does-not-exist.invalid", &[25]).await;
|
||||
assert!(report.probes.is_empty());
|
||||
assert!(report.error.unwrap().contains("doesn't resolve"));
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,15 @@ async fn post_webhook_events(
|
||||
|
||||
// Add HMAC-SHA256 signature
|
||||
let mut headers = settings.headers.clone();
|
||||
sign(&mut headers, &settings.key, &body);
|
||||
if !settings.key.is_empty() {
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes());
|
||||
let tag = hmac::sign(&key, body.as_bytes());
|
||||
|
||||
headers.insert(
|
||||
"X-Signature",
|
||||
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
// Send request
|
||||
let response = settings
|
||||
@@ -180,150 +188,3 @@ async fn post_webhook_events(
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the HMAC-SHA256 `X-Signature` a receiver checks, when the webhook has a key.
|
||||
fn sign(headers: &mut hyper::HeaderMap, key: &str, body: &str) {
|
||||
if !key.is_empty() {
|
||||
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
|
||||
let tag = hmac::sign(&key, body.as_bytes());
|
||||
|
||||
headers.insert(
|
||||
"X-Signature",
|
||||
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: "Send test" for a saved webhook (settings-reorg, Webhooks). One
|
||||
/// sample event, sent the way a real batch is: the same URL, headers, sign-in,
|
||||
/// signature, timeout and certificate checks. The event's type,
|
||||
/// `webhook.test`, is none the server raises, and an `X-Inbuxa-Test` header
|
||||
/// marks it, so a receiver can tell it apart. Answers the HTTP status, or why
|
||||
/// nothing came back.
|
||||
pub async fn send_test(hook: ®istry::schema::structs::WebHook) -> Result<u16, String> {
|
||||
let mut headers = hook
|
||||
.http_auth
|
||||
.build_headers(hook.http_headers.clone(), "application/json".into())
|
||||
.await
|
||||
.map_err(|err| format!("Unable to build HTTP headers: {err}"))?;
|
||||
let key = hook
|
||||
.signature_key
|
||||
.secret()
|
||||
.await
|
||||
.map_err(|err| format!("Unable to retrieve signature key: {err}"))?
|
||||
.unwrap_or_default()
|
||||
.into_owned();
|
||||
|
||||
let created = now();
|
||||
let body = serde_json::json!({
|
||||
"events": [{
|
||||
"id": format!("test-{created}"),
|
||||
"createdAt": mail_parser::DateTime::from_timestamp(created as i64).to_rfc3339(),
|
||||
"type": "webhook.test",
|
||||
"data": { "details": "A test from inbuxa Admin. Nothing happened on the server." },
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
sign(&mut headers, &key, &body);
|
||||
headers.insert("X-Inbuxa-Test", "true".parse().unwrap());
|
||||
|
||||
let response = utils::http::http_client_builder(hook.allow_invalid_certs)
|
||||
.build()
|
||||
.map_err(|err| format!("Unable to build an HTTP client: {err}"))?
|
||||
.post(&hook.url)
|
||||
.timeout(hook.timeout.into_inner())
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Webhook request to {} failed: {err}", hook.url))?;
|
||||
Ok(response.status().as_u16())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use registry::schema::structs::{SecretKeyOptional, SecretKeyValue, WebHook};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// One request in, the given status out; hands back what was received.
|
||||
async fn receiver(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}/hook", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
if let Some(end) = text.find("\r\n\r\n") {
|
||||
let length = text[..end]
|
||||
.lines()
|
||||
.find_map(|l| {
|
||||
l.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.map(|v| v.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if buf.len() >= end + 4 + length || n == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
socket
|
||||
.write_all(
|
||||
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
});
|
||||
(url, task)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_test_signs_and_marks_the_sample() {
|
||||
let (url, task) = receiver("204 No Content").await;
|
||||
let hook = WebHook {
|
||||
url,
|
||||
enable: false,
|
||||
signature_key: SecretKeyOptional::Value(SecretKeyValue { secret: "k".into() }),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(send_test(&hook).await, Ok(204));
|
||||
|
||||
let request = task.await.unwrap();
|
||||
let (head, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
let head = head.to_ascii_lowercase();
|
||||
assert!(head.contains("x-inbuxa-test: true"), "{head}");
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(parsed["events"][0]["type"], "webhook.test");
|
||||
let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, b"k"), body.as_bytes());
|
||||
assert!(
|
||||
head.contains(&format!(
|
||||
"x-signature: {}",
|
||||
STANDARD.encode(tag.as_ref()).to_ascii_lowercase()
|
||||
)),
|
||||
"{head}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_test_reports_what_came_back() {
|
||||
let (url, _task) = receiver("403 Forbidden").await;
|
||||
let hook = WebHook {
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(send_test(&hook).await, Ok(403));
|
||||
|
||||
let hook = WebHook {
|
||||
url: "http://127.0.0.1:9/hook".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(send_test(&hook).await.unwrap_err().contains("failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,40 +120,6 @@ impl ManagementApi for Server {
|
||||
jmap::inbuxa::explanation::question(self, &access_token, &subject).await?;
|
||||
Ok(explain_stream(self.clone(), access_token, question, in_flight))
|
||||
}
|
||||
// inbuxa: try a saved directory before anything signs in through it
|
||||
"directory" if is_post && path.get(1).copied() == Some("test") => {
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
jmap::inbuxa::directory_test::assert_allowed(&access_token)?;
|
||||
let request = body
|
||||
.as_deref()
|
||||
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||
.unwrap_or_default();
|
||||
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
|
||||
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||
}
|
||||
// inbuxa: send one sample event to a saved webhook
|
||||
"webhook" if is_post && path.get(1).copied() == Some("test") => {
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
jmap::inbuxa::webhook_test::assert_allowed(&access_token)?;
|
||||
let request = body
|
||||
.as_deref()
|
||||
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||
.unwrap_or_default();
|
||||
let answer = jmap::inbuxa::webhook_test::test(self, &request).await?;
|
||||
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||
}
|
||||
// inbuxa: whether the outside world reaches each node's ports
|
||||
"ports" if path.get(1).copied() == Some("check") => {
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
if access_token.tenant_id().is_some() {
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Port checks are for server-level administrators."));
|
||||
}
|
||||
access_token.enforce_permission(Permission::SysNetworkListenerGet)?;
|
||||
let answer = common::reachability::report(self).await?;
|
||||
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||
}
|
||||
"account" => {
|
||||
// Authenticate request
|
||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `POST /api/directory/test`: try a saved directory before anything signs in
|
||||
//! through it (settings-reorg, guided setup "Connect a sign-in directory").
|
||||
//!
|
||||
//! The body names a directory and an address, and optionally a password:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"directoryId": "b", "address": "[email protected]", "password": "…"}
|
||||
//! ```
|
||||
//!
|
||||
//! The answer says whether the directory opened, what a recipient lookup of
|
||||
//! the address finds, and, when a password is given, whether it signs in.
|
||||
//! It calls the directory itself, below the sign-in path, so a test never
|
||||
//! creates or updates an account (DIR-14), never counts toward the sign-in
|
||||
//! ban, and doesn't mind which domains use the directory (DIR-6). Nothing is
|
||||
//! cached (DIR-32). A password hash a directory hands back is never returned.
|
||||
//!
|
||||
//! For server-level administrators who may change directories.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use directory::{Credentials, Directory, Recipient};
|
||||
use registry::schema::enums::Permission;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
fn message(err: &trc::Error) -> String {
|
||||
err.value_as_str(trc::Key::Reason)
|
||||
.or_else(|| err.value_as_str(trc::Key::Details))
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| err.to_string())
|
||||
}
|
||||
|
||||
fn kind(directory: &Directory) -> &'static str {
|
||||
match directory {
|
||||
Directory::Ldap(_) => "ldap",
|
||||
Directory::Sql(_) => "sql",
|
||||
Directory::OpenId(_) => "oidc",
|
||||
Directory::Unavailable(d) => match d.directory_type() {
|
||||
registry::schema::enums::DirectoryType::Ldap => "ldap",
|
||||
registry::schema::enums::DirectoryType::Sql => "sql",
|
||||
registry::schema::enums::DirectoryType::Oidc => "oidc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Directory tests are for server-level administrators."));
|
||||
}
|
||||
access_token.enforce_permission(Permission::SysDirectoryUpdate)
|
||||
}
|
||||
|
||||
fn bad(details: &'static str) -> trc::Error {
|
||||
trc::ResourceEvent::BadParameters.into_err().details(details)
|
||||
}
|
||||
|
||||
pub async fn test(server: &Server, body: &Value) -> trc::Result<Value> {
|
||||
let directory_id = body
|
||||
.get("directoryId")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| Id::from_str(id).ok())
|
||||
.ok_or_else(|| bad("Expected {\"directoryId\": …, \"address\": …}"))?;
|
||||
let address = body
|
||||
.get("address")
|
||||
.and_then(Value::as_str)
|
||||
.map(|a| a.trim().to_lowercase())
|
||||
.filter(|a| !a.is_empty())
|
||||
.ok_or_else(|| bad("Expected an address to look up"))?;
|
||||
let password = body
|
||||
.get("password")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|p| !p.is_empty());
|
||||
|
||||
let Some(directory) = server
|
||||
.core
|
||||
.storage
|
||||
.directories
|
||||
.get(&(directory_id.id() as u32))
|
||||
.cloned()
|
||||
else {
|
||||
return Ok(json!({
|
||||
"opened": false,
|
||||
"error": "The server hasn't loaded this directory. Save it, and try again in a few seconds.",
|
||||
}));
|
||||
};
|
||||
|
||||
let mut out = json!({ "kind": kind(&directory) });
|
||||
if let Directory::Unavailable(d) = directory.as_ref() {
|
||||
out["opened"] = json!(false);
|
||||
out["error"] = json!(message(&d.error()));
|
||||
return Ok(out);
|
||||
}
|
||||
out["opened"] = json!(true);
|
||||
|
||||
if let Some(discovery) = directory.oidc_discovery_document() {
|
||||
out["oidc"] = json!({
|
||||
"issuer": discovery.document.issuer,
|
||||
"jwksUri": discovery.document.jwks_uri,
|
||||
});
|
||||
}
|
||||
|
||||
// What mail for this address would find.
|
||||
if directory.can_lookup_recipients() {
|
||||
out["lookup"] = match directory.recipient(&address).await {
|
||||
Ok(Recipient::Account(a)) => json!({
|
||||
"found": "account",
|
||||
"email": a.email,
|
||||
"aliases": a.email_aliases,
|
||||
"groups": a.groups.unwrap_or_default(),
|
||||
"description": a.description,
|
||||
}),
|
||||
Ok(Recipient::Group(g)) => json!({
|
||||
"found": "group",
|
||||
"email": g.email,
|
||||
"aliases": g.email_aliases,
|
||||
"description": g.description,
|
||||
}),
|
||||
Ok(Recipient::Invalid) => json!({ "found": "none" }),
|
||||
Err(err) => json!({ "error": message(&err) }),
|
||||
};
|
||||
}
|
||||
|
||||
// Whether this person could sign in. OIDC takes tokens, not passwords
|
||||
// (DIR-29), so there's nothing to try there.
|
||||
if let Some(password) = password
|
||||
&& !matches!(directory.as_ref(), Directory::OpenId(_))
|
||||
{
|
||||
let credentials = Credentials::Basic {
|
||||
username: address.clone(),
|
||||
secret: password.to_string(),
|
||||
mfa_token: None,
|
||||
};
|
||||
out["signIn"] = match directory.authenticate(&credentials).await {
|
||||
Ok(a) => json!({
|
||||
"ok": true,
|
||||
"email": a.email,
|
||||
"groups": a.groups.unwrap_or_default(),
|
||||
"description": a.description,
|
||||
}),
|
||||
Err(err) if matches!(err.as_ref(), trc::EventType::Auth(trc::AuthEvent::Failed)) => {
|
||||
json!({ "ok": false, "wrongPassword": true })
|
||||
}
|
||||
Err(err) => json!({ "ok": false, "error": message(&err) }),
|
||||
};
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
@@ -798,10 +798,6 @@ mod tests {
|
||||
assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err());
|
||||
}
|
||||
|
||||
fn is_timestamp(value: &str) -> bool {
|
||||
chrono::DateTime::parse_from_rfc3339(value).is_ok()
|
||||
}
|
||||
|
||||
/// The settings questions a release prepares answers for (EX-26): every
|
||||
/// non-secret property of every settings object, at the object's own
|
||||
/// default, built exactly as a live question is.
|
||||
@@ -846,12 +842,6 @@ mod tests {
|
||||
if info.secret {
|
||||
continue;
|
||||
}
|
||||
// A date's default is the moment the object is built, so its
|
||||
// question changes every run and no live question ever
|
||||
// matches it: nothing worth preparing.
|
||||
if matches!(map[&property].as_str(), Some(v) if is_timestamp(v)) {
|
||||
continue;
|
||||
}
|
||||
let mut facts = Facts::default();
|
||||
push_setting(&mut facts, &object, &property, &info, &map[&property]);
|
||||
out.push((object.clone(), property, facts));
|
||||
@@ -866,7 +856,6 @@ mod tests {
|
||||
assert!(questions.len() > 500, "found {}", questions.len());
|
||||
assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement"));
|
||||
assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth"));
|
||||
assert!(!questions.iter().any(|(o, p, _)| o == "x:Account" && p == "createdAt"));
|
||||
}
|
||||
|
||||
/// Writes `resources/explain/settings.json.gz` (EX-26). Run before a
|
||||
|
||||
@@ -17,8 +17,6 @@ pub mod audit_log;
|
||||
pub mod ai_limits;
|
||||
pub mod log_settings;
|
||||
pub mod data_inventory;
|
||||
pub mod directory_test;
|
||||
pub mod webhook_test;
|
||||
pub mod explanation;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `POST /api/webhook/test`: send one sample event to a saved webhook
|
||||
//! (settings-reorg, Webhooks "Send test").
|
||||
//!
|
||||
//! ```json
|
||||
//! {"webhookId": "b"}
|
||||
//! ```
|
||||
//!
|
||||
//! The answer is `{"sent": true, "status": 200, "ms": 84}` when the receiver
|
||||
//! answered 2xx, `{"sent": false, "status": 403, …}` when it answered
|
||||
//! otherwise, and `{"sent": false, "error": "…"}` when nothing came back. The
|
||||
//! webhook is used as saved, even when it's off, so it can be tried before
|
||||
//! it's switched on. The request goes where the saved webhook already sends,
|
||||
//! so this gives nobody a reach they didn't have.
|
||||
//!
|
||||
//! For server-level administrators who may change webhooks.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use registry::schema::{enums::Permission, structs::WebHook};
|
||||
use serde_json::{Value, json};
|
||||
use std::{str::FromStr, time::Instant};
|
||||
use types::id::Id;
|
||||
|
||||
pub fn assert_allowed(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
return Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Webhook tests are for server-level administrators."));
|
||||
}
|
||||
access_token.enforce_permission(Permission::SysWebHookUpdate)
|
||||
}
|
||||
|
||||
pub async fn test(server: &Server, body: &Value) -> trc::Result<Value> {
|
||||
let webhook_id = body
|
||||
.get("webhookId")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| Id::from_str(id).ok())
|
||||
.ok_or_else(|| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Expected {\"webhookId\": …}")
|
||||
})?;
|
||||
let Some(hook) = server.registry().object::<WebHook>(webhook_id).await? else {
|
||||
return Ok(json!({ "sent": false, "error": "There's no such webhook. Save it first." }));
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
Ok(match common::telemetry::webhooks::send_test(&hook).await {
|
||||
Ok(status) => json!({
|
||||
"sent": (200..300).contains(&status),
|
||||
"status": status,
|
||||
"ms": started.elapsed().as_millis() as u64,
|
||||
}),
|
||||
Err(error) => json!({ "sent": false, "error": error }),
|
||||
})
|
||||
}
|
||||
@@ -46,8 +46,6 @@ enum Event {
|
||||
StoreMetrics,
|
||||
// inbuxa: MON-25: alert evaluation
|
||||
EvaluateAlerts,
|
||||
// inbuxa: settings-reorg: probe the other nodes' ports
|
||||
ProbePeerPorts,
|
||||
}
|
||||
|
||||
/// When the next metric-history tick is due (MON-4), read from the registry
|
||||
@@ -97,11 +95,6 @@ pub fn spawn_task_scheduler(inner: Arc<Inner>) {
|
||||
Instant::now() + server.registry().refresh_node_id_interval(),
|
||||
Event::RenewNodeIdLease,
|
||||
);
|
||||
// inbuxa: first round a minute after start, once the others have a lease
|
||||
queue.schedule(
|
||||
Instant::now() + Duration::from_secs(60),
|
||||
Event::ProbePeerPorts,
|
||||
);
|
||||
}
|
||||
|
||||
// Spam classifier training
|
||||
@@ -236,19 +229,6 @@ pub fn spawn_task_scheduler(inner: Arc<Inner>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Event::ProbePeerPorts => {
|
||||
queue.schedule(
|
||||
Instant::now() + common::reachability::PROBE_INTERVAL,
|
||||
Event::ProbePeerPorts,
|
||||
);
|
||||
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = common::reachability::probe_peers(&server).await {
|
||||
trc::error!(err.details("Failed to probe the other nodes' ports"));
|
||||
}
|
||||
});
|
||||
}
|
||||
Event::OtelMetrics => {
|
||||
if let Some(otel) = &server.core.metrics.otel {
|
||||
queue.schedule(Instant::now() + otel.interval, Event::OtelMetrics);
|
||||
@@ -496,7 +476,6 @@ impl Event {
|
||||
Event::RenewNodeIdLease => "renewNodeIdLease",
|
||||
Event::StoreMetrics => "storeMetrics",
|
||||
Event::EvaluateAlerts => "evaluateAlerts",
|
||||
Event::ProbePeerPorts => "probePeerPorts",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,15 +316,6 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
||||
spam_rules::set_applied_version(server.store(), spam_rules::BUNDLED_SPAM_RULES_APPLIED)
|
||||
.await?;
|
||||
}
|
||||
// inbuxa: personal-data catalog, D5: a new install's first rules
|
||||
// leave the hashed-address blocklist off
|
||||
if spam_rules::apply_new_install(server.registry(), server.store()).await?
|
||||
&& let Err(err) = reload_and_broadcast(server, ObjectType::SpamDnsblServer).await
|
||||
{
|
||||
return Ok(TaskResult::permanent(format!(
|
||||
"Spam rules were stored but not activated ({err}); run Reload settings"
|
||||
)));
|
||||
}
|
||||
Ok(TaskResult::Success(vec![]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.28.6"
|
||||
"2026.9.28.3"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
# Feature spec: data loss prevention and mail flow rules
|
||||
|
||||
Status: **approved 2026-09-28**, with the answers under [Settled](#settled)
|
||||
and the detector catalog in §2.3. Phase 1 of DLP and the rule builder, specced together because they
|
||||
need the same conditions, the same place in the mail path and the same record
|
||||
of what matched. Not a rebuild of an upstream feature, so it has no line in
|
||||
SPEC.md §4's table.
|
||||
|
||||
## Provenance
|
||||
|
||||
Written for the record SPEC.md §3 rule 3 asks for. Sources, and nothing else:
|
||||
|
||||
| Source | License | Used for |
|
||||
|---|---|---|
|
||||
| This repository at `0502eb4` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `crates/smtp/src/queue/`, `crates/jmap/src/submission/set.rs`, `crates/common/src/scripts/`, `vendor/sieve-rs`, `resources/schema/schema.json.gz` | AGPL-3.0-only | Where a check can run, what the queue stores, what a sender sees on a refusal |
|
||||
| inbuxa-admin at `b82904c` | AGPL-3.0-only | Where the pages go |
|
||||
| ihasmail-inbuxa (the webmail) at `290bc63` | AGPL-3.0-or-later | How a refused send reaches the person sending |
|
||||
| `inbuxa-drafts/queue/dlp.md`, `rule-builder.md` | Own | What John asked for and settled |
|
||||
| The personal-data catalog spec and the audit-hold-lock spec | Own | Roles, the audit log, legal holds, the catalog check |
|
||||
| RFC 5321, RFC 3463 (enhanced status codes), RFC 8620/8621 (JMAP) | Public | Refusal codes and the submission error shape |
|
||||
| The issuing authorities' published formats and check-digit rules for each identifier in §2.3 (ISO 13616, ISO/IEC 7812, ISO 7064, and each national scheme's own publication) | Public | The detector rules; each is implemented from its publication and tested against its published examples |
|
||||
|
||||
No Enterprise-only file or snippet was used, and no third-party DLP product
|
||||
was consulted for design: the detectors are public checksum and format rules
|
||||
(Luhn, ISO 13616 mod 97, the SSA's published SSN rules).
|
||||
|
||||
## What it is
|
||||
|
||||
1. **Data loss prevention (DLP).** Policies that look at mail as someone
|
||||
sends it, find what shouldn't leave (card numbers, bank accounts, national
|
||||
ID numbers, words and patterns an organization names, attachments of a
|
||||
kind), and then **block** it with a notice, **warn** and let the sender
|
||||
send anyway with a stated reason, or **hold** it until a reviewer releases
|
||||
or rejects it.
|
||||
2. **Mail flow rules.** The same engine, for ordinary transport rules an
|
||||
administrator writes in a form instead of in Sieve: disclaimers, banners,
|
||||
headers, copies, redirects, refusals.
|
||||
3. **One record of what matched**, in the audit log, and a **review queue**
|
||||
for held mail.
|
||||
|
||||
Settled before this spec (John, 2026-09-27 and 2026-09-28): DLP's first
|
||||
version checks **outgoing mail only**, content and attachments, as it's sent;
|
||||
its actions are block with a notice, warn with an override (audited), and hold
|
||||
for review. A record-only action was not chosen. The rule builder goes
|
||||
alongside DLP, one design; journaling comes after both.
|
||||
|
||||
**Out of scope** (later specs): inbound DLP, files and calendar sharing,
|
||||
scanning mail already stored, a machine-learning classifier (the local AI
|
||||
model could add one later; nothing here depends on it), mobile and ihasmail
|
||||
screens, journaling.
|
||||
|
||||
Nothing in code, docs, UI text or output claims the product meets a legal
|
||||
standard or prevents every leak. The pages say what a policy checks and what
|
||||
it did.
|
||||
|
||||
## 1. What exists today
|
||||
|
||||
Checked by reading the code at `0502eb4`:
|
||||
|
||||
| Need | Today |
|
||||
|---|---|
|
||||
| A place in the send path that sees every outgoing message | Yes. SMTP submission and webmail sends both reach `Session::queue_message` (`inbound/data.rs`); JMAP submission builds a local session and runs MAIL, RCPT and DATA (`jmap/src/submission/set.rs` ~L690–760). Nothing reaches the queue around it. |
|
||||
| Order at DATA | Authentication checks → spam filter → milters → MTA hooks → the DATA system Sieve script → headers, DKIM signing → queue. |
|
||||
| Rules without code | System Sieve (`x:SieveSystemScript`): one script per stage, chosen by an expression on `x:MtaStageData.script`. Hand-written only; the console has a text field. |
|
||||
| Refusing a message | A 5xx at DATA. The webmail gets `forbiddenToSend` with the text `Server rejected DATA: <reply>` and nothing structured. |
|
||||
| Holding a message | **Nowhere.** "Quarantine" in the code is only DMARC's disposition. The queue's recipient status is `Scheduled`, `Completed`, `TemporaryFailure`, `PermanentFailure`, archived with rkyv. |
|
||||
| Reading attachments | Text and HTML parts only. There's no PDF or Office text extraction: search indexes text parts and file names. |
|
||||
| Recording what happened | The audit log, with system actors, reasons and outcomes (AU-1…AU-12). |
|
||||
| Who is allowed | Roles with per-permission grants; server and tenant levels; the compliance roles from the catalog spec. |
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 One engine, native, after the system script
|
||||
|
||||
Rules are evaluated by a new native engine at DATA, **after** the system Sieve
|
||||
script and before headers and DKIM signing. Mail flow rules and DLP policies
|
||||
are two views of the same rule list.
|
||||
|
||||
Why not generate Sieve: holding a message, a warning the sender can override,
|
||||
counted detectors with checksums, and a per-rule match record are all things
|
||||
Sieve doesn't have. Adding them means new extensions in `vendor/sieve-rs`,
|
||||
which widens the fork of a crate we'd otherwise take from upstream, and a
|
||||
generated script would have to share the single DATA script with whatever an
|
||||
administrator wrote by hand. A native engine leaves hand-written Sieve exactly
|
||||
as it is: it still runs, first, and the rules see its result.
|
||||
|
||||
The engine lives in `crates/features/src/mailflow/` (pure evaluation over a
|
||||
parsed message and an envelope, unit-testable), called from `data.rs` behind
|
||||
one `// inbuxa:` marked block.
|
||||
|
||||
### 2.2 Rules
|
||||
|
||||
A fork-owned JMAP object, `inbuxa:MailRule`, stored in inbuxa's own subspace
|
||||
like legal holds (not a registry object, so upstream schema imports never
|
||||
touch it):
|
||||
|
||||
| Property | |
|
||||
|---|---|
|
||||
| `name`, `description` | |
|
||||
| `kind` | `dlp` or `transport`: which page shows it and which permission edits it |
|
||||
| `enabled` | |
|
||||
| `priority` | Order; lower runs first |
|
||||
| `direction` | `outgoing` (authenticated senders), `incoming`, or `any`. **DLP rules are `outgoing` only** in this version. |
|
||||
| `conditions` | All must match (list below) |
|
||||
| `exceptions` | Any matching one skips the rule |
|
||||
| `actions` | What happens (list below) |
|
||||
| `stopProcessing` | Later rules don't run for this message |
|
||||
| `tenantId` | Always none in this version: rules are server-level (settled answer 3); a **Tenant** condition narrows a rule to tenants |
|
||||
| `createdBy`, `updatedAt` | |
|
||||
|
||||
Every create, update and delete is audited with its before and after, like
|
||||
any setting, and appears on the compliance Overview's **Changes that affect
|
||||
review**.
|
||||
|
||||
### 2.3 Conditions
|
||||
|
||||
Shared by both kinds:
|
||||
|
||||
| Condition | Matches when |
|
||||
|---|---|
|
||||
| Sender | the sender is one of the chosen accounts, or in a chosen group, domain or tenant |
|
||||
| Tenant | the sender is in one of the chosen tenants |
|
||||
| Recipient | any recipient is one of the chosen addresses, domains, groups |
|
||||
| **Recipient outside** | any recipient isn't at a domain this server hosts |
|
||||
| Subject or body contains | any of a list of words or phrases (whole words, case-insensitive) |
|
||||
| Subject or body matches | a regular expression (the `regex` crate: linear time, no backtracking) |
|
||||
| Header | a header exists, or its value contains or matches |
|
||||
| Attachment | its detected type, extension or name matches; its size is over a limit; there are more than N |
|
||||
| **Can't be inspected** | an attachment is encrypted or password-protected (ZIP, PDF, Office), or bigger than the inspection limit |
|
||||
| Message size | over a limit |
|
||||
|
||||
DLP adds **detectors**. Each counts what it finds, and a rule sets a minimum
|
||||
(for example "5 or more card numbers"). A detector is one of two strengths:
|
||||
|
||||
- **Checked**: the identifier has a published check digit or checksum, so a
|
||||
random number rarely passes. Found on its own.
|
||||
- **Needs a word**: the format is too common to trust alone (nine digits, a
|
||||
date). Counted only with a corroborating word nearby, within 50 characters
|
||||
either side, in the languages where the identifier is used ("passport",
|
||||
"Reisepass", "pasaporte"...).
|
||||
|
||||
The catalog (settled answer 6: the recognized, protected identifiers, not a
|
||||
chosen few). Each row is one table entry and one check function in
|
||||
`crates/features/src/mailflow/detectors/`:
|
||||
|
||||
| Region | Detector | Strength | Rule |
|
||||
|---|---|---|---|
|
||||
| Any | Payment card number | Checked | 13–19 digits, spaces or dashes allowed, a known issuer prefix (ISO/IEC 7812), Luhn |
|
||||
| Any | IBAN | Checked | country code, length for that country, ISO 13616 mod 97 |
|
||||
| Any | SWIFT/BIC | Needs a word | 8 or 11 characters, a valid country code in positions 5–6 |
|
||||
| Any | Email addresses, in bulk | Checked | a count of distinct addresses (a customer list leaving), not one address |
|
||||
| Any | Phone numbers, in bulk | Needs a word | a count of distinct numbers in international or national form |
|
||||
| Any | Date of birth | Needs a word | a date beside "born", "DOB", "date of birth" and their translations |
|
||||
| Any | Passport number | Needs a word | the formats of the issuing countries in this table |
|
||||
| Any | Private key | Checked | a PEM or OpenSSH private-key block |
|
||||
| Any | Cloud and service credentials | Checked | the published prefixes and lengths: AWS access key IDs, GitHub tokens, Slack tokens, Stripe live secret keys, Google API keys |
|
||||
| US | Social Security number | Checked | `AAA-GG-SSSS`, or nine digits with a word; never area 000, 666 or 9xx, group 00, serial 0000 |
|
||||
| US | ITIN | Checked | 9XX-GG-SSSS with the IRS's group ranges |
|
||||
| US | EIN | Needs a word | a valid IRS prefix and seven digits |
|
||||
| US | Bank routing number (ABA) | Checked | nine digits, a valid Federal Reserve prefix, the 3-7-1 checksum |
|
||||
| US | Driver's license | Needs a word | each state's published format |
|
||||
| US | Medicare Beneficiary Identifier | Checked | CMS's 11-character pattern and excluded letters |
|
||||
| US | National Provider Identifier | Checked | ten digits, Luhn over the `80840` prefix |
|
||||
| US | DEA registration number | Checked | two letters, seven digits, DEA's check digit |
|
||||
| UK | National Insurance number | Checked | two letters (HMRC's excluded prefixes), six digits, A–D |
|
||||
| UK | NHS number | Checked | ten digits, mod 11 |
|
||||
| UK | Unique Taxpayer Reference | Needs a word | ten digits |
|
||||
| Canada | Social Insurance Number | Checked | nine digits, Luhn |
|
||||
| Australia | Tax File Number | Checked | weighted mod 11 |
|
||||
| Australia | Medicare number | Checked | ten digits, weighted check digit |
|
||||
| EU | Germany: tax ID (Steuer-ID) | Checked | eleven digits, ISO 7064 MOD 11,10 |
|
||||
| EU | Germany: ID card number | Checked | nine characters, the 7-3-1 check digit |
|
||||
| EU | France: social security number (NIR) | Checked | fifteen characters, mod 97 key |
|
||||
| EU | Spain: DNI and NIE | Checked | eight digits and the mod 23 letter |
|
||||
| EU | Italy: codice fiscale | Checked | sixteen characters, the check letter |
|
||||
| EU | Netherlands: BSN | Checked | nine digits, the eleven test |
|
||||
| EU | Belgium: national number | Checked | eleven digits, mod 97 |
|
||||
| EU | Poland: PESEL | Checked | eleven digits, weighted check digit |
|
||||
| EU | Sweden: personnummer | Checked | a date, three digits and a Luhn check digit |
|
||||
| EU | Denmark: CPR number | Needs a word | a valid date and four digits |
|
||||
| EU | Finland: personal identity code | Checked | a date, a century sign, three digits, the mod 31 character |
|
||||
| EU | Ireland: PPS number | Checked | seven digits, one or two letters, mod 23 |
|
||||
| EU | Portugal: NIF | Checked | nine digits, mod 11 |
|
||||
| EU | Austria: social insurance number | Checked | ten digits, weighted check digit |
|
||||
| Europe | Norway: national identity number | Checked | eleven digits, two mod 11 check digits |
|
||||
| Europe | Switzerland: AHV number | Checked | `756`, then ten digits, EAN-13 check |
|
||||
| Asia | India: Aadhaar | Checked | twelve digits, Verhoeff |
|
||||
| Asia | India: PAN | Needs a word | five letters, four digits, a letter |
|
||||
| Asia | China: resident ID | Checked | eighteen characters, ISO 7064 MOD 11-2 |
|
||||
| Asia | Japan: My Number | Checked | twelve digits, weighted check digit |
|
||||
| Asia | Singapore: NRIC and FIN | Checked | a letter, seven digits, the check letter |
|
||||
| Asia | South Korea: resident registration number | Needs a word | thirteen digits with a valid date |
|
||||
| Americas | Brazil: CPF and CNPJ | Checked | two mod 11 check digits |
|
||||
| Americas | Mexico: CURP | Checked | eighteen characters, the check digit |
|
||||
| Africa | South Africa: ID number | Checked | thirteen digits with a valid date, Luhn |
|
||||
| Any | Word list | — | a list the organization maintains, counted |
|
||||
| Any | Pattern | — | the organization's own regular expression, counted |
|
||||
|
||||
Some protected data has no number to find: health conditions, religion,
|
||||
union membership, sexual orientation, criminal records. No detector claims to
|
||||
recognize those; a **word list** is how an organization covers its own terms
|
||||
for them, and the console offers editable starting lists (medical terms,
|
||||
diagnosis codes as ICD-10 patterns) rather than presenting them as detection.
|
||||
|
||||
**Templates**, so a policy doesn't pick forty detectors one at a time. Each
|
||||
is a named set, editable once added, and named for what it finds, never for a
|
||||
law: *Payment cards and bank accounts*, *US personal identifiers*, *UK
|
||||
personal identifiers*, *EU national identifiers*, *Health identifiers* (the NHS number, the US Medicare Beneficiary
|
||||
Identifier, NPI and DEA numbers, the Australian Medicare number), *Credentials and keys*, *Contact lists*.
|
||||
|
||||
The catalog grows by table entry: a new identifier is one row, one check
|
||||
function and its published examples as tests.
|
||||
|
||||
What the detectors read: the subject, every text and HTML part (as text),
|
||||
and attachments whose detected type is text (`text/*`, CSV, JSON, XML).
|
||||
Office documents (DOCX, XLSX, PPTX, ODT, ODS, ODP) are read too (settled
|
||||
answer 2): they're ZIP files of XML, unpacked and read in-house with limits on
|
||||
unpacked size and entry count. PDF files count as **can't be inspected** in
|
||||
this version, so a policy can still act on them. Inspection stops at
|
||||
a limit per message (proposed 10 MB of text), and what's past it counts as
|
||||
can't be inspected too.
|
||||
|
||||
### 2.4 Actions
|
||||
|
||||
**Transport actions** (both kinds): add a disclaimer (text and HTML, top or
|
||||
bottom, once per thread), add or remove a header, prefix the subject, add a
|
||||
recipient (a copy), redirect to other recipients, refuse with a text, send
|
||||
through a chosen route (an existing `x:MtaVirtualQueue`).
|
||||
|
||||
**DLP actions**, exactly one per DLP rule:
|
||||
|
||||
| Action | Sender sees | Message |
|
||||
|---|---|---|
|
||||
| **Block** | SMTP `550 5.7.1` with the rule's notice text; in the webmail, the notice in the send dialog | Not accepted; nothing is stored |
|
||||
| **Warn** | The notice and, once they give a reason, can send anyway | Sent after an override; the reason is audited |
|
||||
| **Hold for review** | Accepted with "held for review"; a notice mail from the server if the rule asks | Waits in the queue for a reviewer |
|
||||
|
||||
When several DLP rules match, the strictest wins: block, then hold, then warn.
|
||||
|
||||
### 2.5 Warn and override
|
||||
|
||||
**Webmail (JMAP).** The first submission fails with a new error type,
|
||||
`inbuxa:dlpWarning`, carrying the matched rules' names and notice texts (never
|
||||
the matched text). The webmail shows them and asks for a reason; it
|
||||
resubmits with `inbuxa:dlpOverride: {"reason": "..."}` on the
|
||||
`EmailSubmission` create (capability `urn:inbuxa:jmap`). The server passes the
|
||||
reason into the local SMTP session as trusted session data, not as a header,
|
||||
so it can't be forged from the message. An override covers only the rules
|
||||
that warned; if a block or hold rule also matches, that still applies.
|
||||
|
||||
**Mail apps (SMTP).** They show whatever text the server returns, so the
|
||||
refusal says how to override: `550 5.7.1 <notice>. To send anyway, start the
|
||||
subject with [override: your reason]`. On the next attempt the engine strips
|
||||
the tag before DKIM signing, and records the reason (settled answer 1).
|
||||
|
||||
A block's refusal uses the same error path with `inbuxa:dlpBlocked` in the
|
||||
webmail.
|
||||
|
||||
### 2.6 Hold for review
|
||||
|
||||
A held message is queued normally but **not scheduled**: its due time is set
|
||||
to never, and a review record `inbuxa:HeldMessage` (queue id, sender,
|
||||
recipients, subject, size, matched rules and counts, held at, expires at) is
|
||||
written in inbuxa's own subspace. The queue's stored format is untouched, so a
|
||||
node still on the previous version during a rolling upgrade reads the message
|
||||
fine and simply never sends it.
|
||||
|
||||
The sender gets `250 2.0.0 Held for review`, and the rule may send them a
|
||||
notice mail. The message stays in their Sent folder as usual.
|
||||
|
||||
A reviewer, under **Management › Compliance › Held mail**:
|
||||
|
||||
- sees the list, and opens one to read it (each opening is audited, like any
|
||||
access to someone else's mail);
|
||||
- **releases** it with a reason: it's scheduled at once and delivered as
|
||||
normal;
|
||||
- **rejects** it with a reason: it's removed from the queue and the sender
|
||||
gets a notice with the reviewer's note, not their name.
|
||||
|
||||
Unreviewed mail is rejected back to the sender after **7 days**, with a
|
||||
notice (settled answer 5); the number is a setting. Emails › Queue shows held mail as held and refuses **Retry** on it, so
|
||||
nobody can deliver it around the review. The sender can't unsend it either
|
||||
once it's held (the webmail says so).
|
||||
|
||||
Held messages count against no one's quota. Each held message and each
|
||||
decision is in the audit log.
|
||||
|
||||
### 2.7 What's recorded
|
||||
|
||||
Every DLP match writes one audit record: actor **DLP** (a system actor),
|
||||
target the message (queue id, sender, recipient domains), the rules and each
|
||||
detector's count, the action, and for an override the sender's reason.
|
||||
**Never the matched text**: the log would otherwise become a second copy of
|
||||
what the policy was keeping in. A card number isn't written, even masked.
|
||||
|
||||
Transport rules that change a message record the rule and action the same way.
|
||||
Unmatched mail writes nothing.
|
||||
|
||||
### 2.8 Permissions and who does what
|
||||
|
||||
New permissions (ids from 674):
|
||||
|
||||
| Permission | Gives |
|
||||
|---|---|
|
||||
| `sysMailRuleGet` / `Update` | See / change transport rules |
|
||||
| `sysDlpPolicyGet` / `Update` | See / change DLP rules |
|
||||
| `sysDlpReviewGet` | See held mail and open it |
|
||||
| `sysDlpReviewUpdate` | Release or reject held mail |
|
||||
|
||||
**Administrator** has all. **Compliance Officer** (server-level) has
|
||||
`sysDlpPolicyGet`, `sysDlpReviewGet` and `sysDlpReviewUpdate`: officers see
|
||||
the rules and review held mail, administrators edit (settled answer 4), so
|
||||
"officers change no setting" stays true. Tenant roles get none: rules and the
|
||||
review queue are server-level (settled answer 3).
|
||||
|
||||
### 2.9 Privacy catalog
|
||||
|
||||
New entries, so the catalog check passes: `inbuxa:MailRule` (administrator
|
||||
identities), `inbuxa:HeldMessage` (sender, recipients, subject: held until
|
||||
reviewed or expired, then removed), and the held message's content in the
|
||||
queue (content, the sender's and correspondents'). The DLP audit records are
|
||||
covered by the audit log's entry.
|
||||
|
||||
### 2.10 Mixed versions and clusters
|
||||
|
||||
Rules and review records live in the shared data store, so every node sees the
|
||||
same ones. During a rolling upgrade a node still on the old version doesn't
|
||||
check mail against rules; the Overview can't tell. The console says so when
|
||||
nodes report different versions, and the release notes say to enable DLP
|
||||
rules after every node is upgraded.
|
||||
|
||||
### 2.11 Cost
|
||||
|
||||
Rules are compiled once when they change (regexes, word lists as an
|
||||
Aho-Corasick automaton) and shared by every session. Detectors only run on
|
||||
mail that some enabled rule could match (direction, sender, recipient checks
|
||||
first). The inspection limit caps the worst case.
|
||||
|
||||
## 3. Console
|
||||
|
||||
- **Management › Compliance › Data loss prevention**: DLP rules, a form with
|
||||
conditions, exceptions, detectors and the action; the notice text; what
|
||||
matched in the last 30 days (from the audit log).
|
||||
- **Management › Compliance › Held mail**: the review queue.
|
||||
- **Settings › Mail flow › Rules** (with the settings reorganization's
|
||||
approved order): transport rules, same form, ordered, with **Stop
|
||||
processing**.
|
||||
|
||||
Every form previews the rule in words ("If a recipient is outside and the
|
||||
message contains 5 or more card numbers, hold it for review").
|
||||
|
||||
## 4. Webmail (ihasmail-inbuxa)
|
||||
|
||||
- A warning dialog: the notice, a reason field, **Send anyway** and **Edit
|
||||
message**.
|
||||
- A block dialog with the notice.
|
||||
- A held message shows as **Held for review** in Sent, and its undo is gone.
|
||||
|
||||
## 5. Tests
|
||||
|
||||
Unit: each detector against valid and near-miss numbers (Luhn-failing cards,
|
||||
IBANs with a wrong check, SSN areas 000/666/9xx), word lists, regexes, the
|
||||
inspection limit, the can't-be-inspected cases, rule order and stop
|
||||
processing. Integration (`tests/src/smtp/`, `tests/src/jmap/`): block, warn
|
||||
and override over SMTP and JMAP, hold then release and reject, expiry,
|
||||
Retry refused on held mail, audit records carrying no matched text, a
|
||||
message queued by a node without the engine (held message format unchanged).
|
||||
|
||||
## 6. Phases
|
||||
|
||||
1. This spec, approved.
|
||||
2. Engine, conditions, the detector framework and the catalog in §2.3,
|
||||
Office text extraction, transport actions; DLP block and warn over SMTP and
|
||||
JMAP; audit records; catalog entries. The detector catalog may land in
|
||||
more than one PR (by region), each with its published test vectors.
|
||||
3. Hold for review: review records, release, reject, expiry, queue guard.
|
||||
4. Console: DLP rules, held mail, mail flow rules.
|
||||
5. Webmail dialogs; docs; a row in `inbuxa-drafts/divergence-log.md`.
|
||||
|
||||
Each phase is its own PR with tests; releases as John decides.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Mail a user's own filter forwards automatically to an outside address isn't
|
||||
checked in this version (it leaves as generated mail, not a submission).
|
||||
- What a mail app keeps in its own Sent folder, or sends through another
|
||||
server, is outside what this server sees.
|
||||
- Detectors find formats, not meaning: a card number in a harmless test
|
||||
message matches; a number written in words doesn't.
|
||||
|
||||
## Settled
|
||||
|
||||
John, 2026-09-28, all six as recommended, with 6 widened:
|
||||
|
||||
1. **Override from mail apps**: the `[override: reason]` subject tag, stripped
|
||||
before sending (§2.5).
|
||||
2. **Office and PDF**: Office documents are read in this version; PDF counts
|
||||
as can't be inspected (§2.3).
|
||||
3. **Tenants**: server-level rules only, with a Tenant condition (§2.2, §2.8).
|
||||
4. **Who edits DLP rules**: administrators; compliance officers see the rules
|
||||
and review held mail (§2.8).
|
||||
5. **Unreviewed held mail**: rejected back to the sender after 7 days, with a
|
||||
notice (§2.6).
|
||||
6. **Detectors**: the five proposed "and any other recognized and protected
|
||||
PII", which §2.3 turns into a catalog of identifiers with published formats
|
||||
and checks, plus templates. Data with no number to find (health,
|
||||
religion...) is covered by word lists, not claimed as detection.
|
||||
@@ -407,11 +407,7 @@ retention is (not a field on `x:TracerLog`, which is also stored inside
|
||||
`x:Bootstrap` with fields after it, so a new field would change that
|
||||
object's stored format); new installs 30 days, existing servers keep every
|
||||
file as today. D5 is built after the v0.16.24 import lands, on its reworked
|
||||
spam-rules loader, which keeps each blocklist's on/off state. D5 built after the import: a new install's first boot leaves a note
|
||||
(`S` `n`), and the rules update, once the bundled rules are in, switches
|
||||
`STWT_MSBL_EBL_EMAIL` off and forgets the note; the loader keeps that
|
||||
switch through later updates. An existing server has no note and keeps
|
||||
every blocklist as it is.
|
||||
spam-rules loader, which keeps each blocklist's on/off state.
|
||||
|
||||
| # | Change | Trade-off |
|
||||
|---|---|---|
|
||||
|
||||
Binary file not shown.
@@ -847,7 +847,7 @@ default = "none"
|
||||
whose = ["correspondent"]
|
||||
where = ["memory"]
|
||||
scope = "server"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:DmarcTroubleshoot".properties]
|
||||
ehloDomain = ["network"]
|
||||
ipRevPtr = ["network"]
|
||||
@@ -1266,7 +1266,7 @@ default = "none"
|
||||
whose = ["correspondent", "holder", "administrator"]
|
||||
where = ["log-file"]
|
||||
scope = "server"
|
||||
retention = { setting = "inbuxa:LogSettings.keepForDays" }
|
||||
retention = "unbounded"
|
||||
[object."x:Log".properties]
|
||||
details = ["network", "identifier", "metadata"]
|
||||
timestamp = ["metadata"]
|
||||
@@ -1689,7 +1689,7 @@ default = "none"
|
||||
whose = ["correspondent"]
|
||||
where = ["memory"]
|
||||
scope = "server"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:SpamClassify".properties]
|
||||
authenticatedAs = ["identifier"]
|
||||
ehloDomain = ["network"]
|
||||
@@ -1810,7 +1810,7 @@ default = "none"
|
||||
whose = ["holder", "correspondent"]
|
||||
where = ["data-store"]
|
||||
scope = "tenant"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:TaskCalendarItipContents".properties]
|
||||
from = ["identifier"]
|
||||
iCalendarData = ["content"]
|
||||
@@ -1825,7 +1825,7 @@ default = "none"
|
||||
whose = ["holder"]
|
||||
where = ["data-store"]
|
||||
scope = "tenant"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:TaskDestroyAccount".properties]
|
||||
accountName = ["identifier"]
|
||||
|
||||
@@ -1852,7 +1852,7 @@ default = "none"
|
||||
whose = ["holder"]
|
||||
where = ["data-store"]
|
||||
scope = "tenant"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:TaskMergeThreads".properties]
|
||||
messageIds = ["metadata"]
|
||||
threadName = ["content"]
|
||||
@@ -1886,7 +1886,7 @@ default = "none"
|
||||
whose = ["holder"]
|
||||
where = ["data-store"]
|
||||
scope = "tenant"
|
||||
retention = "object-life"
|
||||
retention = "unbounded"
|
||||
[object."x:TaskStatusRetry".properties]
|
||||
failureReason = ["content"]
|
||||
|
||||
@@ -2040,23 +2040,30 @@ default = "none"
|
||||
|
||||
[object."x:TracerLog"]
|
||||
default = "none"
|
||||
|
||||
whose = ["correspondent", "holder", "administrator"]
|
||||
where = ["log-file"]
|
||||
scope = "server"
|
||||
retention = "unbounded"
|
||||
[object."x:TracerLog".properties]
|
||||
path = ["metadata"]
|
||||
|
||||
[object."x:TracerOtelGrpc"]
|
||||
# Configuration: the "webhooks" and "otel-tracer" sources carry what it sends
|
||||
default = "none"
|
||||
|
||||
whose = ["correspondent", "holder", "administrator"]
|
||||
where = ["external"]
|
||||
scope = "server"
|
||||
retention = "receiver"
|
||||
[object."x:TracerOtelGrpc".properties]
|
||||
endpoint = ["network"]
|
||||
httpAuth = ["credential"]
|
||||
httpHeaders = ["credential"]
|
||||
|
||||
[object."x:TracerOtelHttp"]
|
||||
# Configuration: the "webhooks" and "otel-tracer" sources carry what it sends
|
||||
default = "none"
|
||||
|
||||
whose = ["correspondent", "holder", "administrator"]
|
||||
where = ["external"]
|
||||
scope = "server"
|
||||
retention = "receiver"
|
||||
[object."x:TracerOtelHttp".properties]
|
||||
endpoint = ["network"]
|
||||
httpAuth = ["credential"]
|
||||
@@ -2088,9 +2095,11 @@ usedDiskQuota = ["metadata"]
|
||||
default = "none"
|
||||
|
||||
[object."x:WebHook"]
|
||||
# Configuration: the "webhooks" and "otel-tracer" sources carry what it sends
|
||||
default = "none"
|
||||
|
||||
whose = ["correspondent", "holder", "administrator"]
|
||||
where = ["external"]
|
||||
scope = "server"
|
||||
retention = "receiver"
|
||||
[object."x:WebHook".properties]
|
||||
httpAuth = ["credential"]
|
||||
httpHeaders = ["credential"]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU
|
||||
r0pqQlntxFWW4X3bTLq57ObxRipXJXdzjsL9cyzz2Bo
|
||||
@@ -1,10 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
@@ -289,30 +286,6 @@ pub async fn test(test: &TestServer) {
|
||||
not_valid_before + length / 2,
|
||||
task.due_timestamp() as i64
|
||||
);
|
||||
|
||||
// inbuxa: renewing while a valid certificate already covers the names
|
||||
// (say, one stored by hand before the domain went automatic) schedules
|
||||
// the renewal for when it falls due. It used to end the task for good.
|
||||
let rescheduled = test
|
||||
.server
|
||||
.acme_renew(tls_domain_id)
|
||||
.await
|
||||
.ok()
|
||||
.expect("a renewal that isn't due yet to be rescheduled, not to fail");
|
||||
assert!(
|
||||
matches!(
|
||||
rescheduled.as_slice(),
|
||||
[Task::AcmeRenewal(TaskDomainManagement { domain_id, .. })] if *domain_id == tls_domain_id
|
||||
),
|
||||
"Expected one rescheduled ACME renewal, found: {:?}",
|
||||
rescheduled
|
||||
);
|
||||
assert_eq!(
|
||||
rescheduled[0].due_timestamp() as i64,
|
||||
not_valid_before + length / 2,
|
||||
"The rescheduled renewal should fall due when the certificate does"
|
||||
);
|
||||
|
||||
account.registry_destroy_all(ObjectType::Certificate).await;
|
||||
account.registry_destroy_all(ObjectType::Task).await;
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
@@ -61,7 +58,7 @@ _995._tcp.pop3.example.org. IN TLSA 2 1 1
|
||||
_dmarc.example.org. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
|
||||
_mta-sts.example.org. IN TXT "v=STSv1; id=12942536112359691423"
|
||||
_smtp._tls.example.org. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
|
||||
_ua-auto-config.example.org. IN TXT "v=UAAC1; a=sha256; d=ZZ35kyyCO86LM5UUTecwutQ8B+0XdZ3wJnjoYXnH0Wk="
|
||||
_ua-auto-config.example.org. IN TXT "v=UAAC1; a=sha256; d=9X2mMgWAc10oSPuRKZSFBwPXEQpnxkS7SXPO8PC7euM="
|
||||
_validation-persist.example.org. IN TXT "pebble.letsencrypt.org; accounturi=REDACTED"
|
||||
dummy-v1-ed25519._domainkey.example.org. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=REDACTED"
|
||||
dummy-v1-rsa._domainkey.example.org. IN TXT "v=DKIM1; k=rsa; h=sha256; p=REDACTED"
|
||||
|
||||
@@ -274,45 +274,6 @@ pub async fn test(test: &mut TestServer) {
|
||||
.unwrap();
|
||||
assert_eq!(trace["retention"]["days"], json!(7), "{trace}");
|
||||
|
||||
// D5: a new install's first rules leave the hashed-address blocklist
|
||||
// off, once; an existing server (no note) keeps it as it is
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"x:SpamDnsblServer/set",
|
||||
json!({"create": {"m": {"@type": "Email", "name": "STWT_MSBL_EBL_EMAIL", "enable": true,
|
||||
"zone": {"else": "hash(email, 'sha1') + '.ebl.msbl.org'", "match": {}},
|
||||
"tag": {"else": "'MSBL_EBL'", "match": {}}}}}),
|
||||
)
|
||||
.await;
|
||||
let msbl = response["created"]["m"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{response}"))
|
||||
.to_string();
|
||||
let registry = test.server.registry();
|
||||
let store = test.server.store();
|
||||
assert!(
|
||||
!common::manager::spam_rules::apply_new_install(registry, store).await.unwrap(),
|
||||
"no note, no change"
|
||||
);
|
||||
let enabled = |response: &Value| response["list"][0]["enable"].clone();
|
||||
let (_, response) = call(&admin, "x:SpamDnsblServer/get", json!({"ids": [msbl]})).await;
|
||||
assert_eq!(enabled(&response), json!(true));
|
||||
let (_, response) = call(&officer, "inbuxa:DataInventory/get", json!({"ids": null})).await;
|
||||
assert!(
|
||||
response["list"][0]["processors"]
|
||||
.as_array()
|
||||
.is_some_and(|p| p.iter().any(|p| p["host"] == "ebl.msbl.org")),
|
||||
"the zone, not the hash: {response}"
|
||||
);
|
||||
common::manager::spam_rules::mark_new_install(store).await.unwrap();
|
||||
assert!(common::manager::spam_rules::apply_new_install(registry, store).await.unwrap());
|
||||
let (_, response) = call(&admin, "x:SpamDnsblServer/get", json!({"ids": [msbl]})).await;
|
||||
assert_eq!(enabled(&response), json!(false), "{response}");
|
||||
assert!(
|
||||
!common::manager::spam_rules::apply_new_install(registry, store).await.unwrap(),
|
||||
"the note works once"
|
||||
);
|
||||
|
||||
// A tenant can still be deleted: its unused role goes with it
|
||||
let spare = admin
|
||||
.registry_create_object(Tenant {
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user