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
+12 -1
View File
@@ -73,6 +73,8 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::MaskedEmail(_) => Permission::SysMaskedEmailGet,
// inbuxa: deleted accounts (UD-17)
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
// inbuxa: AI call limits, with the classifier's permissions
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -161,6 +163,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysAccountCreate,
Permission::SysAccountDestroy,
),
// inbuxa: AI call limits, with the classifier's permissions
SetRequestMethod::AiLimits(s) => validate_set(
s,
self,
Permission::SysSpamLlmUpdate,
Permission::SysSpamLlmUpdate,
Permission::SysSpamLlmUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set(
s,
self,
@@ -269,7 +279,8 @@ impl JmapAuthorization for AccessToken {
| MethodObject::VacationResponse
| MethodObject::SieveScript
| MethodObject::MaskedEmail
| MethodObject::DeletedAccount => Permission::JmapEmailChanges,
| MethodObject::DeletedAccount
| MethodObject::AiLimits => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(),
},
+17
View File
@@ -168,6 +168,9 @@ impl RequestHandler for Server {
SetResponseMethod::DeletedAccount(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AiLimits(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response);
}
@@ -316,6 +319,13 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:AiLimits/get
GetRequestMethod::AiLimits(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::ai_limits::get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::Principal(req) => {
self.principal_get(*req, access_token).await?.into()
}
@@ -550,6 +560,13 @@ impl RequestHandler for Server {
.await?
.into()
}
// inbuxa: inbuxa:AiLimits/set
SetRequestMethod::AiLimits(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::ai_limits::set(self, access_token, *req)
.await?
.into()
}
SetRequestMethod::AddressBook(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
+1
View File
@@ -415,6 +415,7 @@ impl IntermediateChangesResponse {
| MethodObject::Quota
| MethodObject::MaskedEmail
| MethodObject::DeletedAccount
| MethodObject::AiLimits
| MethodObject::Registry(_) => unreachable!(),
})
}
+190
View File
@@ -0,0 +1,190 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:AiLimits/get` and `/set`: the fork's limits on AI model calls
//! (AI spam classification spec, "Added by inbuxa-server"). Server-level:
//! a principal in a tenant can neither read nor change them (AI-27).
use common::{Server, auth::AccessToken};
use inbuxa_features::ai::limits::{self, AiLimits as Limits};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_ai_limits::{AiLimits, AiLimitsProperty as P, AiLimitsValue},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use registry::types::duration::Duration;
use types::id::Id;
type LValue = Value<'static, P, AiLimitsValue>;
const ALL: &[P] = &[
P::Id,
P::SpamMaxAdded,
P::SpamMaxSubtracted,
P::SpamCallCeiling,
P::MaxConcurrentCalls,
P::MaxContentBytes,
P::FailureBackoff,
P::UserCallsPerHour,
];
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("AI model settings are server-level."))
} else {
Ok(())
}
}
fn to_value(limits: &Limits, properties: &[P]) -> LValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(AiLimitsValue::Id(Id::singleton())),
P::SpamMaxAdded => Value::Number((limits.spam_max_added).into()),
P::SpamMaxSubtracted => Value::Number((limits.spam_max_subtracted).into()),
P::SpamCallCeiling => Value::Number((limits.spam_call_ceiling.into_inner().as_millis() as u64).into()),
P::MaxConcurrentCalls => Value::Number((limits.max_concurrent_calls).into()),
P::MaxContentBytes => Value::Number((limits.max_content_bytes).into()),
P::FailureBackoff => Value::Number((limits.failure_backoff.into_inner().as_millis() as u64).into()),
P::UserCallsPerHour => Value::Number((limits.user_calls_per_hour).into()),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:AiLimits/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<AiLimits>,
) -> trc::Result<GetResponse<AiLimits>> {
assert_server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(1)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let limits = limits::get(&server.core.storage.data).await?;
match ids {
None => response.list.push(to_value(&limits, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response.list.push(to_value(&limits, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
fn apply(limits: &mut Limits, property: &P, value: &Value<'_, P, AiLimitsValue>) -> Result<(), String> {
let number = || value.as_f64().ok_or_else(|| "must be a number".to_string());
let whole = || value.as_u64().ok_or_else(|| "must be a whole number".to_string());
match property {
P::SpamMaxAdded => limits.spam_max_added = number()?,
P::SpamMaxSubtracted => limits.spam_max_subtracted = number()?,
P::SpamCallCeiling => limits.spam_call_ceiling = Duration::from_millis(whole()?),
P::MaxConcurrentCalls => limits.max_concurrent_calls = whole()?,
P::MaxContentBytes => limits.max_content_bytes = whole()?,
P::FailureBackoff => limits.failure_backoff = Duration::from_millis(whole()?),
P::UserCallsPerHour => limits.user_calls_per_hour = whole()?,
P::Id => return Err("is immutable".to_string()),
}
Ok(())
}
/// Puts a property back to its default (a `null` in `/set`).
fn reset(limits: &mut Limits, property: &P, defaults: &Limits) -> Result<(), String> {
match property {
P::SpamMaxAdded => limits.spam_max_added = defaults.spam_max_added,
P::SpamMaxSubtracted => limits.spam_max_subtracted = defaults.spam_max_subtracted,
P::SpamCallCeiling => limits.spam_call_ceiling = defaults.spam_call_ceiling,
P::MaxConcurrentCalls => limits.max_concurrent_calls = defaults.max_concurrent_calls,
P::MaxContentBytes => limits.max_content_bytes = defaults.max_content_bytes,
P::FailureBackoff => limits.failure_backoff = defaults.failure_backoff,
P::UserCallsPerHour => limits.user_calls_per_hour = defaults.user_calls_per_hour,
P::Id => return Err("is immutable".to_string()),
}
Ok(())
}
/// `inbuxa:AiLimits/set`: updates the singleton. Unset (`null`) restores a
/// property's default.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, AiLimits>,
) -> trc::Result<SetResponse<AiLimits>> {
assert_server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response.not_created.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
let data = &server.core.storage.data;
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() {
response.not_updated.append(id, SetError::not_found());
continue;
}
let mut limits = limits::get(data).await?;
let defaults = Limits::default();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
};
let result = if matches!(value, Value::Null) {
reset(&mut limits, property, &defaults)
} else {
apply(&mut limits, property, &value)
};
if let Err(why) = result {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description(why),
);
break;
}
}
if error.is_none()
&& let Err((property, why)) = limits.check()
{
error = Some(
SetError::invalid_properties()
.with_property(property.parse::<P>().unwrap_or(P::Id))
.with_description(format!("{property} {why}.")),
);
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
limits::set(data, &limits).await?;
response.updated.append(id, None);
}
}
}
Ok(response)
}
+1
View File
@@ -8,6 +8,7 @@
//! `crates/features`; this module only speaks JMAP for them.
pub mod access;
pub mod ai_limits;
pub mod deleted_account;
pub mod fastmail;
pub mod masked_email;
+17
View File
@@ -588,6 +588,15 @@ impl RegistrySet for Server {
continue 'outer;
}
// inbuxa: AI-12, AI-18: the classifier and its models follow their rules
match inbuxa_features::ai::writes::check(self.registry(), &new_object).await? {
Ok(()) => {}
Err(err) => {
set.failed(modification, err);
continue 'outer;
}
}
// inbuxa: UD-16: a kept account's addresses stay its own
if let Some(err) =
crate::inbuxa::deleted_account::reserved(self, stored, &new_object).await?
@@ -660,6 +669,10 @@ impl RegistrySet for Server {
let object_id = match (modification, result) {
(Modification::Update { id, object }, RegistryWriteResult::Success(_)) => {
cache_invalidator.process_update(id, &object, &new_object);
// inbuxa: AI-2: content leaving the network is flagged
if let ObjectInner::AiModel(model) = &new_object.inner {
self.ai_warn_if_remote(model).await;
}
// inbuxa: MT-8: what moves with a domain follows it
for (id, old, new) in inbuxa_features::tenancy::writes::after_save(
&self.core.storage.data,
@@ -703,6 +716,10 @@ impl RegistrySet for Server {
RegistryWriteResult::Success(id),
) => {
cache_invalidator.process_create(&new_object);
// inbuxa: AI-2: content leaving the network is flagged
if let ObjectInner::AiModel(model) = &new_object.inner {
self.ai_warn_if_remote(model).await;
}
// inbuxa: ME-7a
if let ObjectInner::MaskedEmail(mask) = &new_object.inner {
crate::inbuxa::masked_email::created(self, id, mask).await?;