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:
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -415,6 +415,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::Quota
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user