Author SHA1 Message Date
jcoffey-dev c60f3be5ca Version 2026.9.23
ci / build (pull_request) Successful in 2m56s
ci / publish (pull_request) Skipped
Carries the renamed registry capability and web interface client (#12),
for inbuxa-server 2026.9.24.2, and the Local AI page (#13).
2026-09-22 22:42:02 -07:00
jcoffey-dev a4fcd8cffe Merge pull request 'Local AI: a page to set up AI spam filtering, and the form hooks the spec asks for' (#13) from feature/local-ai-setup into main
ci / build (push) Successful in 1m0s
ci / publish (push) Skipped
Reviewed-on: #13
2026-09-23 05:38:51 +00:00
jcoffey-dev d0eddd2cda Local AI: a page to set up AI spam filtering, and the form hooks the spec asks for
ci / build (pull_request) Successful in 1m13s
ci / publish (pull_request) Skipped
Settings › Spam Filter › Local AI (CustomComponent/LocalAi) shows whether
the language-model classifier is on and which model it asks. It's off
until someone turns it on. Setting it up asks "Guided or manual?": guided
explains what's sent (subject and text only), that the model's word is one
bounded signal and never holds up mail, and recommends running Qwen3 4B
Instruct 2507 locally under llama.cpp or Ollama; it then takes the model's
address and the prompt, creates the model (or reuses one of the same name)
and switches the classifier on with its real id. Manual goes to the
ordinary forms. Turning it off is one click and keeps the model. The page
also edits inbuxa:AiLimits, sending a return to a default as null so the
server keeps following it.

On the schema-driven forms (ai-spam-classification spec, "INBUXA Admin"):
the default prompt is prefilled when the classifier is switched to
Enabled, the model form suggests local addresses, a model outside the
network gets the AI-2 warning (advisory, never blocking), and the
classifier form says failures never hold up mail.

The server's schema gains the menu link separately, after this release.
2026-09-22 22:08:33 -07:00
jcoffey-dev fa8f5abbad Merge pull request 'Use the server's renamed registry capability and web interface client' (#12) from fork/rename-upstream-identifiers into main
ci / build (push) Successful in 6m0s
ci / publish (push) Skipped
Reviewed-on: #12
2026-09-23 04:15:49 +00:00
jcoffey-dev ba1b8b74b7 Use the server's renamed registry capability and web interface client
ci / build (pull_request) Successful in 1m9s
ci / publish (pull_request) Skipped
inbuxa-server renames the identifiers that carried the upstream name
(its SPEC.md §2.4): upstream's JMAP capability for the registry (x:)
objects is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap,
and the web interface it serves signs in as inbuxa-webui. There are no
aliases, so this lands with the server change and deploys with it.
2026-09-22 18:59:49 -07:00
11 changed files with 1102 additions and 9 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "2026.9.21.2" "version": "2026.9.23"
} }
+30 -2
View File
@@ -10,6 +10,7 @@
import { humanize } from '@/lib/humanize'; import { humanize } from '@/lib/humanize';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel'; import { HelpPanel } from '@/help/HelpPanel';
import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
import { iconForView } from '@/lib/viewIcon'; import { iconForView } from '@/lib/viewIcon';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
@@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setSelectedVariant(newVariant); setSelectedVariant(newVariant);
const newData = buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant); const newData = {
...buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant),
// inbuxa: e.g. the default prompt when the AI classifier is switched on
...variantPrefill(obj.objectName, newVariant),
};
setFormData(newData); setFormData(newData);
}, },
[schema, resolved], [schema, resolved],
@@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</div> </div>
)} )}
{/* inbuxa: notices for the AI objects (locality warning, AI-2) */}
{formNotices(resolved.obj.objectName, formData).map((notice) => (
<div
key={notice.key}
role={notice.tone === 'warning' ? 'alert' : 'note'}
className={
notice.tone === 'warning'
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm'
: 'rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground'
}
>
{t(notice.key, notice.text)}
</div>
))}
{sectionsToRender.map((section, sectionIdx) => ( {sectionsToRender.map((section, sectionIdx) => (
<Card key={sectionIdx}> <Card key={sectionIdx}>
{section.title && ( {section.title && (
@@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
<FieldWidget <FieldWidget
key={formField.name} key={formField.name}
field={field} field={field}
formField={formField} formField={
formField.placeholder
? formField
: {
...formField,
// inbuxa: local example addresses on the AI model form
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
}
}
value={fieldValue} value={fieldValue}
onChange={(v) => handleFieldChange(formField.name, v)} onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={fieldReadOnly} readOnly={fieldReadOnly}
+8
View File
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/LegacyProtocolsPage'), () => import('@/features/hardening/LegacyProtocolsPage'),
(m) => m.LegacyProtocolsPage, (m) => m.LegacyProtocolsPage,
); );
// inbuxa: Settings Spam Filter Local AI (ai-spam-classification spec).
const LocalAiPage = lazyFeature(
() => import('@/features/ai/LocalAiPage'),
(m) => m.LocalAiPage,
);
const TenantLegacyProtocols = lazyFeature( const TenantLegacyProtocols = lazyFeature(
() => import('@/features/hardening/TenantLegacyProtocols'), () => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols, (m) => m.TenantLegacyProtocols,
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LegacyProtocols') { if (componentName === 'LegacyProtocols') {
return <LegacyProtocolsPage />; return <LegacyProtocolsPage />;
} }
if (componentName === 'LocalAi') {
return <LocalAiPage />;
}
return ( return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground"> <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName} Unknown component: {componentName}
+507
View File
@@ -0,0 +1,507 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Settings Spam Filter Local AI. Spam filtering with a language
* model the operator runs, off until someone turns it on here or on the
* classifier's own form.
*
* Setting it up asks "Guided or manual?" each time (admin UX roadmap): guided
* walks through what it does, the model's address and the prompt, then turns
* it on; manual goes to the ordinary forms. Turning it off is one click and
* keeps the model, so turning it back on is easy too.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Brain, Loader2, Power, PowerOff, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast';
import {
DEFAULT_LIMITS,
DEFAULT_PROMPT,
disableClassifier,
enableWithModel,
EXAMPLE_URLS,
fetchLimits,
fetchStatus,
LIMIT_FIELDS,
LimitsUnavailable,
locality,
RECOMMENDED_MODEL,
saveLimits,
type AiLimits,
type Status,
} from './localAi';
import { formNotices } from './formExtras';
export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi';
type Load<T> = { kind: 'loading' } | { kind: 'ready'; value: T } | { kind: 'error'; message: string };
export function LocalAiPage() {
const { t } = useTranslation();
const canUpdate = useAccountStore(
(s) => s.hasObjectPermission('sysSpamLlm', 'Update') && s.hasObjectPermission('sysAiModel', 'Create'),
);
const [status, setStatus] = useState<Load<Status>>({ kind: 'loading' });
const [mode, setMode] = useState<'idle' | 'choose' | 'guided'>('idle');
const [busy, setBusy] = useState(false);
const reload = useCallback((signal?: AbortSignal) => {
fetchStatus(signal)
.then((value) => {
if (!signal?.aborted) setStatus({ kind: 'ready', value });
})
.catch((e: unknown) => {
if (!signal?.aborted) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
});
}, []);
useEffect(() => {
const ctl = new AbortController();
reload(ctl.signal);
return () => ctl.abort();
}, [reload]);
const turnOff = async () => {
setBusy(true);
const outcome = await disableClassifier().catch((e: unknown) => ({
ok: false,
message: e instanceof Error ? e.message : String(e),
}));
setBusy(false);
if (outcome.ok) {
toast({ title: t('localAi.turnedOff', 'Local AI spam filtering is off.') });
reload();
} else {
toast({ title: t('localAi.failed', 'That didnt work'), description: outcome.message, variant: 'destructive' });
}
};
if (status.kind === 'loading') return <LoadingFallback />;
return (
<div className="mx-auto max-w-4xl space-y-6">
<header className="space-y-1">
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Brain className="h-6 w-6" /> {t('localAi.title', 'Local AI')}
</h1>
<p className="text-sm text-muted-foreground">
{t(
'localAi.subtitle',
'Spam filtering with a language model you run on your own machines. Off until you turn it on.',
)}
</p>
</header>
{status.kind === 'error' ? (
<Card>
<CardContent className="pt-6 text-sm text-destructive">{status.message}</CardContent>
</Card>
) : (
<StatusCard
status={status.value}
canUpdate={canUpdate}
busy={busy}
onSetUp={() => setMode('choose')}
onTurnOff={turnOff}
/>
)}
{mode === 'choose' && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('localAi.howTitle', 'Guided or manual?')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button onClick={() => setMode('guided')}>{t('localAi.guided', 'Guided')}</Button>
<Button variant="outline" asChild>
<Link to="/Settings/x:AiModel">{t('localAi.manual', 'Manual: the model and classifier forms')}</Link>
</Button>
<Button variant="ghost" onClick={() => setMode('idle')}>
{t('common.cancel', 'Cancel')}
</Button>
</CardContent>
</Card>
)}
{mode === 'guided' && status.kind === 'ready' && (
<GuidedSetup
status={status.value}
onDone={() => {
setMode('idle');
reload();
}}
onCancel={() => setMode('idle')}
/>
)}
<LimitsCard canUpdate={canUpdate} />
</div>
);
}
function StatusCard({
status,
canUpdate,
busy,
onSetUp,
onTurnOff,
}: {
status: Status;
canUpdate: boolean;
busy: boolean;
onSetUp: () => void;
onTurnOff: () => void;
}) {
const { t } = useTranslation();
const model = status.models.find((m) => m.id === status.modelId);
return (
<Card>
<CardContent className="space-y-4 pt-6">
{status.enabled ? (
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-medium">
<Power className="h-4 w-4 text-green-600" /> {t('localAi.on', 'On')}
</p>
{model && (
<p className="text-muted-foreground">
{t('localAi.asks', 'Asks {{name}} ({{model}}) at {{url}}', {
name: model.name,
model: model.model,
url: model.url,
})}
</p>
)}
</div>
) : (
<div className="space-y-1 text-sm">
<p className="flex items-center gap-2 font-medium">
<PowerOff className="h-4 w-4 text-muted-foreground" /> {t('localAi.off', 'Off')}
</p>
<p className="text-muted-foreground">
{t(
'localAi.offExplain',
'The spam filter doesnt use a language model. Nothing is sent anywhere until you set one up.',
)}
</p>
</div>
)}
{canUpdate && (
<div className="flex flex-wrap gap-2">
{status.enabled ? (
<>
<Button variant="outline" onClick={onTurnOff} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('localAi.turnOff', 'Turn off')}
</Button>
<Button variant="ghost" asChild>
<Link to="/Settings/x:SpamLlm">{t('localAi.editClassifier', 'Edit the classifier')}</Link>
</Button>
</>
) : (
<Button onClick={onSetUp}>{t('localAi.setUp', 'Set up local AI spam filtering')}</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}
function GuidedSetup({ status, onDone, onCancel }: { status: Status; onDone: () => void; onCancel: () => void }) {
const { t } = useTranslation();
const [step, setStep] = useState(0);
const [name, setName] = useState('local');
const [url, setUrl] = useState(EXAMPLE_URLS.llamaCpp);
const [model, setModel] = useState(RECOMMENDED_MODEL.model);
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const notices = useMemo(() => formNotices('x:AiModel', { url }), [url]);
const where = locality(url);
const finish = async () => {
setBusy(true);
setError(null);
const outcome = await enableWithModel({ name, url, model, prompt }, status.models).catch((e: unknown) => ({
ok: false,
property: undefined,
message: e instanceof Error ? e.message : String(e),
}));
setBusy(false);
if (outcome.ok) {
toast({ title: t('localAi.turnedOn', 'Local AI spam filtering is on.') });
onDone();
} else {
setError(outcome.property ? `${outcome.property}: ${outcome.message}` : (outcome.message ?? ''));
}
};
const steps = [
<div key="what" className="space-y-3 text-sm">
<p>
{t('localAi.whatLead', 'The spam filter will ask a language model for its opinion of each incoming message.')}
</p>
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
<li>{t('localAi.whatSent', 'Only the subject and text are sent: no addresses, headers or attachments.')}</li>
<li>
{t('localAi.whatBounded', 'Its opinion is one signal among many, adding at most {{max}} points by default.', {
max: DEFAULT_LIMITS.spamMaxAdded,
})}
</li>
<li>{t('localAi.whatNeverHolds', 'If the model is slow or down, mail is never held up.')}</li>
<li>
{t(
'localAi.whatModel',
'Run the model yourself, on this machine or your own network: llama.cpps server or Ollama both work. We recommend {{label}} ({{license}}) with at least {{cpus}} CPU cores.',
{ label: RECOMMENDED_MODEL.label, license: RECOMMENDED_MODEL.license, cpus: RECOMMENDED_MODEL.minCpus },
)}
</li>
</ul>
</div>,
<div key="model" className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ai-url">{t('localAi.url', 'Model address')}</Label>
<Input id="ai-url" value={url} onChange={(e) => setUrl(e.target.value)} />
<p className="text-xs text-muted-foreground">
{t('localAi.urlHint', 'llama.cpp: {{llama}} · Ollama: {{ollama}}', {
llama: EXAMPLE_URLS.llamaCpp,
ollama: EXAMPLE_URLS.ollama,
})}
</p>
</div>
{notices.map((n) => (
<p
key={n.key}
role={n.tone === 'warning' ? 'alert' : 'note'}
className={
n.tone === 'warning'
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm'
: 'rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground'
}
>
{t(n.key, n.text)}
</p>
))}
<div className="space-y-1.5">
<Label htmlFor="ai-model">{t('localAi.model', 'Model name')}</Label>
<Input id="ai-model" value={model} onChange={(e) => setModel(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ai-name">{t('localAi.name', 'Name in inbuxa')}</Label>
<Input id="ai-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
</div>,
<div key="prompt" className="space-y-1.5">
<Label htmlFor="ai-prompt">{t('localAi.prompt', 'Instructions for the model')}</Label>
<Textarea id="ai-prompt" rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<p className="text-xs text-muted-foreground">
{t(
'localAi.promptHint',
'The default was measured against real mail. The server adds its own framing so the message is treated as data, not instructions.',
)}
</p>
</div>,
];
const canNext =
step === 0 || (step === 1 && where !== 'invalid' && model.trim() !== '' && name.trim() !== '') || step === 2;
const last = step === steps.length - 1;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">
{t('localAi.stepOf', 'Step {{n}} of {{total}}', { n: step + 1, total: steps.length })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{steps[step]}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex flex-wrap gap-2">
{step > 0 && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={busy}>
{t('common.back', 'Back')}
</Button>
)}
{last ? (
<Button onClick={finish} disabled={busy || prompt.trim() === ''}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('localAi.turnOn', 'Turn on')}
</Button>
) : (
<Button onClick={() => setStep(step + 1)} disabled={!canNext}>
{t('common.next', 'Next')}
</Button>
)}
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</CardContent>
</Card>
);
}
const LIMIT_LABELS: Record<
keyof AiLimits,
{ key: string; text: string; unit: 'points' | 'seconds' | 'count' | 'bytes' }
> = {
spamMaxAdded: { key: 'localAi.limit.spamMaxAdded', text: 'Most the model can add to a score', unit: 'points' },
spamMaxSubtracted: {
key: 'localAi.limit.spamMaxSubtracted',
text: 'Most the model can take off a score',
unit: 'points',
},
spamCallCeiling: {
key: 'localAi.limit.spamCallCeiling',
text: 'Longest the spam filter waits for the model',
unit: 'seconds',
},
maxConcurrentCalls: { key: 'localAi.limit.maxConcurrentCalls', text: 'Requests in flight at once', unit: 'count' },
maxContentBytes: { key: 'localAi.limit.maxContentBytes', text: 'Most message text sent', unit: 'bytes' },
failureBackoff: { key: 'localAi.limit.failureBackoff', text: 'Pause after repeated failures', unit: 'seconds' },
userCallsPerHour: {
key: 'localAi.limit.userCallsPerHour',
text: 'Calls per account per hour from its own Sieve scripts',
unit: 'count',
},
};
/** Durations travel in milliseconds and show in seconds. */
function toShown(key: keyof AiLimits, v: number): number {
return LIMIT_LABELS[key].unit === 'seconds' ? v / 1000 : v;
}
function fromShown(key: keyof AiLimits, v: number): number {
return LIMIT_LABELS[key].unit === 'seconds' ? Math.round(v * 1000) : v;
}
function LimitsCard({ canUpdate }: { canUpdate: boolean }) {
const { t } = useTranslation();
const [load, setLoad] = useState<Load<AiLimits>>({ kind: 'loading' });
const [draft, setDraft] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fill = (limits: AiLimits) =>
setDraft(Object.fromEntries(LIMIT_FIELDS.map((k) => [k, String(toShown(k, limits[k]))])));
useEffect(() => {
const ctl = new AbortController();
fetchLimits(ctl.signal)
.then((value) => {
if (ctl.signal.aborted) return;
setLoad({ kind: 'ready', value });
fill(value);
})
.catch((e: unknown) => {
if (ctl.signal.aborted) return;
setLoad({
kind: 'error',
message:
e instanceof LimitsUnavailable
? t('localAi.limitsUnavailable', 'This server doesnt offer AI limits.')
: e instanceof Error
? e.message
: String(e),
});
});
return () => ctl.abort();
}, [t]);
if (load.kind !== 'ready') {
return load.kind === 'error' ? (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">{load.message}</CardContent>
</Card>
) : null;
}
const parsed = (): AiLimits | null => {
const out = { ...load.value };
for (const k of LIMIT_FIELDS) {
const n = Number(draft[k]);
if (draft[k] === undefined || draft[k].trim() === '' || !Number.isFinite(n) || n < 0) return null;
out[k] = fromShown(k, n);
}
return out;
};
const save = async (next: AiLimits) => {
setBusy(true);
setError(null);
const outcome = await saveLimits(load.value, next).catch((e: unknown) => ({
ok: false,
property: undefined,
message: e instanceof Error ? e.message : String(e),
}));
setBusy(false);
if (outcome.ok) {
setLoad({ kind: 'ready', value: next });
fill(next);
toast({ title: t('localAi.limitsSaved', 'Limits saved.') });
} else {
setError(outcome.message ?? '');
}
};
const next = parsed();
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('localAi.limitsTitle', 'Limits')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t(
'localAi.limitsLead',
'These keep the models influence small and its load bounded. The defaults suit a small CPU-only server.',
)}
</p>
<div className="grid gap-4 sm:grid-cols-2">
{LIMIT_FIELDS.map((k) => (
<div key={k} className="space-y-1.5">
<Label htmlFor={`limit-${k}`}>{t(LIMIT_LABELS[k].key, LIMIT_LABELS[k].text)}</Label>
<Input
id={`limit-${k}`}
inputMode="decimal"
value={draft[k] ?? ''}
disabled={!canUpdate || busy}
onChange={(e) => setDraft({ ...draft, [k]: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
{t('localAi.limitDefault', 'Default: {{value}}', { value: toShown(k, DEFAULT_LIMITS[k]) })}
{LIMIT_LABELS[k].unit === 'seconds' ? ` ${t('localAi.seconds', 's')}` : ''}
</p>
</div>
))}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{canUpdate && (
<div className="flex flex-wrap gap-2">
<Button onClick={() => next && save(next)} disabled={busy || !next}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save', 'Save')}
</Button>
<Button variant="ghost" onClick={() => save({ ...DEFAULT_LIMITS })} disabled={busy}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('localAi.resetDefaults', 'Reset to defaults')}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
+80
View File
@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: what the schema-driven forms add for the AI objects (spec, "INBUXA
* Admin"): the default prompt when the classifier is switched on, local
* example placeholders on the model form, and the notices above both forms.
* DynamicForm calls these at three marked points; everything else about the
* forms stays as the schema draws them.
*/
import { DEFAULT_PROMPT, EXAMPLE_URLS, locality, RECOMMENDED_MODEL } from './localAi';
/** Values to prefill when a form switches to a variant. */
export function variantPrefill(objectName: string, variant: string): Record<string, unknown> {
if (objectName === 'x:SpamLlm' && variant === 'Enable') return { prompt: DEFAULT_PROMPT };
return {};
}
/** A placeholder for a field the schema gives none. */
export function fieldPlaceholder(objectName: string, fieldName: string): string | undefined {
if (objectName !== 'x:AiModel') return undefined;
if (fieldName === 'url') return EXAMPLE_URLS.llamaCpp;
if (fieldName === 'model') return RECOMMENDED_MODEL.model;
return undefined;
}
export interface FormNotice {
tone: 'info' | 'warning';
/** An i18n key and its English default. */
key: string;
text: string;
}
/** Notices to show above a form, from what it currently holds. */
export function formNotices(objectName: string, data: Record<string, unknown>): FormNotice[] {
if (objectName === 'x:AiModel') {
const url = typeof data.url === 'string' ? data.url.trim() : '';
if (!url) return [];
switch (locality(url)) {
case 'remote':
return [
{
tone: 'warning',
key: 'localAi.remoteWarning',
text:
'This address is outside your network. The spam filter will send the subject and text of ' +
'incoming mail to it. For privacy, run the model on your own machines.',
},
];
case 'unknown':
return [
{
tone: 'info',
key: 'localAi.nameNotice',
text:
'If this name points outside your network, message text will leave it. The server checks when ' +
'you save and warns in its log.',
},
];
default:
return [];
}
}
if (objectName === 'x:SpamLlm') {
return [
{
tone: 'info',
key: 'localAi.neverHoldsMail',
text:
"The model's opinion is one signal among many, and adds at most a few points. If the model is " +
'slow or unavailable, mail is never held up: the message is scored without it.',
},
];
}
return [];
}
+173
View File
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const jmapRequest = vi.fn();
vi.mock('@/services/jmap/client', () => ({
getAccountId: () => 'a',
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
}));
import {
DEFAULT_LIMITS,
DEFAULT_PROMPT,
enableWithModel,
fetchLimits,
fetchStatus,
LimitsUnavailable,
locality,
parseLimits,
saveLimits,
} from './localAi';
import { fieldPlaceholder, formNotices, variantPrefill } from './formExtras';
beforeEach(() => jmapRequest.mockReset());
describe('locality (AI-2)', () => {
it.each([
['http://127.0.0.1:8080/v1/chat/completions', 'local'],
['http://localhost:11434/v1/chat/completions', 'local'],
['http://10.0.0.5/v1', 'local'],
['http://172.16.1.1/v1', 'local'],
['http://172.31.255.1/v1', 'local'],
['http://192.168.1.20/v1', 'local'],
['http://[::1]:8080/v1', 'local'],
['http://[fd12:3456::1]/v1', 'local'],
['http://172.32.0.1/v1', 'remote'],
['https://8.8.8.8/v1', 'remote'],
['https://[2001:db8::1]/v1', 'remote'],
['https://api.example.com/v1/chat/completions', 'unknown'],
['ai.lan', 'invalid'],
['', 'invalid'],
])('%s is %s', (url, expected) => {
expect(locality(url)).toBe(expected);
});
});
describe('limits', () => {
it('fills anything unset with the defaults', () => {
expect(parseLimits({ spamMaxAdded: 1.5 })).toEqual({ ...DEFAULT_LIMITS, spamMaxAdded: 1.5 });
});
it('reads the singleton under the fork capability', async () => {
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/get', { list: [{ maxContentBytes: 4096 }] }, '0']]);
expect((await fetchLimits()).maxContentBytes).toBe(4096);
expect(jmapRequest.mock.calls[0][2]).toEqual(['urn:inbuxa:jmap']);
});
it('says so when the server has no AI limits', async () => {
jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }, '0']]);
await expect(fetchLimits()).rejects.toBeInstanceOf(LimitsUnavailable);
});
it('sends only what changed, and a return to the default as null', async () => {
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }, '0']]);
const current = { ...DEFAULT_LIMITS, spamMaxAdded: 3 };
const next = { ...current, spamMaxAdded: DEFAULT_LIMITS.spamMaxAdded, userCallsPerHour: 10 };
expect(await saveLimits(current, next)).toEqual({ ok: true });
const [, args] = jmapRequest.mock.calls[0][0][0];
expect(args.update.singleton).toEqual({ spamMaxAdded: null, userCallsPerHour: 10 });
});
it('sends nothing when nothing changed', async () => {
expect(await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS })).toEqual({ ok: true });
expect(jmapRequest).not.toHaveBeenCalled();
});
it('reports the property the server rejected', async () => {
jmapRequest.mockResolvedValue([
[
'inbuxa:AiLimits/set',
{
notUpdated: {
singleton: { type: 'invalidProperties', properties: ['spamMaxAdded'], description: 'too big' },
},
},
'0',
],
]);
const outcome = await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS, spamMaxAdded: 99 });
expect(outcome).toEqual({ ok: false, property: 'spamMaxAdded', message: 'too big' });
});
});
describe('status', () => {
it('is off by default, and names the model when on', async () => {
jmapRequest.mockResolvedValueOnce([
['x:SpamLlm/get', { list: [{ '@type': 'Disable' }] }, 'c'],
['x:AiModel/get', { list: [] }, 'm'],
]);
expect(await fetchStatus()).toEqual({ enabled: false, modelId: null, models: [] });
jmapRequest.mockResolvedValueOnce([
['x:SpamLlm/get', { list: [{ '@type': 'Enable', modelId: 'm1' }] }, 'c'],
['x:AiModel/get', { list: [{ id: 'm1', name: 'local', model: 'q', url: 'http://127.0.0.1/v1' }] }, 'm'],
]);
const on = await fetchStatus();
expect(on.enabled).toBe(true);
expect(on.modelId).toBe('m1');
});
});
describe('guided setup', () => {
const input = { name: 'local', url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'q', prompt: 'p' };
it('creates the model, then enables the classifier with its real id', async () => {
jmapRequest
.mockResolvedValueOnce([['x:AiModel/set', { created: { m: { id: 'm9' } } }, '0']])
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
expect(await enableWithModel(input, [])).toEqual({ ok: true });
const enable = jmapRequest.mock.calls[1][0][0][1];
expect(enable.update.singleton).toEqual({ '@type': 'Enable', modelId: 'm9', prompt: 'p' });
});
it('reuses a model of the same name instead of creating a duplicate', async () => {
jmapRequest
.mockResolvedValueOnce([['x:AiModel/set', { updated: { m1: null } }, '0']])
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
await enableWithModel(input, [{ id: 'm1', name: 'local', model: 'old', url: 'http://10.0.0.1/v1' }]);
const update = jmapRequest.mock.calls[0][0][0][1];
expect(update.update).toHaveProperty('m1');
expect(update.create).toBeUndefined();
expect(jmapRequest.mock.calls[1][0][0][1].update.singleton.modelId).toBe('m1');
});
it('never switches the classifier on when the model could not be made', async () => {
jmapRequest.mockResolvedValueOnce([
['x:AiModel/set', { notCreated: { m: { type: 'invalidProperties', properties: ['url'] } } }, '0'],
]);
const outcome = await enableWithModel(input, []);
expect(outcome.ok).toBe(false);
expect(outcome.property).toBe('url');
expect(jmapRequest).toHaveBeenCalledTimes(1);
});
});
describe('form extras', () => {
it('prefills the default prompt only when the classifier is switched on', () => {
expect(variantPrefill('x:SpamLlm', 'Enable')).toEqual({ prompt: DEFAULT_PROMPT });
expect(variantPrefill('x:SpamLlm', 'Disable')).toEqual({});
expect(variantPrefill('x:Domain', 'Enable')).toEqual({});
});
it('suggests local addresses on the model form only', () => {
expect(fieldPlaceholder('x:AiModel', 'url')).toMatch(/^http:\/\/127\.0\.0\.1/);
expect(fieldPlaceholder('x:AiModel', 'name')).toBeUndefined();
expect(fieldPlaceholder('x:Domain', 'url')).toBeUndefined();
});
it('warns about a model outside the network, and never about a local one', () => {
expect(formNotices('x:AiModel', { url: 'https://8.8.8.8/v1' })[0]?.tone).toBe('warning');
expect(formNotices('x:AiModel', { url: 'https://api.example.com/v1' })[0]?.tone).toBe('info');
expect(formNotices('x:AiModel', { url: 'http://127.0.0.1:8080/v1' })).toEqual([]);
expect(formNotices('x:AiModel', {})).toEqual([]);
});
it('tells the classifier form that failures never hold up mail', () => {
expect(formNotices('x:SpamLlm', {})[0]?.key).toBe('localAi.neverHoldsMail');
});
});
+292
View File
@@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: local AI spam filtering (inbuxa-server's ai-spam-classification
* spec). The wire and the rules; the page and the form hooks draw from it.
*
* The feature is off until an administrator turns it on, and meant for a
* model running on the operator's own machines: nothing here presets a hosted
* endpoint, and the locality check says so when one is chosen (AI-2).
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { JmapMethodResponse, JmapSetError } from '@/types/jmap';
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
const LIMITS = 'inbuxa:AiLimits';
const CLASSIFIER = 'x:SpamLlm';
const MODEL = 'x:AiModel';
/**
* The fork's default classification prompt, prefilled only when an
* administrator enables the classifier (spec, "Default prompt"). The
* calibration measured it; the server adds its own framing around it.
*/
export const DEFAULT_PROMPT =
'Classify the email below as one of: Unsolicited, Commercial, Harmful, Legitimate. ' +
"Unsolicited: bulk mail the recipient didn't ask for. Commercial: selling something. " +
'Harmful: phishing, fraud or malware. Legitimate: anything else. Then give your confidence: ' +
'High, Medium or Low. Answer on one line as Category,Confidence,Reason with a reason of at most 20 words.';
/** The calibration's recommendation (spec, "Calibration"): Apache-2.0, 4 vCPU minimum on CPU only. */
export const RECOMMENDED_MODEL = {
label: 'Qwen3 4B Instruct 2507',
model: 'qwen3-4b-instruct-2507',
license: 'Apache-2.0',
minCpus: 4,
};
/** Where a model served beside the mail server usually answers: llama.cpp's server, or Ollama. */
export const EXAMPLE_URLS = {
llamaCpp: 'http://127.0.0.1:8080/v1/chat/completions',
ollama: 'http://127.0.0.1:11434/v1/chat/completions',
};
// ── Locality (AI-2) ──────────────────────────────────────────────────────
export type Locality = 'local' | 'unknown' | 'remote' | 'invalid';
function ipv4Octets(host: string): number[] | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (!m) return null;
const o = m.slice(1).map(Number);
return o.every((n) => n <= 255) ? o : null;
}
/**
* Is the endpoint on this network? `local` for localhost, loopback, RFC 1918
* and RFC 4193 addresses; `remote` for any other address; `unknown` for a
* name the browser can't resolve, where only the server can tell (it logs
* its own warning); `invalid` when there's no URL to judge. Advisory only: it
* never blocks an endpoint the operator chose.
*/
export function locality(url: string): Locality {
let host: string;
try {
host = new URL(url).hostname.toLowerCase();
} catch {
return 'invalid';
}
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
if (host === 'localhost' || host.endsWith('.localhost')) return 'local';
const v4 = ipv4Octets(host);
if (v4) {
const [a, b] = v4;
if (a === 127 || a === 10) return 'local';
if (a === 172 && b >= 16 && b <= 31) return 'local';
if (a === 192 && b === 168) return 'local';
return 'remote';
}
if (host.includes(':')) {
if (host === '::1') return 'local';
const first = parseInt(host.split(':')[0] || '0', 16);
if ((first & 0xfe00) === 0xfc00) return 'local';
return 'remote';
}
return 'unknown';
}
// ── The limits, inbuxa:AiLimits ──────────────────────────────────────────
export interface AiLimits {
spamMaxAdded: number;
spamMaxSubtracted: number;
/** Milliseconds. */
spamCallCeiling: number;
maxConcurrentCalls: number;
maxContentBytes: number;
/** Milliseconds. */
failureBackoff: number;
userCallsPerHour: number;
}
/** The spec's defaults, which the server also applies to anything unset. */
export const DEFAULT_LIMITS: AiLimits = {
spamMaxAdded: 2.0,
spamMaxSubtracted: 1.0,
spamCallCeiling: 20_000,
maxConcurrentCalls: 4,
maxContentBytes: 2048,
failureBackoff: 60_000,
userCallsPerHour: 60,
};
export const LIMIT_FIELDS = Object.keys(DEFAULT_LIMITS) as (keyof AiLimits)[];
export class LimitsUnavailable extends Error {}
export function parseLimits(raw: Record<string, unknown>): AiLimits {
const out = { ...DEFAULT_LIMITS };
for (const key of LIMIT_FIELDS) {
const v = raw[key];
if (typeof v === 'number' && Number.isFinite(v)) out[key] = v;
}
return out;
}
function methodError(responses: JmapMethodResponse[]): string | null {
const [name, result] = responses[0] ?? [];
if (name !== 'error') return null;
return typeof result?.description === 'string' ? result.description : String(result?.type ?? 'error');
}
export async function fetchLimits(signal?: AbortSignal): Promise<AiLimits> {
const accountId = getAccountId('x:');
const responses = await jmapRequest([[`${LIMITS}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
const [name, result] = responses[0] ?? [];
if (name === 'error') {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new LimitsUnavailable();
throw new Error(methodError(responses) ?? 'error');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
return parseLimits(list[0] ?? {});
}
export interface SaveOutcome {
ok: boolean;
/** The property the server rejected, and why. */
property?: string;
message?: string;
}
/**
* Saves what changed. A value equal to the default is sent as null, so the
* server keeps following the default rather than pinning today's number.
*/
export async function saveLimits(current: AiLimits, next: AiLimits): Promise<SaveOutcome> {
const patch: Record<string, number | null> = {};
for (const key of LIMIT_FIELDS) {
if (next[key] === current[key]) continue;
patch[key] = next[key] === DEFAULT_LIMITS[key] ? null : next[key];
}
if (Object.keys(patch).length === 0) return { ok: true };
const accountId = getAccountId('x:');
const responses = await jmapRequest(
[[`${LIMITS}/set`, { accountId, update: { singleton: patch } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const notUpdated = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated;
const failure = notUpdated?.singleton;
if (failure) {
return {
ok: false,
property: failure.properties?.[0],
message: failure.description ?? failure.type,
};
}
return { ok: true };
}
// ── The classifier and its models ────────────────────────────────────────
export interface AiModelSummary {
id: string;
name: string;
model: string;
url: string;
}
export interface Status {
enabled: boolean;
/** The model the classifier asks, when enabled. */
modelId: string | null;
models: AiModelSummary[];
}
export async function fetchStatus(signal?: AbortSignal): Promise<Status> {
const accountId = getAccountId('x:');
const responses = await jmapRequest(
[
[`${CLASSIFIER}/get`, { accountId, ids: ['singleton'] }, 'c'],
[`${MODEL}/get`, { accountId, ids: null, properties: ['name', 'model', 'url'] }, 'm'],
],
signal,
);
const err = methodError(responses);
if (err) throw new Error(err);
const classifier = ((responses[0][1] as { list?: Record<string, unknown>[] }).list ?? [])[0] ?? {};
const models = ((responses[1]?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? []).map((m) => ({
id: String(m.id),
name: String(m.name ?? ''),
model: String(m.model ?? ''),
url: String(m.url ?? ''),
}));
const enabled = classifier['@type'] === 'Enable';
return { enabled, modelId: enabled ? String(classifier.modelId ?? '') || null : null, models };
}
export interface SetupInput {
name: string;
url: string;
model: string;
prompt: string;
}
/**
* The guided setup: makes sure the model exists, then switches the
* classifier on with it. A model already configured under the same name is
* reused (and its address and model name updated), so running the setup again
* after a failure never piles up duplicates. The classifier is only switched
* on once the model is known to exist, with its real id.
*/
export async function enableWithModel(input: SetupInput, existing: AiModelSummary[]): Promise<SaveOutcome> {
const accountId = getAccountId('x:');
const fields = { name: input.name, url: input.url, model: input.model, modelType: 'Chat' };
const same = existing.find((m) => m.name === input.name);
let modelId: string;
if (same) {
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, update: { [same.id]: fields } }, '0']]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.[same.id];
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
modelId = same.id;
} else {
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, create: { m: fields } }, '0']]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const result = responses[0][1] as {
created?: Record<string, { id?: string }>;
notCreated?: Record<string, JmapSetError>;
};
const f = result.notCreated?.m;
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
const id = result.created?.m?.id;
if (!id) return { ok: false, message: 'The server created the model but did not return its id.' };
modelId = id;
}
const responses = await jmapRequest([
[
`${CLASSIFIER}/set`,
{ accountId, update: { singleton: { '@type': 'Enable', modelId, prompt: input.prompt } } },
'0',
],
]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
return { ok: true };
}
/** Switches the classifier off. The model stays configured, for turning it back on. */
export async function disableClassifier(): Promise<SaveOutcome> {
const accountId = getAccountId('x:');
const responses = await jmapRequest([
[`${CLASSIFIER}/set`, { accountId, update: { singleton: { '@type': 'Disable' } } }, '0'],
]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
return f ? { ok: false, message: f.description ?? f.type } : { ok: true };
}
+5
View File
@@ -71,6 +71,11 @@ function checkSpecialLink(
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false }; return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
} }
// inbuxa: the Local AI page is for whoever may see the AI classifier.
if (viewName === 'CustomComponent/LocalAi') {
return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false };
}
if (viewName.startsWith('CustomComponent/')) { if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false }; return { visible: true, enterprise: false };
} }
+3 -3
View File
@@ -36,17 +36,17 @@ describe('getOAuthClientId', () => {
it('falls back to the built-in default when the placeholder is empty', async () => { it('falls back to the built-in default when the placeholder is empty', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />'); const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('falls back to the built-in default when the placeholder is only whitespace', async () => { it('falls back to the built-in default when the placeholder is only whitespace', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />'); const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('falls back to the built-in default when the placeholder is absent', async () => { it('falls back to the built-in default when the placeholder is absent', async () => {
const getOAuthClientId = await loadWithMeta(''); const getOAuthClientId = await loadWithMeta('');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('is inbuxa-admin when hosted apart from the server', async () => { it('is inbuxa-admin when hosted apart from the server', async () => {
+2 -2
View File
@@ -9,10 +9,10 @@
// INBUXA requires OAuth clients to be registered, and registers these two on // INBUXA requires OAuth clients to be registered, and registers these two on
// every start (inbuxa-server contract C-6). Served by the server itself, this // every start (inbuxa-server contract C-6). Served by the server itself, this
// is the web interface at /admin, registered as `stalwart-webui`. Hosted // is the web interface at /admin, registered as `inbuxa-webui`. Hosted
// anywhere else, with the server's address in <meta name="api-base-url">, it // anywhere else, with the server's address in <meta name="api-base-url">, it
// is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL. // is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL.
const SERVED_BY_SERVER_CLIENT_ID = 'stalwart-webui'; const SERVED_BY_SERVER_CLIENT_ID = 'inbuxa-webui';
const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin'; const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin';
let cached: string | undefined; let cached: string | undefined;
+1 -1
View File
@@ -15,7 +15,7 @@ import type { Schema } from '@/types/schema';
const JMAP_USING = [ const JMAP_USING = [
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:core',
'urn:stalwart:jmap', 'urn:inbuxa:jmap:registry',
'urn:ietf:params:jmap:blob', 'urn:ietf:params:jmap:blob',
'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:calendars',