10 Commits
Author SHA1 Message Date
jcoffey-dev dbf911e0a6 Merge pull request 'Version 2026.9.23' (#14) from release/2026.9.23 into main
ci / build (push) Successful in 1m6s
ci / publish (push) Skipped
Reviewed-on: #14
2026-09-23 05:47:31 +00:00
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
jcoffey-dev 0191f7e10c Merge pull request 'Write the name in lowercase where people see it' (#11) from fix/brand-lowercase into main
ci / build (push) Successful in 1m23s
ci / publish (push) Skipped
2026-09-22 22:21:02 +00:00
jcoffey-dev 7e3418079c Write the name in lowercase where people see it
ci / build (pull_request) Successful in 1m1s
ci / publish (pull_request) Skipped
The brand is lowercase inbuxa. This changes the setup wizard's welcome and
completion text, the logo's accessible name, the version line in the top bar,
the document title, the legacy-protocols screens and banner, the protocol
table's JMAP row, one help text, and the issuer new authenticator enrollments
are labeled with.

Keys are ids here, not English text, so nothing moved: each catalog value and
its inline fallback changed together. This console ships English only, so
there is no other catalog to follow. Comments, identifiers and env var names
are untouched.
2026-09-22 15:19:25 -07:00
jcoffey-dev 0ea86b6be0 Merge pull request 'Point links at git.coffeylabs.org after the move from GitHub' (#10) from fix/links-after-move into main
ci / build (push) Successful in 1m4s
ci / publish (push) Skipped
2026-09-22 16:11:23 +00:00
jcoffey-dev 3e7bb80f73 Point links at git.coffeylabs.org after the move from GitHub
ci / build (pull_request) Successful in 1m6s
ci / publish (pull_request) Skipped
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:22 -07:00
25 changed files with 1129 additions and 36 deletions
+1 -1
View File
@@ -32,6 +32,6 @@ session.
You'll get an acknowledgement within a few days. A report that turns out to You'll get an acknowledgement within a few days. A report that turns out to
affect the mail server rather than this interface will be moved to affect the mail server rather than this interface will be moved to
[inbuxa-server](https://github.com/inbuxa/inbuxa-server), and one that affects [inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server), and one that affects
upstream Stalwart's web interface will be passed to Stalwart Labs with credit upstream Stalwart's web interface will be passed to Stalwart Labs with credit
to you. to you.
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "2026.9.21.2" "version": "2026.9.23"
} }
+3 -3
View File
@@ -336,7 +336,7 @@ export function BootstrapWizard() {
<WizardShell> <WizardShell>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to INBUXA')}</h2> <h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")} {t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p> </p>
@@ -471,7 +471,7 @@ function SuccessScreen({
'bootstrap.credentialsCreated', 'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.', 'Your administrator account has been created. Write these down now: the password will not be shown again.',
) )
: t('bootstrap.configuredSuccessfully', 'INBUXA has been configured successfully.')} : t('bootstrap.configuredSuccessfully', 'inbuxa has been configured successfully.')}
</p> </p>
</div> </div>
</div> </div>
@@ -503,7 +503,7 @@ function SuccessScreen({
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '} <span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t( {t(
'bootstrap.nextStepBody', 'bootstrap.nextStepBody',
'restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.', 'restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
)} )}
</p> </p>
</div> </div>
+1 -1
View File
@@ -20,7 +20,7 @@ export function DefaultLogo() {
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="165 35 616 130" viewBox="165 35 616 130"
aria-label={t('logo.inbuxaAlt', 'INBUXA')} aria-label={t('logo.inbuxaAlt', 'inbuxa')}
className="h-7 w-auto max-w-[320px]" className="h-7 w-auto max-w-[320px]"
> >
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} /> <image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
+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}
+1 -1
View File
@@ -38,7 +38,7 @@ function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
function generateTotp(): { totp: OTPAuth.TOTP; url: string } { function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({ const totp = new OTPAuth.TOTP({
issuer: 'INBUXA', issuer: 'inbuxa',
label: 'account', label: 'account',
algorithm: 'SHA1', algorithm: 'SHA1',
digits: 6, digits: 6,
+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}
+1 -1
View File
@@ -104,7 +104,7 @@ export function TopBar() {
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })} {t('version.label', 'inbuxa Admin {{version}}', { version: __APP_VERSION__ })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
+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 };
}
@@ -62,10 +62,10 @@ export function LegacyProtocolsBanner() {
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '} <strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
{off === 'server' {off === 'server'
? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.') ? t('legacyProtocols.bannerTail', 'on this server. Only inbuxa webmail and JMAP apps can sign in.')
: t( : t(
'legacyProtocols.bannerTailTenant', 'legacyProtocols.bannerTailTenant',
'for your organization. Only INBUXA webmail and JMAP apps can sign in.', 'for your organization. Only inbuxa webmail and JMAP apps can sign in.',
)} )}
</span> </span>
{off === 'server' && ( {off === 'server' && (
@@ -115,7 +115,7 @@ export function LegacyProtocolsPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t( {t(
'legacyProtocols.subtitle', 'legacyProtocols.subtitle',
'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only INBUXA webmail and JMAP apps can reach this server.', 'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.',
)} )}
</p> </p>
</header> </header>
@@ -206,7 +206,7 @@ function StatusCard({
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{off {off
? t('legacyProtocols.statusOffBody', 'Only INBUXA webmail and JMAP apps can sign in.') ? t('legacyProtocols.statusOffBody', 'Only inbuxa webmail and JMAP apps can sign in.')
: t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')}
{policy.changedAt !== null && ( {policy.changedAt !== null && (
<> <>
@@ -276,7 +276,7 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground"> <p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
{t( {t(
'legacyProtocols.lockNote', 'legacyProtocols.lockNote',
'Incoming mail (SMTP) and INBUXA webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.', 'Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.',
)} )}
</p> </p>
</div> </div>
@@ -102,7 +102,7 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
{off {off
? t( ? t(
'legacyProtocols.tenantOff', 'legacyProtocols.tenantOff',
'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.', 'Off for {{organization}}. Only inbuxa webmail and JMAP apps can sign in to its domains.',
{ organization: name }, { organization: name },
) )
: t( : t(
+5 -5
View File
@@ -128,7 +128,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
return ( return (
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed"> <section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
<p className="text-base font-semibold"> <p className="text-base font-semibold">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} {t('legacyProtocols.statementTitle', 'Only inbuxa webmail and JMAP apps will work.')}
</p> </p>
<p> <p>
{scope.kind === 'server' {scope.kind === 'server'
@@ -152,7 +152,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li> <li>
{t( {t(
'legacyProtocols.statementFilters', 'legacyProtocols.statementFilters',
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in inbuxa webmail keep working.',
)} )}
</li> </li>
<li> <li>
@@ -164,7 +164,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li> <li>
{t( {t(
'legacyProtocols.statementWebmail', 'legacyProtocols.statementWebmail',
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', 'People keep full access through inbuxa webmail, which can be installed as an app on phones and computers.',
)} )}
</li> </li>
</ul> </ul>
@@ -183,7 +183,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<p> <p>
{t( {t(
'legacyProtocols.statementSubmission', 'legacyProtocols.statementSubmission',
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and inbuxa webmail (JMAP) are not affected and cannot be turned off here.',
)} )}
</p> </p>
{scope.kind === 'server' && ( {scope.kind === 'server' && (
@@ -191,7 +191,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '} <strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
{t( {t(
'legacyProtocols.firewallBody', 'legacyProtocols.firewallBody',
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', 'inbuxa stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
)} )}
</p> </p>
)} )}
+1 -1
View File
@@ -244,7 +244,7 @@ export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]
}, },
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever"). // Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] }, { key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] }, { key: 'jmap', label: 'JMAP (inbuxa webmail)', state: 'locked', ports: [] },
]; ];
} }
+1 -1
View File
@@ -104,7 +104,7 @@ export const PAGE_HELP: Record<string, PageHelp> = {
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.', about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
}, },
'x:OAuthClient': { 'x:OAuthClient': {
about: 'Apps allowed to sign people in through this server, like INBUXA webmail and INBUXA Admin.', about: 'Apps allowed to sign people in through this server, like inbuxa webmail and inbuxa Admin.',
}, },
'x:DkimSignature': { 'x:DkimSignature': {
about: 'The keys that sign outgoing mail so receivers can check it really came from you.', about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
+1 -1
View File
@@ -9,7 +9,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
const APP_NAME = 'INBUXA Admin'; const APP_NAME = 'inbuxa Admin';
export function useDocumentTitle(title?: string | null) { export function useDocumentTitle(title?: string | null) {
useEffect(() => { useEffect(() => {
+5 -5
View File
@@ -15,7 +15,7 @@
"bootstrap": { "bootstrap": {
"clipboardBlocked": "Your browser blocked clipboard access.", "clipboardBlocked": "Your browser blocked clipboard access.",
"complete": "Setup complete", "complete": "Setup complete",
"configuredSuccessfully": "INBUXA has been configured successfully.", "configuredSuccessfully": "inbuxa has been configured successfully.",
"copyFailed": "Copy failed", "copyFailed": "Copy failed",
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.", "credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.", "emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
@@ -23,11 +23,11 @@
"failedToLoad": "Failed to load bootstrap state.", "failedToLoad": "Failed to load bootstrap state.",
"finishSetup": "Finish setup", "finishSetup": "Finish setup",
"loadingSetup": "Loading setup...", "loadingSetup": "Loading setup...",
"nextStepBody": "restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.", "nextStepBody": "restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
"nextStepLabel": "Next step:", "nextStepLabel": "Next step:",
"noConfirm": "The server did not confirm the update.", "noConfirm": "The server did not confirm the update.",
"stepOf": "Step {{current}} of {{total}}", "stepOf": "Step {{current}} of {{total}}",
"welcome": "Welcome to INBUXA", "welcome": "Welcome to inbuxa",
"welcomeSubtitle": "Let's get your server set up." "welcomeSubtitle": "Let's get your server set up."
}, },
"common": { "common": {
@@ -295,11 +295,11 @@
}, },
"logo": { "logo": {
"alt": "Logo", "alt": "Logo",
"inbuxaAlt": "INBUXA" "inbuxaAlt": "inbuxa"
}, },
"logout": "Logout", "logout": "Logout",
"version": { "version": {
"label": "INBUXA Admin {{version}}" "label": "inbuxa Admin {{version}}"
}, },
"nav": { "nav": {
"layoutLegacy": "Legacy", "layoutLegacy": "Legacy",
+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
@@ -11,4 +11,4 @@
* this fork. The version shown beside the link names the build, which is what * this fork. The version shown beside the link names the build, which is what
* makes the offer something a person can act on. * makes the offer something a person can act on.
*/ */
export const SOURCE_URL = 'https://github.com/inbuxa/inbuxa-admin'; export const SOURCE_URL = 'https://git.coffeylabs.org/inbuxa/inbuxa-admin';
+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',