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.
229 lines
8.1 KiB
Rust
229 lines
8.1 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! inbuxa: the spam filter rules that ship with the server.
|
|
//!
|
|
//! Upstream fetches its latest published rules from GitHub at run time, so
|
|
//! scoring changes with a release nobody here tested and depends on reaching
|
|
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
|
|
//! and license) and uses it whenever no other source is configured. The rules
|
|
//! URL remains an operator override (`https://` or `file://`).
|
|
//!
|
|
//! Loading rules adds what's missing and brings an existing rule up to date,
|
|
//! but never touches one an admin edited: every object an update writes is
|
|
//! fingerprinted, and one that no longer matches its fingerprint is kept as
|
|
//! it is. Tags (scores) are never replaced. Switching a rule on or off isn't
|
|
//! an edit, and is kept either way. They load on first boot, and again
|
|
//! whenever the bundled rules differ from the ones last applied, so an
|
|
//! upgrade brings new tags (the AI classifier's `LLM_*` scores, say) and
|
|
//! fixed rules to an install that already had rules.
|
|
|
|
use registry::{schema::prelude::ObjectType, types::EnumImpl};
|
|
use std::io::Read;
|
|
use store::{
|
|
SUBSPACE_INBUXA, Store, ValueKey,
|
|
write::{AnyClass, BatchBuilder, ValueClass},
|
|
};
|
|
use trc::AddContext;
|
|
|
|
/// The version of spam-filter the embedded rules come from.
|
|
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
|
|
|
|
/// What's recorded once the bundled rules are loaded: their version, then the
|
|
/// fork's own generation of the update, so a change to how an update applies
|
|
/// runs it once more. Generation 2 fingerprints (upstream v0.16.24).
|
|
pub const BUNDLED_SPAM_RULES_APPLIED: &str = "3.0.2+2";
|
|
|
|
static BUNDLED_SPAM_RULES: &[u8] =
|
|
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
|
|
|
|
/// Upstream's default rules source, the value every install created before
|
|
/// the rules were bundled has saved. Read only to treat it as unset.
|
|
const LEGACY_DEFAULT_URL: &str = "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
|
|
|
|
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
|
|
/// setting and upstream's old default both mean the bundled rules.
|
|
pub fn rules_url(configured: Option<String>) -> Option<String> {
|
|
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
|
|
}
|
|
|
|
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
|
|
pub fn bundled_rules() -> Result<Vec<u8>, String> {
|
|
let mut json = Vec::new();
|
|
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
|
|
.read_to_end(&mut json)
|
|
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
|
|
Ok(json)
|
|
}
|
|
|
|
fn applied_key() -> ValueClass {
|
|
ValueClass::Any(AnyClass {
|
|
subspace: SUBSPACE_INBUXA,
|
|
key: b"Sr".to_vec(),
|
|
})
|
|
}
|
|
|
|
fn fingerprint_key(object: ObjectType, id: u64) -> ValueClass {
|
|
let mut key = b"Sf".to_vec();
|
|
key.extend_from_slice(object.as_str().as_bytes());
|
|
key.push(0);
|
|
key.extend_from_slice(&id.to_be_bytes());
|
|
ValueClass::Any(AnyClass {
|
|
subspace: SUBSPACE_INBUXA,
|
|
key,
|
|
})
|
|
}
|
|
|
|
/// The fingerprint of what a rules update last wrote to this object, if one
|
|
/// did.
|
|
pub async fn fingerprint(data: &Store, object: ObjectType, id: u64) -> trc::Result<Option<String>> {
|
|
data.get_value::<String>(ValueKey::from(fingerprint_key(object, id)))
|
|
.await
|
|
.caused_by(trc::location!())
|
|
}
|
|
|
|
/// Records the fingerprint of what a rules update wrote to this object.
|
|
pub async fn set_fingerprint(
|
|
data: &Store,
|
|
object: ObjectType,
|
|
id: u64,
|
|
fingerprint: &str,
|
|
) -> trc::Result<()> {
|
|
let mut batch = BatchBuilder::new();
|
|
batch.set(fingerprint_key(object, id), fingerprint.as_bytes().to_vec());
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// The bundled rules last loaded into the registry, if any
|
|
/// ([`BUNDLED_SPAM_RULES_APPLIED`]'s form).
|
|
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
|
|
data.get_value::<String>(ValueKey::from(applied_key()))
|
|
.await
|
|
.caused_by(trc::location!())
|
|
}
|
|
|
|
/// Records that the bundled rules have been loaded.
|
|
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
|
|
let mut batch = BatchBuilder::new();
|
|
batch.set(applied_key(), version.as_bytes().to_vec());
|
|
data.write(batch.build_all())
|
|
.await
|
|
.caused_by(trc::location!())
|
|
.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::*;
|
|
|
|
#[test]
|
|
fn upstream_default_and_empty_mean_bundled() {
|
|
assert_eq!(rules_url(None), None);
|
|
assert_eq!(rules_url(Some(String::new())), None);
|
|
assert_eq!(rules_url(Some(" ".into())), None);
|
|
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
|
|
assert_eq!(
|
|
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
|
|
Some("file:///srv/rules.json.gz")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn applied_marker_names_the_bundled_version() {
|
|
assert!(
|
|
BUNDLED_SPAM_RULES_APPLIED
|
|
.strip_prefix(BUNDLED_SPAM_RULES_VERSION)
|
|
.is_some_and(|generation| generation.starts_with('+'))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bundled_rules_parse_and_score_the_ai_tags() {
|
|
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
|
|
let tags = rules["SpamTag"].as_array().unwrap();
|
|
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
|
|
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
|
|
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
|
|
}
|
|
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
|
|
}
|
|
}
|