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:
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:AiLimits/get` and `/set` under `urn:inbuxa:jmap`: the fork's
|
||||
//! limits on AI model calls (AI spam classification spec, "Added by
|
||||
//! inbuxa-server"). A singleton, id `singleton`.
|
||||
|
||||
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AiLimits;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AiLimitsProperty {
|
||||
Id,
|
||||
SpamMaxAdded,
|
||||
SpamMaxSubtracted,
|
||||
SpamCallCeiling,
|
||||
MaxConcurrentCalls,
|
||||
MaxContentBytes,
|
||||
FailureBackoff,
|
||||
UserCallsPerHour,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum AiLimitsValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for AiLimitsProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
AiLimitsProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
AiLimitsProperty::Id => "id",
|
||||
AiLimitsProperty::SpamMaxAdded => "spamMaxAdded",
|
||||
AiLimitsProperty::SpamMaxSubtracted => "spamMaxSubtracted",
|
||||
AiLimitsProperty::SpamCallCeiling => "spamCallCeiling",
|
||||
AiLimitsProperty::MaxConcurrentCalls => "maxConcurrentCalls",
|
||||
AiLimitsProperty::MaxContentBytes => "maxContentBytes",
|
||||
AiLimitsProperty::FailureBackoff => "failureBackoff",
|
||||
AiLimitsProperty::UserCallsPerHour => "userCallsPerHour",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl AiLimitsProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => AiLimitsProperty::Id,
|
||||
b"spamMaxAdded" => AiLimitsProperty::SpamMaxAdded,
|
||||
b"spamMaxSubtracted" => AiLimitsProperty::SpamMaxSubtracted,
|
||||
b"spamCallCeiling" => AiLimitsProperty::SpamCallCeiling,
|
||||
b"maxConcurrentCalls" => AiLimitsProperty::MaxConcurrentCalls,
|
||||
b"maxContentBytes" => AiLimitsProperty::MaxContentBytes,
|
||||
b"failureBackoff" => AiLimitsProperty::FailureBackoff,
|
||||
b"userCallsPerHour" => AiLimitsProperty::UserCallsPerHour,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AiLimitsProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
AiLimitsProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for AiLimitsValue {
|
||||
type Property = AiLimitsProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(AiLimitsProperty::Id) => Id::from_str(value).ok().map(AiLimitsValue::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
AiLimitsValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for AiLimits {
|
||||
type Property = AiLimitsProperty;
|
||||
|
||||
type Element = AiLimitsValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = AiLimitsProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for AiLimitsValue {
|
||||
fn from(id: Id) -> Self {
|
||||
AiLimitsValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for AiLimitsValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
AiLimitsValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
AiLimitsValue::Id(id) => Some(AnyId::Id(*id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||
if let AnyId::Id(id) = new_id {
|
||||
*self = AiLimitsValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for AiLimitsProperty {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn as_id_ref(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod contact;
|
||||
pub mod email;
|
||||
pub mod email_submission;
|
||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||
pub mod file_node;
|
||||
pub mod identity;
|
||||
|
||||
@@ -56,6 +56,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::DeletedAccount(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::AiLimits(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::Principal(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::VacationResponse(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||
@@ -83,6 +84,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::DeletedAccount(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AiLimits(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AddressBook(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -113,6 +113,7 @@ pub enum GetRequestMethod {
|
||||
Registry(Box<GetRequest<Registry>>),
|
||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -135,6 +136,7 @@ pub enum SetRequestMethod<'x> {
|
||||
Registry(Box<SetRequest<'x, Registry>>),
|
||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -160,6 +160,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::AiLimits) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::AiLimits(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -318,6 +325,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::AiLimits) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::AiLimits(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
|
||||
@@ -100,6 +100,7 @@ pub enum GetResponseMethod {
|
||||
Registry(GetResponse<Registry>),
|
||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -123,6 +124,7 @@ pub enum SetResponseMethod {
|
||||
Registry(Box<SetResponse<Registry>>),
|
||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -282,6 +284,19 @@ impl<'x> From<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEm
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: AI call limits
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::AiLimits(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: deleted accounts (UD-17)
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>) -> Self {
|
||||
|
||||
Reference in New Issue
Block a user