Compare commits

..
20 Commits
Author SHA1 Message Date
jcoffey-dev 213c7f0362 Merge pull request 'Release 2026.9.28.5' (#98) from release/2026.9.28.5-pr into main
ci / fork-checks (push) Successful in 15s
publish / version (push) Successful in 12s
ci / build (push) Canceled after 7m39s
publish / publish-amd64 (push) Successful in 30m34s
publish / release (push) Successful in 30s
publish / publish-arm64 (push) Successful in 57m44s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 23s
2026-09-29 00:05:39 +00:00
jcoffey-dev f7fb115a0f Merge pull request 'Spec: data loss prevention and mail flow rules' (#97) from spec/dlp-mail-flow-rules into main
ci / fork-checks (push) Successful in 44s
ci / build (push) Canceled after 6m41s
2026-09-28 23:58:53 +00:00
jcoffey-dev 3199a6f1fb Release 2026.9.28.5
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 7m37s
2026-09-28 16:57:47 -07:00
jcoffey-dev 2b45a2e412 Spec: approved
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 16m58s
2026-09-28 16:41:38 -07:00
jcoffey-dev 3fadf82909 Spec: John's answers, and the detector catalog answer 6 asks for
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 7m23s
All six settled as recommended. Answer 6 ("and any other recognized and protected PII") becomes a catalog of identifiers with published formats and checks, grouped by region, each either checked by its check digit or counted only beside a corroborating word, plus templates named for what they find. Data with no number to find is covered by word lists and not claimed as detection. Office documents are read; PDF counts as can't be inspected.
2026-09-28 16:04:31 -07:00
jcoffey-dev 2a851ea230 Spec: data loss prevention and mail flow rules
ci / fork-checks (pull_request) Successful in 46s
ci / build (pull_request) Successful in 4m52s
Phase 1: one native rule engine at DATA, after the system Sieve script,
for both DLP policies and transport rules. DLP checks outgoing mail
with counted detectors (payment cards, IBAN, US SSN, word lists,
patterns) and blocks, warns with an audited override, or holds for
review. Held mail stays in the queue unscheduled, with its own review
record, so the queue's stored format is unchanged. Matches go to the
audit log without the matched text. Six questions for John at the end.
2026-09-28 15:57:53 -07:00
jcoffey-dev 0502eb45ed Merge pull request 'DNS test: expect the account-configuration digest inbuxa publishes' (#96) from fix/dns-test-pacc-digest into main
ci / fork-checks (push) Successful in 34s
ci / build (push) Successful in 24m56s
2026-09-28 22:48:02 +00:00
jcoffey-dev 11ba361c8c DNS test: expect the account-configuration digest inbuxa publishes
ci / fork-checks (pull_request) Successful in 55s
ci / build (pull_request) Successful in 18m46s
The automation suite's DNS test compared the published zone with one
copied from upstream v0.16.22. Its _ua-auto-config record carries a
SHA-256 of the account-configuration (PACC) document, and that document
names the provider as the brand, which the rebrand changed. The digest
the server publishes is right; the expected zone still held upstream's.

With the new digest the whole automation suite passes: ACME (including
the not-due reschedule check), DKIM, DNS and RFC 2136. It had been
failing at this point on main since the rebrand.
2026-09-28 15:29:06 -07:00
jcoffey-dev ba75ab4ecc Merge pull request 'ACME: a renewal that isn't due yet is rescheduled, not failed for good' (#93) from fix/acme-not-due-reschedule into main
ci / fork-checks (push) Successful in 18s
ci / build (push) Successful in 42m1s
2026-09-28 21:52:17 +00:00
jcoffey-dev afffa0fc96 Merge pull request 'Try a directory before anything signs in through it' (#95) from feature/directory-test into main
ci / fork-checks (push) Canceled after 21s
ci / build (push) Canceled after 21s
2026-09-28 21:51:56 +00:00
jcoffey-dev 32b22d0828 Merge pull request 'Schema: each expression field says which values and variables it accepts' (#91) from feature/expression-schema into main
ci / fork-checks (push) Canceled after 20s
ci / build (push) Canceled after 20s
2026-09-28 21:51:34 +00:00
jcoffey-dev 558b776e9f Merge pull request 'Release 2026.9.28.4' (#94) from release/2026.9.28.4-pr into main
ci / fork-checks (push) Successful in 15s
publish / version (push) Successful in 2m18s
publish / publish-amd64 (push) Successful in 29m39s
publish / release (push) Successful in 1s
ci / build (push) Successful in 59m47s
publish / publish-arm64 (push) Successful in 44m48s
publish / binaries (push) Successful in 45s
publish / announce (push) Successful in 23s
2026-09-28 19:46:36 +00:00
jcoffey-dev ac3a63973d Try a directory before anything signs in through it
ci / fork-checks (pull_request) Successful in 59s
ci / build (pull_request) Successful in 4m5s
POST /api/directory/test takes a saved directory's id, an address and
optionally a password, and answers whether the directory opened, what a
recipient lookup of the address finds (account or group, with its
aliases, groups and name), and whether the password signs in. A wrong
password is told apart from a directory that can't be reached or is set
up wrong.

It calls the directory itself, below the sign-in path: a test never
creates or updates an account, never counts toward the sign-in ban and
doesn't depend on which domains use the directory. A password hash a
directory returns is never sent back. OIDC directories report their
discovered issuer; they take no passwords.

For server-level administrators with directory update permission. The
console's guided directory setup uses it to test a real person before
any domain is switched over.
2026-09-28 12:38:58 -07:00
jcoffey-dev e61a475859 Schema: each expression field says which values and variables it accepts
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 5m24s
The registry knows, for every expression field, the constants it may
evaluate to and the variables its conditions may read, and enforces both.
The schema served to INBUXA Admin described every one as a bare
x:Expression, so the console could offer nothing better than free text.

tools/fork/expr-schema.py reads those contexts from the generated registry
code and writes them onto each field's type as
expression: {constants, variables}. All 124 expression fields are covered.
CI runs it with --check so the schema can't drift from the registry.
2026-09-28 12:32:28 -07:00
jcoffey-dev 6c862e4971 Release 2026.9.28.4
ci / fork-checks (pull_request) Successful in 15s
ci / build (pull_request) Successful in 23m29s
The personal-data catalog and what it feeds: the data inventory and its
snapshots (#83, #89), the Compliance Officer roles (#88), the Compliance
Overview and Data Inventory menu entries (#92). Log file retention
(#87), privacy defaults for new installs (#85, #90), webhooks that send
only the events they name (#82), and upstream v0.16.24 (#84), whose
spam rules updates keep what an admin edited.

Explain: 12 settings asked about (upstream's new createdAt fields, the
certificate dates and the webhook events policy), with the release's
recommended model built locally; 705 answers carry over.
2026-09-28 12:22:34 -07:00
jcoffey-dev beb6c33e63 ACME: a renewal that isn't due yet is rescheduled, not failed for good
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 16m46s
When a valid certificate already covered a domain's names (one stored
by hand before the domain was switched to automatic, for instance), the
renewal task ended with NotDue, which the task manager treats as a
permanent failure. Nothing rescheduled it, so the certificate expired
unrenewed. The renewal now returns a new AcmeRenewal task due when the
certificate falls due, the same way a successful renewal does, and logs
it as a backoff.

The ACME integration suite checks that renewing again right after
issuance hands back one AcmeRenewal for that domain, due at the
certificate's renewal point.
2026-09-28 12:15:05 -07:00
jcoffey-dev 5a73a1183a Merge pull request 'Compliance menu: Overview and Data Inventory first' (#92) from feature/compliance-pages-nav into main
ci / fork-checks (push) Successful in 50s
ci / build (push) Successful in 33m2s
2026-09-28 18:48:52 +00:00
jcoffey-dev ac204078eb Merge pull request 'New installs start with the hashed-address blocklist off, and DNSBL zones read right' (#90) from feature/d5-msbl-off into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 16m10s
2026-09-28 18:32:41 +00:00
jcoffey-dev 728586998b Catalog: what the Overview showed wrong
ci / fork-checks (pull_request) Successful in 19s
ci / build (pull_request) Successful in 44m19s
Seen in the console's first Overview. x:DmarcTroubleshoot and
x:SpamClassify are one-off actions whose results come back in the
response, not kept: object-life, not unbounded. Tasks go when done
(only a failed one's status may stay, still unconfirmed): object-life.
x:Log reads the log files, so it follows inbuxa:LogSettings.keepForDays.
x:TracerLog, x:WebHook and the OpenTelemetry tracers are configuration:
their credential fields stay classified, but they are no longer listed
in the inventory, where they counted as always sent off the server
even with none configured; the log-file, webhooks and otel-tracer
sources carry what they send.
2026-09-28 11:03:55 -07:00
jcoffey-dev b20b09f81a New installs start with the hashed-address blocklist off, and DNSBL zones read right
ci / fork-checks (pull_request) Successful in 1m3s
ci / build (pull_request) Successful in 1h11m16s
Personal-data catalog spec, default D5 (settled 2026-09-28; built after
the v0.16.24 import's spam-rules loader landed). msbl.org's EBL is sent
a SHA-1 of every email address it's asked about. A new install's first
boot now leaves a note, and the rules update, once the bundled rules
are in, switches STWT_MSBL_EBL_EMAIL off and forgets the note, so it
happens once; the loader keeps that switch through later updates. An
existing server has no note and keeps every blocklist as it is.

Also fixes the data inventory's DNSBL endpoints: a zone is an
expression (`ip_reverse + '.zen.spamhaus.org'`, conditional branches,
`hash(email, 'sha1') + '.ebl.msbl.org'`), and the zone names are now
the quoted literals that start with a dot, from every branch, rather
than the expression's text.

Tested: unit test for the zone rule; the compliance system test (no
note, no change; the inventory lists ebl.msbl.org, not a hash; with the
note the blocklist goes off; the note works once); the system suite;
fork checks.
2026-09-28 10:21:15 -07:00
21 changed files with 968 additions and 39 deletions
+4
View File
@@ -44,6 +44,10 @@ jobs:
# schema has, and name nothing that is gone.
- if: always()
run: python3 tools/fork/privacy-check.py
# The admin reads each expression field's allowed values and variables
# from the schema; they're generated from the registry and must match it.
- if: always()
run: python3 tools/fork/expr-schema.py --check
- if: always()
run: python3 -m unittest discover -s tools/fork/tests
+4
View File
@@ -409,6 +409,10 @@ 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;
+72
View File
@@ -118,6 +118,78 @@ 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::*;
+19 -5
View File
@@ -1,7 +1,10 @@
/*
* 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::{
@@ -72,11 +75,22 @@ impl Server {
.acme_certificate_renewal_due(&domains, renew_before, now())
.await?
{
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)
)));
// 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),
})]);
}
let dns_parameters = match &domain.dns_management {
+34 -6
View File
@@ -88,13 +88,31 @@ fn days(duration: Option<&Duration>) -> Days {
}
}
/// An expression's text, if it is a plain constant (a zone, a switch).
fn expression_text(value: &Value) -> Option<String> {
/// 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) => Some(s.clone()),
Value::Object(o) => o.get("else").and_then(|v| v.as_str()).map(str::to_string),
_ => None,
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)),
_ => {}
}
}
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 {
@@ -263,7 +281,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;
if let Some(zone) = value.get("zone").and_then(expression_text) {
for zone in value.get("zone").map(zone_hosts).unwrap_or_default() {
endpoint(&mut facts, "spam-dnsbl", zone);
}
}
@@ -397,6 +415,16 @@ 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));
+11
View File
@@ -120,6 +120,17 @@ 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())
}
"account" => {
// Authenticate request
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
+156
View File
@@ -0,0 +1,156 @@
/*
* 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)
}
+1
View File
@@ -17,6 +17,7 @@ pub mod audit_log;
pub mod ai_limits;
pub mod log_settings;
pub mod data_inventory;
pub mod directory_test;
pub mod explanation;
pub mod protocol_policy;
pub mod tenant_protocol_policy;
@@ -316,6 +316,15 @@ 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![]))
}
}
+1 -1
View File
@@ -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.3"
"2026.9.28.5"
};
}
@@ -0,0 +1,407 @@
# 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.
+5 -1
View File
@@ -407,7 +407,11 @@ 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.
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.
| # | Change | Trade-off |
|---|---|---|
Binary file not shown.
+14 -23
View File
@@ -847,7 +847,7 @@ default = "none"
whose = ["correspondent"]
where = ["memory"]
scope = "server"
retention = "unbounded"
retention = "object-life"
[object."x:DmarcTroubleshoot".properties]
ehloDomain = ["network"]
ipRevPtr = ["network"]
@@ -1266,7 +1266,7 @@ default = "none"
whose = ["correspondent", "holder", "administrator"]
where = ["log-file"]
scope = "server"
retention = "unbounded"
retention = { setting = "inbuxa:LogSettings.keepForDays" }
[object."x:Log".properties]
details = ["network", "identifier", "metadata"]
timestamp = ["metadata"]
@@ -1689,7 +1689,7 @@ default = "none"
whose = ["correspondent"]
where = ["memory"]
scope = "server"
retention = "unbounded"
retention = "object-life"
[object."x:SpamClassify".properties]
authenticatedAs = ["identifier"]
ehloDomain = ["network"]
@@ -1810,7 +1810,7 @@ default = "none"
whose = ["holder", "correspondent"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
retention = "object-life"
[object."x:TaskCalendarItipContents".properties]
from = ["identifier"]
iCalendarData = ["content"]
@@ -1825,7 +1825,7 @@ default = "none"
whose = ["holder"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
retention = "object-life"
[object."x:TaskDestroyAccount".properties]
accountName = ["identifier"]
@@ -1852,7 +1852,7 @@ default = "none"
whose = ["holder"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
retention = "object-life"
[object."x:TaskMergeThreads".properties]
messageIds = ["metadata"]
threadName = ["content"]
@@ -1886,7 +1886,7 @@ default = "none"
whose = ["holder"]
where = ["data-store"]
scope = "tenant"
retention = "unbounded"
retention = "object-life"
[object."x:TaskStatusRetry".properties]
failureReason = ["content"]
@@ -2040,30 +2040,23 @@ 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"]
@@ -2095,11 +2088,9 @@ 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
View File
@@ -1 +1 @@
wDJZ1KdKs21tjHD-UBI9R_XwPEET7Iul8XEXbPlgBPE
k496pjVWlQ2p4bkZCh8agzaCKMLD4c3Z9WtoxpckYDU
+27
View File
@@ -1,7 +1,10 @@
/*
* 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;
@@ -286,6 +289,30 @@ 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;
+4 -1
View File
@@ -1,7 +1,10 @@
/*
* 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;
@@ -58,7 +61,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=9X2mMgWAc10oSPuRKZSFBwPXEQpnxkS7SXPO8PC7euM="
_ua-auto-config.example.org. IN TXT "v=UAAC1; a=sha256; d=ZZ35kyyCO86LM5UUTecwutQ8B+0XdZ3wJnjoYXnH0Wk="
_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"
+39
View File
@@ -274,6 +274,45 @@ 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 {
+15
View File
@@ -112,3 +112,18 @@ a fresh copy for each one. See `docs/spec/compat-tests.md`.
tools/fork/run-compat.sh --store /srv/inbuxa-copy/rocks.db \
--admin '[email protected]:PASSWORD' --recordings ~/compat
```
## expr-schema.py
Writes each expression field's allowed constants and variables, read from the
generated registry code, into the schema the server serves INBUXA Admin
(`resources/schema/schema.json.gz` and its checksum). The admin uses them to
offer plain choices instead of a free-text box.
```bash
tools/fork/expr-schema.py # update the schema
tools/fork/expr-schema.py --check # exit 1 if it's out of date (CI)
```
Re-run it after anything that regenerates the registry, an upstream import
included.
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Tell INBUXA Admin what each expression field accepts.
Every expression field in the registry has a context: the constants it may
evaluate to (DKIM verification: relaxed, strict or disable) and the variables
its conditions may read (sender_domain, local_port...). The server enforces
both, but the schema it serves the admin describes every expression field as
only an `x:Expression` object, so the admin can offer nothing better than a
free-text box.
This reads those contexts from the generated registry code and writes them
into the served schema, on each expression field's type:
"type": {"type": "object", "objectName": "x:Expression",
"expression": {"constants": ["relaxed", "strict", "disable"],
"variables": ["sender", "sender_domain", ...]}}
The registry code is the source, so re-run this after anything that
regenerates it (an upstream import, a new expression field). `--check` exits 1
when the schema is out of date; CI runs it.
"""
import argparse
import base64
import gzip
import hashlib
import json
import re
import sys
from pathlib import Path
root = Path(__file__).resolve().parents[2]
REGISTRY = root / 'crates' / 'registry' / 'src' / 'schema'
SCHEMA = root / 'resources' / 'schema' / 'schema.json.gz'
SCHEMA_HASH = root / 'resources' / 'schema' / 'schema.json.sha256'
def names(enum, text):
"""Variant → wire name, from the `Enum::Variant => "name"` arms."""
return dict(re.findall(rf'{enum}::(\w+) => "([^"]+)"', text))
def lists(text):
"""Every `pub static NAME: &[ExpressionConstant|Variable] = &[...]`."""
out = {}
for name, kind, body in re.findall(
r'pub static (\w+): &\[(ExpressionConstant|ExpressionVariable)\] = &\[(.*?)\];', text, re.S
):
out[name] = (kind, re.findall(rf'{kind}::(\w+)', body))
return out
def contexts(text):
"""(struct, Property variant, variables list name, constants list name) per context."""
out = []
for block in re.finditer(r'(?m)^impl (\w+) \{(.*?)^\}', text, re.S):
struct, body = block.group(1), block.group(2)
for ctx in re.finditer(r'ExpressionContext \{(.*?)\n\s*\}\n', body, re.S):
fields = ctx.group(1)
prop = re.search(r'property: Property::(\w+),', fields)
var = re.search(r'allowed_variables: (&\[\]|\w+),', fields)
const = re.search(r'allowed_constants: (&\[\]|\w+),', fields)
if prop and var and const:
out.append((struct, prop.group(1), var.group(1), const.group(1)))
return out
def build():
enums = (REGISTRY / 'enums.rs').read_text(encoding='utf-8')
enums_impl = (REGISTRY / 'enums_impl.rs').read_text(encoding='utf-8')
props = names('Property', (REGISTRY / 'properties_impl.rs').read_text(encoding='utf-8'))
const_names = names('ExpressionConstant', enums_impl)
var_names = names('ExpressionVariable', enums_impl)
known = lists(enums)
def resolve(ref, kind, wire):
if ref == '&[]':
return []
found = known.get(ref)
if not found or found[0] != kind:
raise SystemExit(f'expr-schema: no {kind} list named {ref}')
return [wire[v] for v in found[1]]
table = {}
for struct, prop, var, const in contexts((REGISTRY / 'structs_impl.rs').read_text(encoding='utf-8')):
table[(f'x:{struct}', props[prop])] = {
'constants': resolve(const, 'ExpressionConstant', const_names),
'variables': resolve(var, 'ExpressionVariable', var_names),
}
return table
def apply(schema, table):
"""Write the table into the schema; returns the (object, field) pairs it couldn't place."""
missing = []
for (obj, field), expr in sorted(table.items()):
target = schema['fields'].get(obj, {}).get('properties', {}).get(field)
if target is None or target['type'].get('objectName') != 'x:Expression':
missing.append(f'{obj}.{field}')
continue
target['type']['expression'] = expr
return missing
def encode(schema):
text = json.dumps(schema, ensure_ascii=False, separators=(',', ':'))
out = gzip.compress(text.encode('utf-8'), compresslevel=9, mtime=0)
digest = base64.urlsafe_b64encode(hashlib.sha256(out).digest()).decode().rstrip('=')
return out, digest
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--check', action='store_true', help='exit 1 if the schema is out of date')
args = parser.parse_args()
before = SCHEMA.read_bytes()
schema = json.loads(gzip.decompress(before))
table = build()
missing = apply(schema, table)
if missing:
print('expr-schema: expression contexts with no matching schema field:', file=sys.stderr)
for m in missing:
print(f' {m}', file=sys.stderr)
return 1
current = json.loads(gzip.decompress(before))
if current == schema:
print(f'expr-schema: {len(table)} expression fields, schema up to date')
return 0
if args.check:
print('expr-schema: schema is out of date; run tools/fork/expr-schema.py', file=sys.stderr)
return 1
out, digest = encode(schema)
SCHEMA.write_bytes(out)
SCHEMA_HASH.write_text(digest, encoding='utf-8')
print(f'expr-schema: wrote {len(table)} expression fields into {SCHEMA.relative_to(root)}')
return 0
if __name__ == '__main__':
sys.exit(main())