AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)

The classifier sends only the subject and text, between unforgeable markers
after the operator's prompt, to an OpenAI-compatible endpoint the operator
configured; nothing is preset. Its answer maps to an LLM_ tag whose score is
clamped (+5.0, -1.0 by default) and can never discard or reject on its own;
X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed.
Failures, timeouts past the ceiling, a full slot or a paused model leave
mail flowing untagged. llm_prompt answers trusted scripts, and accounts
holding interactAi within an hourly limit. Redirects aren't followed and no
content or secret is logged. The limits live in inbuxa:AiLimits.
Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case,
whose setup no longer waits on a rules file from a developer's own path;
test 22 written as the ignored ai_compat.
This commit is contained in:
2026-09-19 00:41:00 -07:00
parent cba48cf03b
commit 9490fc4677
39 changed files with 2945 additions and 28 deletions
+8
View File
@@ -45,6 +45,8 @@ pub enum MethodObject {
MaskedEmail,
// inbuxa: deleted accounts (UD-17)
DeletedAccount,
// inbuxa: AI call limits
AiLimits,
}
impl MethodObject {
@@ -70,6 +72,7 @@ impl MethodObject {
MethodObject::Registry(_) => Capability::Stalwart,
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa,
}
}
}
@@ -245,6 +248,8 @@ impl MethodName {
(MethodFunction::Set, MethodObject::MaskedEmail) => "MaskedEmail/set",
(MethodFunction::Get, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/get",
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(method, MethodObject::Registry(obj)) => {
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
}
@@ -368,6 +373,8 @@ impl MethodName {
"MaskedEmail/set" => (MethodObject::MaskedEmail, MethodFunction::Set),
"inbuxa:DeletedAccount/get" => (MethodObject::DeletedAccount, MethodFunction::Get),
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -420,6 +427,7 @@ impl Display for MethodObject {
MethodObject::ShareNotification => "ShareNotification",
MethodObject::MaskedEmail => "MaskedEmail",
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::Registry(obj) => {
f.write_str("x:")?;
return f.write_str(obj.as_str());