diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx
index ef45c72..24bceb7 100644
--- a/src/components/forms/DynamicForm.tsx
+++ b/src/components/forms/DynamicForm.tsx
@@ -10,6 +10,7 @@
import { humanize } from '@/lib/humanize';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
+import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
import { iconForView } from '@/lib/viewIcon';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
@@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
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);
},
[schema, resolved],
@@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
)}
+ {/* inbuxa: notices for the AI objects (locality warning, AI-2) */}
+ {formNotices(resolved.obj.objectName, formData).map((notice) => (
+
+ {t(notice.key, notice.text)}
+
+ ))}
+
{sectionsToRender.map((section, sectionIdx) => (
{section.title && (
@@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
handleFieldChange(formField.name, v)}
readOnly={fieldReadOnly}
diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx
index 01068ab..6bf4c32 100644
--- a/src/components/layout/MainContent.tsx
+++ b/src/components/layout/MainContent.tsx
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/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(
() => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols,
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LegacyProtocols') {
return ;
}
+ if (componentName === 'LocalAi') {
+ return ;
+ }
return (
Unknown component: {componentName}
diff --git a/src/features/ai/LocalAiPage.tsx b/src/features/ai/LocalAiPage.tsx
new file mode 100644
index 0000000..00feab3
--- /dev/null
+++ b/src/features/ai/LocalAiPage.tsx
@@ -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
= { 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>({ 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 didn’t work'), description: outcome.message, variant: 'destructive' });
+ }
+ };
+
+ if (status.kind === 'loading') return ;
+
+ return (
+
+
+
+ {t('localAi.title', 'Local AI')}
+
+
+ {t(
+ 'localAi.subtitle',
+ 'Spam filtering with a language model you run on your own machines. Off until you turn it on.',
+ )}
+
+
+
+ {status.kind === 'error' ? (
+
+ {status.message}
+
+ ) : (
+
setMode('choose')}
+ onTurnOff={turnOff}
+ />
+ )}
+
+ {mode === 'choose' && (
+
+
+ {t('localAi.howTitle', 'Guided or manual?')}
+
+
+ setMode('guided')}>{t('localAi.guided', 'Guided')}
+
+ {t('localAi.manual', 'Manual: the model and classifier forms')}
+
+ setMode('idle')}>
+ {t('common.cancel', 'Cancel')}
+
+
+
+ )}
+
+ {mode === 'guided' && status.kind === 'ready' && (
+ {
+ setMode('idle');
+ reload();
+ }}
+ onCancel={() => setMode('idle')}
+ />
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+ {status.enabled ? (
+
+
+ {t('localAi.on', 'On')}
+
+ {model && (
+
+ {t('localAi.asks', 'Asks {{name}} ({{model}}) at {{url}}', {
+ name: model.name,
+ model: model.model,
+ url: model.url,
+ })}
+
+ )}
+
+ ) : (
+
+
+ {t('localAi.off', 'Off')}
+
+
+ {t(
+ 'localAi.offExplain',
+ 'The spam filter doesn’t use a language model. Nothing is sent anywhere until you set one up.',
+ )}
+
+
+ )}
+ {canUpdate && (
+
+ {status.enabled ? (
+ <>
+
+ {busy && }
+ {t('localAi.turnOff', 'Turn off')}
+
+
+ {t('localAi.editClassifier', 'Edit the classifier')}
+
+ >
+ ) : (
+ {t('localAi.setUp', 'Set up local AI spam filtering')}
+ )}
+
+ )}
+
+
+ );
+}
+
+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(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 = [
+
+
+ {t('localAi.whatLead', 'The spam filter will ask a language model for its opinion of each incoming message.')}
+
+
+ {t('localAi.whatSent', 'Only the subject and text are sent: no addresses, headers or attachments.')}
+
+ {t('localAi.whatBounded', 'Its opinion is one signal among many, adding at most {{max}} points by default.', {
+ max: DEFAULT_LIMITS.spamMaxAdded,
+ })}
+
+ {t('localAi.whatNeverHolds', 'If the model is slow or down, mail is never held up.')}
+
+ {t(
+ 'localAi.whatModel',
+ 'Run the model yourself, on this machine or your own network: llama.cpp’s 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 },
+ )}
+
+
+
,
+
+
+
{t('localAi.url', 'Model address')}
+
setUrl(e.target.value)} />
+
+ {t('localAi.urlHint', 'llama.cpp: {{llama}} · Ollama: {{ollama}}', {
+ llama: EXAMPLE_URLS.llamaCpp,
+ ollama: EXAMPLE_URLS.ollama,
+ })}
+
+
+ {notices.map((n) => (
+
+ {t(n.key, n.text)}
+
+ ))}
+
+ {t('localAi.model', 'Model name')}
+ setModel(e.target.value)} />
+
+
+ {t('localAi.name', 'Name in inbuxa')}
+ setName(e.target.value)} />
+
+
,
+
+
{t('localAi.prompt', 'Instructions for the model')}
+
,
+ ];
+
+ const canNext =
+ step === 0 || (step === 1 && where !== 'invalid' && model.trim() !== '' && name.trim() !== '') || step === 2;
+ const last = step === steps.length - 1;
+
+ return (
+
+
+
+ {t('localAi.stepOf', 'Step {{n}} of {{total}}', { n: step + 1, total: steps.length })}
+
+
+
+ {steps[step]}
+ {error && {error}
}
+
+ {step > 0 && (
+ setStep(step - 1)} disabled={busy}>
+ {t('common.back', 'Back')}
+
+ )}
+ {last ? (
+
+ {busy && }
+ {t('localAi.turnOn', 'Turn on')}
+
+ ) : (
+ setStep(step + 1)} disabled={!canNext}>
+ {t('common.next', 'Next')}
+
+ )}
+
+ {t('common.cancel', 'Cancel')}
+
+
+
+
+ );
+}
+
+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>({ kind: 'loading' });
+ const [draft, setDraft] = useState>({});
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(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 doesn’t offer AI limits.')
+ : e instanceof Error
+ ? e.message
+ : String(e),
+ });
+ });
+ return () => ctl.abort();
+ }, [t]);
+
+ if (load.kind !== 'ready') {
+ return load.kind === 'error' ? (
+
+ {load.message}
+
+ ) : 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 (
+
+
+ {t('localAi.limitsTitle', 'Limits')}
+
+
+
+ {t(
+ 'localAi.limitsLead',
+ 'These keep the model’s influence small and its load bounded. The defaults suit a small CPU-only server.',
+ )}
+
+
+ {LIMIT_FIELDS.map((k) => (
+
+
{t(LIMIT_LABELS[k].key, LIMIT_LABELS[k].text)}
+
setDraft({ ...draft, [k]: e.target.value })}
+ />
+
+ {t('localAi.limitDefault', 'Default: {{value}}', { value: toShown(k, DEFAULT_LIMITS[k]) })}
+ {LIMIT_LABELS[k].unit === 'seconds' ? ` ${t('localAi.seconds', 's')}` : ''}
+
+
+ ))}
+
+ {error && {error}
}
+ {canUpdate && (
+
+ next && save(next)} disabled={busy || !next}>
+ {busy && }
+ {t('common.save', 'Save')}
+
+ save({ ...DEFAULT_LIMITS })} disabled={busy}>
+
+ {t('localAi.resetDefaults', 'Reset to defaults')}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/ai/formExtras.ts b/src/features/ai/formExtras.ts
new file mode 100644
index 0000000..0585651
--- /dev/null
+++ b/src/features/ai/formExtras.ts
@@ -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 {
+ 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): 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 [];
+}
diff --git a/src/features/ai/localAi.test.ts b/src/features/ai/localAi.test.ts
new file mode 100644
index 0000000..a26b3ee
--- /dev/null
+++ b/src/features/ai/localAi.test.ts
@@ -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');
+ });
+});
diff --git a/src/features/ai/localAi.ts b/src/features/ai/localAi.ts
new file mode 100644
index 0000000..dfd67e0
--- /dev/null
+++ b/src/features/ai/localAi.ts
@@ -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): 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 {
+ 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[] }).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 {
+ const patch: Record = {};
+ 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 }).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 {
+ 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[] }).list ?? [])[0] ?? {};
+ const models = ((responses[1]?.[1] as { list?: Record[] } | 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 {
+ 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 }).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;
+ notCreated?: Record;
+ };
+ 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 }).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 {
+ 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 }).notUpdated?.singleton;
+ return f ? { ok: false, message: f.description ?? f.type } : { ok: true };
+}
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
index 776daa2..e37806d 100644
--- a/src/lib/layout.ts
+++ b/src/lib/layout.ts
@@ -71,6 +71,11 @@ function checkSpecialLink(
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/')) {
return { visible: true, enterprise: false };
}