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
+1
View File
@@ -11,6 +11,7 @@ store = { path = "../store" }
trc = { path = "../trc" }
common = { path = "../common" }
registry = { path = "../registry" }
inbuxa-features = { path = "../features" }
smtp-proto = { version = "0.2", features = ["rkyv"] }
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-auth = { version = "0.13" }
+120
View File
@@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! The language model's opinion as one spam signal (AI spam classification
//! spec, AI-3, AI-4, AI-6, AI-9 to AI-13, AI-16). Any failure leaves the
//! message scored as if the classifier were off.
use crate::{SpamFilterContext, TextPart};
use common::{
Server,
config::mailstore::spamfilter::SpamFilterAction,
enterprise::llm::Call,
};
use inbuxa_features::ai::{answer, request};
use registry::schema::structs::SpamLlm;
use std::future::Future;
use types::id::Id;
pub trait SpamFilterAnalyzeLlm: Sync + Send {
fn spam_filter_analyze_llm(
&self,
ctx: &mut SpamFilterContext<'_>,
) -> impl Future<Output = ()> + Send;
}
impl SpamFilterAnalyzeLlm for Server {
async fn spam_filter_analyze_llm(&self, ctx: &mut SpamFilterContext<'_>) {
// Read now, so a change applies to the next message (AI-18)
let Ok(Some(SpamLlm::Enable(settings))) =
self.registry().object::<SpamLlm>(Id::singleton()).await
else {
return;
};
// AI-16: local users' own mail isn't sent to a model, and neither is
// mail another tag already discards or rejects
if ctx.input.authenticated_as.is_some_and(|a| !a.is_empty())
|| ctx.result.tags.iter().any(|tag| {
matches!(
self.core.spam.lists.scores.get(tag),
Some(SpamFilterAction::Discard | SpamFilterAction::Reject)
)
})
{
return;
}
let Some(model) = self.ai_model_by_id(settings.model_id).await else {
return;
};
let limits = self.ai_limits().await;
// AI-3: the subject and the message's text, nothing else
let text = ctx
.output
.text_parts
.iter()
.filter_map(|part| match part {
TextPart::Plain { text_body, .. } => Some(*text_body),
TextPart::Html { text_body, .. } => Some(text_body.as_str()),
TextPart::None => None,
})
.collect::<Vec<_>>()
.join("\n\n");
let nonce = request::nonce();
let user = request::classification_text(
&ctx.output.subject,
&text,
limits.max_content_bytes as usize,
&nonce,
);
let system = request::system_text(&settings.prompt);
// AI-9: never longer than the ceiling, whatever the model's timeout
let timeout = model
.timeout
.into_inner()
.min(limits.spam_call_ceiling.into_inner());
let Ok(reply) = self
.ai_call(Call {
model_id: settings.model_id,
model: &model,
account_id: None,
system: Some(&system),
user: &user,
temperature: settings.temperature.into_inner(),
max_tokens: request::CLASSIFY_MAX_TOKENS,
timeout,
})
.await
else {
return;
};
let categories = settings.categories.iter().cloned().collect::<Vec<_>>();
let confidence = settings.confidence.iter().cloned().collect::<Vec<_>>();
let rules = answer::Rules {
separator: &settings.separator,
pos_category: settings.response_pos_category as usize,
pos_confidence: settings.response_pos_confidence.map(|p| p as usize),
pos_explanation: settings.response_pos_explanation.map(|p| p as usize),
categories: &categories,
confidence: &confidence,
};
if let Some(classified) = answer::parse(&reply, &rules) {
ctx.result.tags.insert(classified.tag.clone());
ctx.result.llm_result = Some((
classified.tag,
classified.explanation.unwrap_or_default(),
));
ctx.result.llm_bounds = Some((
limits.spam_max_added as f32,
limits.spam_max_subtracted as f32,
));
}
}
}
+1
View File
@@ -23,6 +23,7 @@ pub mod from;
pub mod headers;
pub mod html;
pub mod init;
pub mod llm; // inbuxa: AI spam classification
pub mod ip;
pub mod messageid;
pub mod mime;
+26 -2
View File
@@ -16,6 +16,7 @@ use crate::{
recipient::SpamFilterAnalyzeRecipient, replyto::SpamFilterAnalyzeReplyTo,
rules::SpamFilterAnalyzeRules, subject::SpamFilterAnalyzeSubject,
url::SpamFilterAnalyzeUrl,
llm::SpamFilterAnalyzeLlm,
},
};
use common::{Server, config::mailstore::spamfilter::SpamFilterAction};
@@ -55,6 +56,21 @@ impl SpamFilterAnalyzeScore for Server {
let mut rbl_count = 0;
for tag in &ctx.result.tags {
// inbuxa: AI-13: the model's tag moves the score only so far, and
// never discards or rejects on its own
if inbuxa_features::ai::answer::is_llm_tag(tag) {
let (max_added, max_subtracted) = ctx.result.llm_bounds.unwrap_or((5.0, 1.0));
let score = match self.core.spam.lists.scores.get(tag) {
Some(SpamFilterAction::Allow(score)) => {
inbuxa_features::ai::answer::clamp(*score, max_added, max_subtracted)
}
_ => 0.0,
};
ctx.result.score += score;
header_len += tag.len() + 10;
results.push((tag.as_str(), score));
continue;
}
let score = match self.core.spam.lists.scores.get(tag) {
Some(SpamFilterAction::Allow(score)) => *score,
Some(SpamFilterAction::Discard) => {
@@ -139,8 +155,12 @@ impl SpamFilterAnalyzeScore for Server {
}
headers.push_str("\r\n");
if let Some((category, explanation)) = &ctx.result.llm_result {
let _ = write!(&mut headers, "X-Spam-LLM: {category} ({explanation})\r\n",);
// inbuxa: AI-15: sanitized, encoded and folded
if let Some((tag, explanation)) = &ctx.result.llm_result {
headers.push_str(&inbuxa_features::ai::answer::header(
tag,
Some(explanation.as_str()).filter(|e| !e.is_empty()),
));
}
let is_spam = final_score >= self.core.spam.scores.spam_threshold;
@@ -239,6 +259,10 @@ impl SpamFilterAnalyzeScore for Server {
// Model classification
self.spam_filter_analyze_classify(ctx).await;
// inbuxa: AI-17: the language model, after every other analysis and
// before user-defined rules, so rules can test its tags
self.spam_filter_analyze_llm(ctx).await;
// User-defined rules
self.spam_filter_analyze_rules(ctx).await;
+2
View File
@@ -113,6 +113,8 @@ pub struct SpamFilterResult {
pub rbl_url_checks: usize,
pub rbl_email_checks: usize,
pub llm_result: Option<(String, String)>,
// inbuxa: AI-13: how far the model's tag may move the score (added, subtracted)
pub llm_bounds: Option<(f32, f32)>,
}
pub struct SpamFilterContext<'x> {