Compare commits
16
Commits
7d5f2273ae
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1c3c8b0e | ||
|
|
cf6047eb8b | ||
|
|
a7111b90af | ||
|
|
28c49b4056 | ||
|
|
dbf911e0a6 | ||
|
|
c60f3be5ca | ||
|
|
a4fcd8cffe | ||
|
|
d0eddd2cda | ||
|
|
fa8f5abbad | ||
|
|
ba1b8b74b7 | ||
|
|
0191f7e10c | ||
|
|
7e3418079c | ||
|
|
0ea86b6be0 | ||
|
|
3e7bb80f73 | ||
|
|
4eb838042c | ||
|
|
14ccb05e52 |
@@ -39,7 +39,7 @@ concurrency:
|
||||
jobs:
|
||||
# A release tag is built and tested again before its image is published.
|
||||
build:
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
env:
|
||||
|
||||
@@ -55,7 +55,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
weekly-release:
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
container:
|
||||
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||
env:
|
||||
|
||||
+1
-1
@@ -32,6 +32,6 @@ session.
|
||||
|
||||
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
|
||||
[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
|
||||
to you.
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.9.21.2"
|
||||
"version": "2026.9.23"
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ export function BootstrapWizard() {
|
||||
<WizardShell>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
|
||||
</p>
|
||||
@@ -471,7 +471,7 @@ function SuccessScreen({
|
||||
'bootstrap.credentialsCreated',
|
||||
'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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,7 +503,7 @@ function SuccessScreen({
|
||||
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
|
||||
{t(
|
||||
'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>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function DefaultLogo() {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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]"
|
||||
>
|
||||
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
|
||||
|
||||
@@ -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';
|
||||
@@ -62,6 +63,7 @@ import { logFormChange } from '@/lib/debug';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
|
||||
import { isSieveScriptField } from '@/lib/sievepad';
|
||||
import { reloadActionFor } from '@/lib/settingsApply';
|
||||
|
||||
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
||||
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
||||
@@ -305,7 +307,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],
|
||||
@@ -524,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
setCreatedObjectId(newId);
|
||||
setServerCreatedProps(extraProps);
|
||||
} else {
|
||||
// inbuxa: a settings object says "Saved and applied" once the server has it.
|
||||
if (!reloadActionFor(obj.objectName)) {
|
||||
toast({
|
||||
title: t('form.createdSuccess', 'Created successfully'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
setOriginalData({ ...formData });
|
||||
navigate(`/${section}/${viewName}`);
|
||||
}
|
||||
@@ -611,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||
|
||||
if (setResult.updated && updateId in setResult.updated) {
|
||||
// inbuxa: a settings object says "Saved and applied" once the server has it.
|
||||
if (!reloadActionFor(obj.objectName)) {
|
||||
toast({
|
||||
title: t('form.savedSuccess', 'Saved successfully'),
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleton) {
|
||||
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
|
||||
@@ -782,6 +794,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
</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) => (
|
||||
<Card key={sectionIdx}>
|
||||
{section.title && (
|
||||
@@ -838,7 +865,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
<FieldWidget
|
||||
key={formField.name}
|
||||
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}
|
||||
onChange={(v) => handleFieldChange(formField.name, v)}
|
||||
readOnly={fieldReadOnly}
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
|
||||
|
||||
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'INBUXA',
|
||||
issuer: 'inbuxa',
|
||||
label: 'account',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
|
||||
@@ -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 <LegacyProtocolsPage />;
|
||||
}
|
||||
if (componentName === 'LocalAi') {
|
||||
return <LocalAiPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown component: {componentName}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: shown when saved settings are stored but the server couldn't apply
|
||||
* them. It stays until they are applied or it is dismissed, names the object
|
||||
* the server couldn't build and links to it, and offers to try again.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { humanize } from '@/lib/humanize';
|
||||
import { resolveList } from '@/lib/schemaResolver';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useSettingsApplyStore } from '@/stores/settingsApplyStore';
|
||||
|
||||
export function SettingsApplyBanner() {
|
||||
const { t } = useTranslation();
|
||||
const failure = useSettingsApplyStore((s) => s.failure);
|
||||
const applying = useSettingsApplyStore((s) => s.applying);
|
||||
const applyNow = useSettingsApplyStore((s) => s.applyNow);
|
||||
const dismiss = useSettingsApplyStore((s) => s.dismiss);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
|
||||
if (!failure) return null;
|
||||
|
||||
let objectLabel: string | null = null;
|
||||
let objectLink: string | null = null;
|
||||
if (failure.object) {
|
||||
const objectName = `x:${failure.object.object}`;
|
||||
const objectType = schema?.objects[objectName];
|
||||
const list = schema ? resolveList(schema, objectName, objectName) : null;
|
||||
objectLabel = list?.singularName ?? humanize(failure.object.object);
|
||||
const section = viewToSection[objectName];
|
||||
if (section) {
|
||||
objectLink =
|
||||
objectType?.type === 'singleton' || failure.object.id === 'singleton'
|
||||
? `/${section}/${objectName}`
|
||||
: `/${section}/${objectName}/${encodeURIComponent(failure.object.id)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">
|
||||
{t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", {
|
||||
reason: failure.message,
|
||||
})}
|
||||
</p>
|
||||
{objectLabel && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '}
|
||||
{objectLink && (
|
||||
<Link to={objectLink} className="font-medium text-primary hover:underline">
|
||||
{t('settingsApply.openObject', 'Open it')}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'settingsApply.stillRunning',
|
||||
'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => void applyNow()} disabled={applying}>
|
||||
{applying && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
{t('settingsApply.applyNow', 'Apply now')}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={dismiss}
|
||||
aria-label={t('settingsApply.dismiss', 'Dismiss')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,7 +104,7 @@ export function TopBar() {
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })}
|
||||
{t('version.label', 'inbuxa Admin {{version}}', { version: __APP_VERSION__ })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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 didn’t 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 doesn’t 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.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 },
|
||||
)}
|
||||
</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 doesn’t 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 model’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')}{' '}
|
||||
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
|
||||
{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(
|
||||
'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>
|
||||
{off === 'server' && (
|
||||
|
||||
@@ -115,7 +115,7 @@ export function LegacyProtocolsPage() {
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'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>
|
||||
</header>
|
||||
@@ -206,7 +206,7 @@ function StatusCard({
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.')}
|
||||
{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">
|
||||
{t(
|
||||
'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>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
|
||||
{off
|
||||
? t(
|
||||
'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 },
|
||||
)
|
||||
: t(
|
||||
|
||||
@@ -128,7 +128,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
||||
return (
|
||||
<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">
|
||||
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
|
||||
{t('legacyProtocols.statementTitle', 'Only inbuxa webmail and JMAP apps will work.')}
|
||||
</p>
|
||||
<p>
|
||||
{scope.kind === 'server'
|
||||
@@ -152,7 +152,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
||||
<li>
|
||||
{t(
|
||||
'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>
|
||||
@@ -164,7 +164,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
||||
<li>
|
||||
{t(
|
||||
'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>
|
||||
</ul>
|
||||
@@ -183,7 +183,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
||||
<p>
|
||||
{t(
|
||||
'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>
|
||||
{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>{' '}
|
||||
{t(
|
||||
'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>
|
||||
)}
|
||||
|
||||
@@ -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").
|
||||
{ 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
@@ -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.',
|
||||
},
|
||||
'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': {
|
||||
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const APP_NAME = 'INBUXA Admin';
|
||||
const APP_NAME = 'inbuxa Admin';
|
||||
|
||||
export function useDocumentTitle(title?: string | null) {
|
||||
useEffect(() => {
|
||||
|
||||
+17
-5
@@ -15,7 +15,7 @@
|
||||
"bootstrap": {
|
||||
"clipboardBlocked": "Your browser blocked clipboard access.",
|
||||
"complete": "Setup complete",
|
||||
"configuredSuccessfully": "INBUXA has been configured successfully.",
|
||||
"configuredSuccessfully": "inbuxa has been configured successfully.",
|
||||
"copyFailed": "Copy failed",
|
||||
"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.",
|
||||
@@ -23,11 +23,11 @@
|
||||
"failedToLoad": "Failed to load bootstrap state.",
|
||||
"finishSetup": "Finish 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:",
|
||||
"noConfirm": "The server did not confirm the update.",
|
||||
"stepOf": "Step {{current}} of {{total}}",
|
||||
"welcome": "Welcome to INBUXA",
|
||||
"welcome": "Welcome to inbuxa",
|
||||
"welcomeSubtitle": "Let's get your server set up."
|
||||
},
|
||||
"common": {
|
||||
@@ -295,11 +295,11 @@
|
||||
},
|
||||
"logo": {
|
||||
"alt": "Logo",
|
||||
"inbuxaAlt": "INBUXA"
|
||||
"inbuxaAlt": "inbuxa"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"version": {
|
||||
"label": "INBUXA Admin {{version}}"
|
||||
"label": "inbuxa Admin {{version}}"
|
||||
},
|
||||
"nav": {
|
||||
"layoutLegacy": "Legacy",
|
||||
@@ -345,6 +345,18 @@
|
||||
"periodValue": "Period value"
|
||||
},
|
||||
"sections": "Sections",
|
||||
"settingsApply": {
|
||||
"applied": "Saved and applied",
|
||||
"applyNow": "Apply now",
|
||||
"dismiss": "Dismiss",
|
||||
"failed": "Saved, but the server couldn't apply the settings: {{reason}}",
|
||||
"failedObject": "The problem is in {{object}}.",
|
||||
"noAnswer": "The server did not answer.",
|
||||
"notConfirmed": "The server did not confirm the reload.",
|
||||
"notReloaded": "The server did not reload its settings.",
|
||||
"openObject": "Open it",
|
||||
"stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed."
|
||||
},
|
||||
"sievepad": {
|
||||
"debug": "Debug",
|
||||
"defaultName": "Sieve script",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -36,17 +36,17 @@ describe('getOAuthClientId', () => {
|
||||
|
||||
it('falls back to the built-in default when the placeholder is empty', async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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';
|
||||
|
||||
let cached: string | undefined;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
describeApplyFailure,
|
||||
describeRequestFailure,
|
||||
describeServerReload,
|
||||
registryWrites,
|
||||
reloadActionFor,
|
||||
reloadActionsFor,
|
||||
serverAppliesWrite,
|
||||
writesRegistry,
|
||||
} from './settingsApply';
|
||||
import type { JmapMethodResponse } from '@/types/jmap';
|
||||
|
||||
describe('reloadActionFor', () => {
|
||||
it('reloads settings objects', () => {
|
||||
expect(reloadActionFor('x:MtaDeliverySchedule')).toBe('ReloadSettings');
|
||||
expect(reloadActionFor('x:SpamPyzor')).toBe('ReloadSettings');
|
||||
expect(reloadActionFor('x:NetworkListener')).toBe('ReloadSettings');
|
||||
});
|
||||
|
||||
it('reloads a type it has never heard of', () => {
|
||||
expect(reloadActionFor('x:SomethingNew')).toBe('ReloadSettings');
|
||||
});
|
||||
|
||||
it('uses the narrower action where one exists', () => {
|
||||
expect(reloadActionFor('x:Certificate')).toBe('ReloadTlsCertificates');
|
||||
expect(reloadActionFor('x:StoreLookup')).toBe('ReloadLookupStores');
|
||||
expect(reloadActionFor('x:MemoryLookupKeyValue')).toBe('ReloadLookupStores');
|
||||
expect(reloadActionFor('x:BlockedIp')).toBe('ReloadBlockedIps');
|
||||
});
|
||||
|
||||
it('skips what the server reloads on write', () => {
|
||||
expect(reloadActionFor('x:Directory')).toBeNull();
|
||||
expect(reloadActionFor('x:Authentication')).toBeNull();
|
||||
});
|
||||
|
||||
it('reloads allowed IPs in full: they are part of the settings, not the blocked list', () => {
|
||||
expect(reloadActionFor('x:AllowedIp')).toBe('ReloadSettings');
|
||||
});
|
||||
|
||||
it('skips data read live, operations and stores', () => {
|
||||
for (const type of [
|
||||
'Account',
|
||||
'Alert',
|
||||
'DnsServer',
|
||||
'Domain',
|
||||
'DkimSignature',
|
||||
'Enterprise',
|
||||
'SpamLlm',
|
||||
'Tenant',
|
||||
'Action',
|
||||
'QueuedMessage',
|
||||
'DataStore',
|
||||
'MetricsStore',
|
||||
'TracingStore',
|
||||
]) {
|
||||
expect(reloadActionFor(`x:${type}`)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores anything outside the registry', () => {
|
||||
expect(reloadActionFor('Email')).toBeNull();
|
||||
expect(reloadActionFor('inbuxa:ProtocolPolicy')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadActionsFor', () => {
|
||||
it('collapses duplicates and puts settings last', () => {
|
||||
expect(reloadActionsFor(['x:MtaRoute', 'x:Certificate', 'x:MtaDeliverySchedule', 'x:Account'])).toEqual([
|
||||
'ReloadTlsCertificates',
|
||||
'ReloadSettings',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry writes', () => {
|
||||
it('recognizes an x: set call among others', () => {
|
||||
expect(writesRegistry([['x:MtaRoute/get', {}, '0']])).toBe(false);
|
||||
expect(
|
||||
writesRegistry([
|
||||
['Blob/upload', {}, 'b'],
|
||||
['x:MtaRoute/set', {}, '0'],
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports only the types a response changed', () => {
|
||||
const responses: JmapMethodResponse[] = [
|
||||
['Blob/upload', { created: { b: {} } }, 'b'],
|
||||
['x:MtaRoute/set', { created: null, updated: { a: null }, destroyed: null }, '0'],
|
||||
['x:SpamPyzor/set', { created: {}, updated: {}, destroyed: [], notUpdated: { singleton: {} } }, '1'],
|
||||
['x:Certificate/set', { destroyed: ['c1'] }, '2'],
|
||||
['error', { type: 'serverFail' }, '3'],
|
||||
];
|
||||
expect(registryWrites(responses)).toEqual([{ objectName: 'x:MtaRoute' }, { objectName: 'x:Certificate' }]);
|
||||
});
|
||||
|
||||
it("reads the server's own reload report where there is one", () => {
|
||||
const responses: JmapMethodResponse[] = [
|
||||
['x:MtaRoute/set', { updated: { a: null }, 'x:settingsReload': { applied: true } }, '0'],
|
||||
[
|
||||
'x:Tracer/set',
|
||||
{
|
||||
created: { t: { id: 't1' } },
|
||||
'x:settingsReload': { applied: false, description: 'Saved, but the running settings were not reloaded. x' },
|
||||
},
|
||||
'1',
|
||||
],
|
||||
['x:Domain/set', { created: { d: { id: 'd1' } } }, '2'],
|
||||
['x:MtaHook/set', { updated: { h: null }, 'x:settingsReload': 'yes' }, '3'],
|
||||
];
|
||||
expect(registryWrites(responses)).toEqual([
|
||||
{ objectName: 'x:MtaRoute', serverReload: { applied: true } },
|
||||
{
|
||||
objectName: 'x:Tracer',
|
||||
serverReload: { applied: false, description: 'Saved, but the running settings were not reloaded. x' },
|
||||
},
|
||||
{ objectName: 'x:Domain' },
|
||||
// Not the shape a server sends: treated as absent.
|
||||
{ objectName: 'x:MtaHook' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves applying to the server when it reported, except for allowed IPs', () => {
|
||||
expect(serverAppliesWrite({ objectName: 'x:MtaRoute', serverReload: { applied: true } })).toBe(true);
|
||||
expect(serverAppliesWrite({ objectName: 'x:MtaRoute', serverReload: { applied: false } })).toBe(true);
|
||||
expect(serverAppliesWrite({ objectName: 'x:MtaRoute' })).toBe(false);
|
||||
expect(serverAppliesWrite({ objectName: 'x:AllowedIp', serverReload: { applied: true } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeServerReload', () => {
|
||||
it('drops the lead-in the banner already says and names the object', () => {
|
||||
expect(
|
||||
describeServerReload({
|
||||
applied: false,
|
||||
description:
|
||||
'Saved, but the running settings were not reloaded. Tracer with id b: Only one console tracer is allowed',
|
||||
}),
|
||||
).toEqual({
|
||||
message: 'Tracer with id b: Only one console tracer is allowed',
|
||||
object: { object: 'Tracer', id: 'b' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a description it does not recognize, and copes with none', () => {
|
||||
expect(describeServerReload({ applied: false, description: 'Store unavailable' })).toEqual({
|
||||
message: 'Store unavailable',
|
||||
});
|
||||
expect(describeServerReload({ applied: false })).toEqual({ message: 'The server did not reload its settings.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeApplyFailure', () => {
|
||||
it('keeps the server message and the object it named', () => {
|
||||
expect(
|
||||
describeApplyFailure({
|
||||
type: 'validationFailed',
|
||||
description: 'Failed to resolve Pyzor host',
|
||||
objectId: { object: 'SpamPyzor', id: 'singleton' },
|
||||
}),
|
||||
).toEqual({ message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } });
|
||||
});
|
||||
|
||||
it('spells out validation errors when there is no description', () => {
|
||||
const failure = describeApplyFailure({
|
||||
type: 'validationFailed',
|
||||
objectId: { object: 'MtaRoute', id: 'b' },
|
||||
validationErrors: [{ type: 'Required', property: 'address' }],
|
||||
});
|
||||
expect(failure.message).toBe('address: This field is required.');
|
||||
expect(failure.object).toEqual({ object: 'MtaRoute', id: 'b' });
|
||||
});
|
||||
|
||||
it('falls back to the error type', () => {
|
||||
expect(describeApplyFailure({ type: 'forbidden' }).message).toBe(
|
||||
'You do not have permission to perform this action.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRequestFailure', () => {
|
||||
it('reads thrown errors and method errors', () => {
|
||||
expect(describeRequestFailure(new Error('Network down')).message).toBe('Network down');
|
||||
expect(describeRequestFailure({ type: 'forbidden', description: 'No' }).message).toBe('No');
|
||||
expect(describeRequestFailure(undefined).message).toBe('The server did not answer.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: which saved registry objects the running server has to be told to
|
||||
* apply, and how to read its answer when it can't.
|
||||
*
|
||||
* A write to the registry is stored at once, but most settings only take
|
||||
* effect when the server rebuilds its configuration from the registry: the
|
||||
* x:Action ReloadSettings action, which also carries the change to every
|
||||
* node of a cluster. Older servers do that by themselves on a write for a few
|
||||
* types only (directories and the default authentication); everything else
|
||||
* waits for a reload, which the admin sends (settingsApplyStore).
|
||||
*
|
||||
* Newer servers reload after every write that needs it and say how it went
|
||||
* in the set response's `x:settingsReload` ({applied, description}), absent
|
||||
* when the write needed no reload. A write that carries it needs nothing from
|
||||
* the admin; the table below is what an older server, which never sends it,
|
||||
* still needs, and it follows the server's own list of what each type needs.
|
||||
*
|
||||
* A reload is all or nothing: the new configuration replaces the running one
|
||||
* only when every settings object builds, so applying straight after a save
|
||||
* can't leave the server half configured. When something doesn't build, the
|
||||
* server keeps what it had and names the object and the problem.
|
||||
*/
|
||||
|
||||
import i18n from '@/i18n';
|
||||
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
||||
import type { JmapMethodCall, JmapMethodResponse, JmapObjectRef, JmapSetError } from '@/types/jmap';
|
||||
|
||||
export type ReloadAction = 'ReloadSettings' | 'ReloadTlsCertificates' | 'ReloadLookupStores' | 'ReloadBlockedIps';
|
||||
|
||||
/** The order the actions run in when more than one is due. */
|
||||
export const RELOAD_ORDER: readonly ReloadAction[] = [
|
||||
'ReloadLookupStores',
|
||||
'ReloadTlsCertificates',
|
||||
'ReloadBlockedIps',
|
||||
'ReloadSettings',
|
||||
];
|
||||
|
||||
/** Types whose own reload action applies them; the server rebuilds nothing else for these. */
|
||||
const OWN_ACTION: Record<string, ReloadAction> = {
|
||||
Certificate: 'ReloadTlsCertificates',
|
||||
StoreLookup: 'ReloadLookupStores',
|
||||
HttpLookup: 'ReloadLookupStores',
|
||||
MemoryLookupKey: 'ReloadLookupStores',
|
||||
MemoryLookupKeyValue: 'ReloadLookupStores',
|
||||
BlockedIp: 'ReloadBlockedIps',
|
||||
// Not AllowedIp: allowed addresses are part of the full settings, and only
|
||||
// ReloadSettings rebuilds them.
|
||||
};
|
||||
|
||||
/** Types that need no reload after a write. Anything not listed here is reloaded. */
|
||||
const NOTHING_TO_APPLY = new Set<string>([
|
||||
// The server reloads these itself on every write, here and on every node.
|
||||
'Directory',
|
||||
'Authentication',
|
||||
// Read from the registry when used, or kept current by cache invalidation.
|
||||
'Account',
|
||||
'AccountPassword',
|
||||
'AccountSettings',
|
||||
'Alert',
|
||||
'ApiKey',
|
||||
'AppPassword',
|
||||
'DnsServer',
|
||||
'Domain',
|
||||
'DkimSignature',
|
||||
'Enterprise',
|
||||
'MailingList',
|
||||
'MaskedEmail',
|
||||
'OAuthClient',
|
||||
'PublicKey',
|
||||
'Role',
|
||||
'SpamLlm',
|
||||
'Tenant',
|
||||
// Operations, records and telemetry rather than settings.
|
||||
'Action',
|
||||
'ArchivedItem',
|
||||
'ArfExternalReport',
|
||||
'Bootstrap',
|
||||
'ClusterNode',
|
||||
'DmarcExternalReport',
|
||||
'DmarcInternalReport',
|
||||
'Log',
|
||||
'Metric',
|
||||
'QueuedMessage',
|
||||
'SpamTrainingSample',
|
||||
'Task',
|
||||
'TlsExternalReport',
|
||||
'TlsInternalReport',
|
||||
'Trace',
|
||||
// Stores are opened once at startup and a reload keeps the ones it has, so
|
||||
// saying "applied" would be untrue. They take effect on a restart.
|
||||
'BlobStore',
|
||||
'Coordinator',
|
||||
'DataStore',
|
||||
'InMemoryStore',
|
||||
'MetricsStore',
|
||||
'SearchStore',
|
||||
'TracingStore',
|
||||
// Applications are unpacked by their own manager, which no reload reaches.
|
||||
'Application',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The action that applies a write to `objectName` (an `x:` registry name), or
|
||||
* null when the write needs none. Types this list doesn't know are reloaded:
|
||||
* an unneeded reload costs a second, a missing one leaves a setting unapplied.
|
||||
*/
|
||||
export function reloadActionFor(objectName: string): ReloadAction | null {
|
||||
if (!objectName.startsWith('x:')) return null;
|
||||
const type = objectName.slice(2);
|
||||
if (NOTHING_TO_APPLY.has(type)) return null;
|
||||
return OWN_ACTION[type] ?? 'ReloadSettings';
|
||||
}
|
||||
|
||||
const REGISTRY_SET = /^(x:[A-Za-z0-9]+)\/set$/;
|
||||
|
||||
/** Whether a request writes to the registry: any `x:<Type>/set` call. */
|
||||
export function writesRegistry(methodCalls: JmapMethodCall[]): boolean {
|
||||
return methodCalls.some(([name]) => REGISTRY_SET.test(name));
|
||||
}
|
||||
|
||||
/** What a newer server says about applying a registry write (`x:settingsReload`). */
|
||||
export interface ServerReload {
|
||||
/** The running settings, on every node, include the write. */
|
||||
applied: boolean;
|
||||
/** Why they don't, when they don't. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A registry type a request created, changed or destroyed, and what the server said about applying it. */
|
||||
export interface RegistryWrite {
|
||||
objectName: string;
|
||||
/** Absent from older servers, and from newer ones when the write needed no reload. */
|
||||
serverReload?: ServerReload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Types whose `x:settingsReload` doesn't tell the whole story. The server
|
||||
* answers an AllowedIp write with the blocked-IP reload, but allowed
|
||||
* addresses are only rebuilt by a full reload, so the admin still sends one.
|
||||
*/
|
||||
const SERVER_RELOAD_INCOMPLETE = new Set<string>(['x:AllowedIp']);
|
||||
|
||||
/** Whether a write's `x:settingsReload` means the admin has nothing to send for it. */
|
||||
export function serverAppliesWrite(write: RegistryWrite): boolean {
|
||||
return write.serverReload !== undefined && !SERVER_RELOAD_INCOMPLETE.has(write.objectName);
|
||||
}
|
||||
|
||||
function readServerReload(value: unknown): ServerReload | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const { applied, description } = value as { applied?: unknown; description?: unknown };
|
||||
if (typeof applied !== 'boolean') return undefined;
|
||||
return typeof description === 'string' && description ? { applied, description } : { applied };
|
||||
}
|
||||
|
||||
/** The registry writes in a response: one per `x:<Type>/set` that created, changed or destroyed something. */
|
||||
export function registryWrites(methodResponses: JmapMethodResponse[]): RegistryWrite[] {
|
||||
const writes: RegistryWrite[] = [];
|
||||
for (const [name, result] of methodResponses) {
|
||||
const match = REGISTRY_SET.exec(name);
|
||||
if (!match || !result) continue;
|
||||
const created = result.created as Record<string, unknown> | null | undefined;
|
||||
const updated = result.updated as Record<string, unknown> | null | undefined;
|
||||
const destroyed = result.destroyed as unknown[] | null | undefined;
|
||||
if (
|
||||
(created && Object.keys(created).length > 0) ||
|
||||
(updated && Object.keys(updated).length > 0) ||
|
||||
(destroyed && destroyed.length > 0)
|
||||
) {
|
||||
const serverReload = readServerReload(result['x:settingsReload']);
|
||||
writes.push(serverReload ? { objectName: match[1], serverReload } : { objectName: match[1] });
|
||||
}
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
/** The actions due for a set of written types, in the order they run. */
|
||||
export function reloadActionsFor(objectNames: Iterable<string>): ReloadAction[] {
|
||||
const due = new Set<ReloadAction>();
|
||||
for (const name of objectNames) {
|
||||
const action = reloadActionFor(name);
|
||||
if (action) due.add(action);
|
||||
}
|
||||
return RELOAD_ORDER.filter((a) => due.has(a));
|
||||
}
|
||||
|
||||
export interface ApplyFailure {
|
||||
/** What went wrong, in the server's words where it gave any. */
|
||||
message: string;
|
||||
/** The settings object that didn't build, when the server named one. */
|
||||
object?: JmapObjectRef;
|
||||
}
|
||||
|
||||
/** Reads a failed reload action into something to show an administrator. */
|
||||
export function describeApplyFailure(err: JmapSetError): ApplyFailure {
|
||||
let message = err.description?.trim() ?? '';
|
||||
if (!message && err.validationErrors && err.validationErrors.length > 0) {
|
||||
message = err.validationErrors
|
||||
.map((ve) => (ve.property ? `${ve.property}: ${validationErrorMessage(ve)}` : validationErrorMessage(ve)))
|
||||
.join('; ');
|
||||
}
|
||||
if (!message) message = friendlySetError(err);
|
||||
const object = typeof err.objectId === 'object' && err.objectId?.object ? err.objectId : undefined;
|
||||
return object ? { message, object } : { message };
|
||||
}
|
||||
|
||||
/** A failure that came back as a method error or a failed request rather than a set error. */
|
||||
export function describeRequestFailure(err: unknown): ApplyFailure {
|
||||
if (err instanceof Error && err.message) return { message: err.message };
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { type?: unknown; description?: unknown };
|
||||
if (typeof e.description === 'string' && e.description) return { message: e.description };
|
||||
if (typeof e.type === 'string' && e.type) {
|
||||
return { message: i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: e.type }) };
|
||||
}
|
||||
}
|
||||
return { message: i18n.t('settingsApply.noAnswer', 'The server did not answer.') };
|
||||
}
|
||||
|
||||
/** How the server starts a refused reload's description; the banner says the same in its own words. */
|
||||
const SERVER_RELOAD_PREFIX = 'Saved, but the running settings were not reloaded. ';
|
||||
/** How the server names the object that didn't build: "<Type> with id <id>: <error>". */
|
||||
const SERVER_RELOAD_OBJECT = /^([A-Z][A-Za-z0-9]*) with id ([^\s:]+): /;
|
||||
|
||||
/** Reads a server's refused reload (`x:settingsReload` with applied: false) into something to show. */
|
||||
export function describeServerReload(reload: ServerReload): ApplyFailure {
|
||||
let message = reload.description?.trim() ?? '';
|
||||
if (message.startsWith(SERVER_RELOAD_PREFIX)) message = message.slice(SERVER_RELOAD_PREFIX.length).trim();
|
||||
if (!message) {
|
||||
return { message: i18n.t('settingsApply.notReloaded', 'The server did not reload its settings.') };
|
||||
}
|
||||
const match = SERVER_RELOAD_OBJECT.exec(message);
|
||||
return match ? { message, object: { object: match[1], id: match[2] } } : { message };
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
* this fork. The version shown beside the link names the build, which is what
|
||||
* 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';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { SectionNav } from '@/components/layout/SectionNav';
|
||||
import { MainContent } from '@/components/layout/MainContent';
|
||||
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import {
|
||||
@@ -320,6 +321,7 @@ export default function AdminPanel() {
|
||||
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
|
||||
)}
|
||||
>
|
||||
<SettingsApplyBanner />
|
||||
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||
<MainContent viewName={viewName} id={id} section={section} />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -12,10 +12,11 @@ import { apiFetch } from '@/services/api';
|
||||
import { logJmapExchange } from '@/lib/debug';
|
||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||
import type { Schema } from '@/types/schema';
|
||||
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
|
||||
|
||||
const JMAP_USING = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:stalwart:jmap',
|
||||
'urn:inbuxa:jmap:registry',
|
||||
'urn:ietf:params:jmap:blob',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:calendars',
|
||||
@@ -35,10 +36,45 @@ export function getAccountId(objectType: string): string {
|
||||
return activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* inbuxa: told about every registry write, so that saved settings can be
|
||||
* applied on the server (settingsApplyStore). `started` comes before the
|
||||
* request goes out and `finished` after it settles, with the types it changed
|
||||
* and what the server said about applying them.
|
||||
*/
|
||||
export interface RegistryWriteListener {
|
||||
started(): void;
|
||||
finished(writes: RegistryWrite[]): void;
|
||||
}
|
||||
|
||||
let registryWriteListener: RegistryWriteListener | null = null;
|
||||
|
||||
export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
|
||||
registryWriteListener = listener;
|
||||
}
|
||||
|
||||
export async function jmapRequest(
|
||||
methodCalls: JmapMethodCall[],
|
||||
signal?: AbortSignal,
|
||||
extraUsing: string[] = [],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
|
||||
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
|
||||
listener.started();
|
||||
let written: RegistryWrite[] = [];
|
||||
try {
|
||||
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
|
||||
written = registryWrites(responses);
|
||||
return responses;
|
||||
} finally {
|
||||
listener.finished(written);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendJmapRequest(
|
||||
methodCalls: JmapMethodCall[],
|
||||
signal?: AbortSignal,
|
||||
extraUsing: string[] = [],
|
||||
): Promise<JmapMethodResponse[]> {
|
||||
const { apiUrl } = useAuthStore.getState();
|
||||
let path = apiUrl || '/jmap';
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { RegistryWriteListener } from '@/services/jmap/client';
|
||||
import type { RegistryWrite } from '@/lib/settingsApply';
|
||||
import type { JmapMethodCall, JmapMethodResponse } from '@/types/jmap';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
jmapRequest: vi.fn<(calls: JmapMethodCall[]) => Promise<JmapMethodResponse[]>>(),
|
||||
listener: null as RegistryWriteListener | null,
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/jmap/client', () => ({
|
||||
getAccountId: () => 'admin',
|
||||
jmapRequest: mocks.jmapRequest,
|
||||
setRegistryWriteListener: (l: RegistryWriteListener | null) => {
|
||||
mocks.listener = l;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-toast', () => ({ toast: mocks.toast }));
|
||||
|
||||
import { APPLY_DELAY_MS, resetSettingsApplyForTests, useSettingsApplyStore } from './settingsApplyStore';
|
||||
|
||||
// A save on an older server: no x:settingsReload in the response.
|
||||
function save(...types: string[]) {
|
||||
mocks.listener!.started();
|
||||
mocks.listener!.finished(types.map((objectName) => ({ objectName })));
|
||||
}
|
||||
|
||||
// A save on a newer server, which applied it or said why not.
|
||||
function serverSave(objectName: string, applied: boolean, description?: string) {
|
||||
const write: RegistryWrite = { objectName, serverReload: description ? { applied, description } : { applied } };
|
||||
mocks.listener!.started();
|
||||
mocks.listener!.finished([write]);
|
||||
}
|
||||
|
||||
const REFUSED =
|
||||
'Saved, but the running settings were not reloaded. Tracer with id b: Only one console tracer is allowed';
|
||||
|
||||
function reloadCreates(call: JmapMethodCall): unknown[] {
|
||||
expect(call[0]).toBe('x:Action/set');
|
||||
return Object.values((call[1] as { create: Record<string, unknown> }).create);
|
||||
}
|
||||
|
||||
function answer(created: string[], notCreated: Record<string, unknown> = {}): JmapMethodResponse[] {
|
||||
return [['x:Action/set', { created: Object.fromEntries(created.map((k) => [k, { id: k }])), notCreated }, 'reload']];
|
||||
}
|
||||
|
||||
describe('settingsApplyStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.jmapRequest.mockReset();
|
||||
mocks.toast.mockReset();
|
||||
resetSettingsApplyForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('registers with the JMAP client', () => {
|
||||
expect(mocks.listener).not.toBeNull();
|
||||
});
|
||||
|
||||
it('applies a settings save once writes settle, and says so', async () => {
|
||||
mocks.jmapRequest.mockResolvedValue(answer(['reload-0']));
|
||||
save('x:MtaDeliverySchedule');
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
|
||||
expect(mocks.toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Saved and applied', variant: 'success' }),
|
||||
);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], applying: false, failure: null });
|
||||
});
|
||||
|
||||
it('sends one reload for a burst of saves', async () => {
|
||||
mocks.jmapRequest.mockResolvedValue(answer(['reload-0', 'reload-1']));
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2);
|
||||
save('x:MtaRoute');
|
||||
// A save still in flight holds the reload back however long it takes.
|
||||
mocks.listener!.started();
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 5);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
mocks.listener!.finished([{ objectName: 'x:Certificate' }]);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([
|
||||
{ '@type': 'ReloadTlsCertificates' },
|
||||
{ '@type': 'ReloadSettings' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing for writes the server applies itself', async () => {
|
||||
save('x:Directory', 'x:Account', 'x:Domain');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a failed reload with the object and message, and retries on Apply now', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(
|
||||
answer([], {
|
||||
'reload-0': {
|
||||
type: 'validationFailed',
|
||||
description: 'Failed to resolve Pyzor host',
|
||||
objectId: { object: 'SpamPyzor', id: 'singleton' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
save('x:MtaDeliverySchedule');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||
pending: ['ReloadSettings'],
|
||||
failure: { message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } },
|
||||
});
|
||||
|
||||
// An unrelated save doesn't rerun a reload that is known to fail.
|
||||
save('x:Account');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
await useSettingsApplyStore.getState().applyNow();
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' }));
|
||||
});
|
||||
|
||||
it('tries again by itself when a later save needs a reload', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
|
||||
save('x:SpamPyzor');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
|
||||
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
save('x:SpamPyzor');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
|
||||
expect(useSettingsApplyStore.getState().failure).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a request that fails outright', async () => {
|
||||
mocks.jmapRequest.mockRejectedValueOnce(new Error('Network down'));
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||
pending: ['ReloadSettings'],
|
||||
failure: { message: 'Network down' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a method error', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce([['error', { type: 'forbidden', description: 'Not allowed' }, 'reload']]);
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState().failure).toEqual({ message: 'Not allowed' });
|
||||
});
|
||||
|
||||
it('dismissing hides the failure but keeps the reload queued', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
useSettingsApplyStore.getState().dismiss();
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ failure: null, pending: ['ReloadSettings'] });
|
||||
});
|
||||
describe('a server that applies writes itself', () => {
|
||||
it('sends nothing and says "Saved and applied" once per burst', async () => {
|
||||
serverSave('x:MtaDeliverySchedule', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2);
|
||||
serverSave('x:MtaRoute', true);
|
||||
serverSave('x:Certificate', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Saved and applied', variant: 'success' }),
|
||||
);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
});
|
||||
|
||||
it("leaves the toast to the form for types it reloads that the admin doesn't", async () => {
|
||||
serverSave('x:Directory', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server's reason, and Apply now sends ReloadSettings", async () => {
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||
pending: ['ReloadSettings'],
|
||||
failure: {
|
||||
message: 'Tracer with id b: Only one console tracer is allowed',
|
||||
object: { object: 'Tracer', id: 'b' },
|
||||
},
|
||||
});
|
||||
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
await useSettingsApplyStore.getState().applyNow();
|
||||
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' }));
|
||||
});
|
||||
|
||||
it('lets a later write in the burst that applied have the last word', async () => {
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
serverSave('x:Tracer', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears an earlier failure once a later write applies', async () => {
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
|
||||
|
||||
serverSave('x:Tracer', true);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry a refused reload on an unrelated save', async () => {
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
save('x:Account');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still reloads allowed IPs in full: the report only covers the blocked list', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
serverSave('x:AllowedIp', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a burst mixing both kinds of answer', () => {
|
||||
it('sends only what the server left to the admin, and says so once', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
serverSave('x:MtaRoute', true);
|
||||
serverSave('x:Certificate', true);
|
||||
// A type the server doesn't answer for, as an older node would.
|
||||
save('x:SomethingNew');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
});
|
||||
|
||||
it("doesn't take back a reload queued after the server applied one", async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
save('x:MtaRoute');
|
||||
serverSave('x:MtaHook', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
// The server's reload came after the first write and covers it.
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
// Nothing is left over to send with a later refusal either.
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||
serverSave('x:Tracer', true);
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(2);
|
||||
|
||||
serverSave('x:MtaHook', true);
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("lets the admin's reload settle a server refusal in the same burst", async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
save('x:SomethingNew');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||
expect(mocks.toast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows a refusal once when the admin reload fails too', async () => {
|
||||
mocks.jmapRequest.mockResolvedValueOnce(
|
||||
answer([], { 'reload-0': { type: 'validationFailed', description: 'Only one console tracer is allowed' } }),
|
||||
);
|
||||
serverSave('x:Tracer', false, REFUSED);
|
||||
save('x:SomethingNew');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast).not.toHaveBeenCalled();
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||
pending: ['ReloadSettings'],
|
||||
failure: { message: 'Only one console tracer is allowed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a server answer that lands while a reload is out once it is back', async () => {
|
||||
let answerReload: (r: JmapMethodResponse[]) => void = () => {};
|
||||
mocks.jmapRequest.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
answerReload = resolve;
|
||||
}),
|
||||
);
|
||||
save('x:MtaRoute');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
expect(useSettingsApplyStore.getState().applying).toBe(true);
|
||||
|
||||
serverSave('x:Certificate', false, 'Saved, but the running settings were not reloaded. Bad key');
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
answerReload(answer(['reload-0']));
|
||||
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||
|
||||
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||
pending: ['ReloadTlsCertificates'],
|
||||
failure: { message: 'Bad key' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: applies saved settings on the running server.
|
||||
*
|
||||
* A newer server applies a registry write itself and says how it went in the
|
||||
* set response (`x:settingsReload`); the admin only reports that. An older
|
||||
* server doesn't, so every write there that needs it queues a reload action
|
||||
* (see lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS
|
||||
* the queued actions go out together in one x:Action/set, so a bulk edit, or
|
||||
* a page that saves several objects in a row, costs one reload rather than
|
||||
* one per object.
|
||||
*
|
||||
* Either way a burst of saves ends in one result: a "Saved and applied" toast,
|
||||
* or SettingsApplyBanner with why the settings weren't applied. What wasn't
|
||||
* applied stays queued until it is, by "Apply now", by the next save that
|
||||
* needs the same reload, or by the server applying a later write of that kind.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import i18n from '@/i18n';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { getAccountId, jmapRequest, setRegistryWriteListener } from '@/services/jmap/client';
|
||||
import {
|
||||
RELOAD_ORDER,
|
||||
describeApplyFailure,
|
||||
describeRequestFailure,
|
||||
describeServerReload,
|
||||
reloadActionFor,
|
||||
serverAppliesWrite,
|
||||
type ApplyFailure,
|
||||
type RegistryWrite,
|
||||
type ReloadAction,
|
||||
} from '@/lib/settingsApply';
|
||||
import type { JmapSetError } from '@/types/jmap';
|
||||
|
||||
export const APPLY_DELAY_MS = 600;
|
||||
|
||||
interface SettingsApplyState {
|
||||
/** Actions not known to be applied: queued by saves, or left over from a failed attempt. */
|
||||
pending: ReloadAction[];
|
||||
applying: boolean;
|
||||
/** Why the last attempt didn't apply, until one does. */
|
||||
failure: ApplyFailure | null;
|
||||
/** Takes note of registry writes: what the server applied, and what the admin has to. */
|
||||
noteWrites: (writes: RegistryWrite[]) => void;
|
||||
/** Sends whatever is queued now, without waiting. */
|
||||
applyNow: () => Promise<void>;
|
||||
/** Hides the failure. What failed stays queued for the next save. */
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let writesInFlight = 0;
|
||||
let applyAgain = false;
|
||||
// Whether a save has queued something since the last attempt. After a failed
|
||||
// reload, only a save that needs one tries again by itself; editing an
|
||||
// account, say, doesn't rerun a reload that is known to fail.
|
||||
let freshlyQueued = false;
|
||||
// What the server reported for the writes since the burst last settled: that
|
||||
// it applied a settings object, and the reloads it couldn't apply (the last
|
||||
// word per action).
|
||||
let serverReported = false;
|
||||
let serverApplied = false;
|
||||
const serverFailures = new Map<ReloadAction, ApplyFailure>();
|
||||
|
||||
function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] {
|
||||
const all = new Set([...a, ...b]);
|
||||
return RELOAD_ORDER.filter((x) => all.has(x));
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
if (writesInFlight > 0) return;
|
||||
const { pending, failure } = useSettingsApplyStore.getState();
|
||||
const send = pending.length > 0 && (freshlyQueued || (!failure && serverFailures.size === 0));
|
||||
if (!send && !serverReported) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
settle();
|
||||
}, APPLY_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Ends a burst of saves: sends what the admin has to, or reports what the server did. */
|
||||
function settle() {
|
||||
const store = useSettingsApplyStore.getState();
|
||||
if (store.applying) {
|
||||
// Report once the reload that is out comes back.
|
||||
applyAgain = true;
|
||||
return;
|
||||
}
|
||||
const serverFailure = [...serverFailures.values()].pop() ?? null;
|
||||
const announce = serverApplied;
|
||||
serverReported = false;
|
||||
serverApplied = false;
|
||||
serverFailures.clear();
|
||||
|
||||
if (serverFailure) useSettingsApplyStore.setState({ failure: serverFailure });
|
||||
const { pending, failure } = useSettingsApplyStore.getState();
|
||||
if (pending.length > 0 && (freshlyQueued || !failure)) {
|
||||
// An older server's writes, or a reload a dismissed failure left queued:
|
||||
// the admin's reload speaks for the whole burst.
|
||||
void store.applyNow();
|
||||
return;
|
||||
}
|
||||
if (announce && !failure) {
|
||||
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) => ({
|
||||
pending: [],
|
||||
applying: false,
|
||||
failure: null,
|
||||
|
||||
noteWrites: (writes) => {
|
||||
let pending = get().pending;
|
||||
for (const write of writes) {
|
||||
const own = reloadActionFor(write.objectName);
|
||||
if (write.serverReload && serverAppliesWrite(write)) {
|
||||
// The server has applied this write, or tried to: nothing to send.
|
||||
// A type the admin sends nothing for, a directory say, was a full reload.
|
||||
const action = own ?? 'ReloadSettings';
|
||||
serverReported = true;
|
||||
if (write.serverReload.applied) {
|
||||
serverFailures.delete(action);
|
||||
pending = pending.filter((a) => a !== action);
|
||||
// Only settings objects say "Saved and applied"; the rest have their form's toast.
|
||||
if (own) serverApplied = true;
|
||||
} else {
|
||||
serverFailures.set(action, describeServerReload(write.serverReload));
|
||||
pending = merge(pending, [action]);
|
||||
}
|
||||
} else if (own) {
|
||||
// An older server: the admin applies it.
|
||||
freshlyQueued = true;
|
||||
pending = merge(pending, [own]);
|
||||
}
|
||||
}
|
||||
if (pending.length === 0) {
|
||||
// The server has applied everything queued, and whatever failed before.
|
||||
freshlyQueued = false;
|
||||
set({ pending, failure: null });
|
||||
} else {
|
||||
set({ pending });
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
|
||||
applyNow: async () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
if (get().applying) {
|
||||
// A save landed while the last reload was out: go again once it's back.
|
||||
applyAgain = true;
|
||||
return;
|
||||
}
|
||||
const actions = get().pending;
|
||||
if (actions.length === 0) return;
|
||||
|
||||
freshlyQueued = false;
|
||||
// This reload comes after every write so far, so it has the last word on
|
||||
// what the server said about them.
|
||||
for (const action of actions) serverFailures.delete(action);
|
||||
set({ applying: true, pending: [] });
|
||||
let notApplied: ReloadAction[] = [];
|
||||
let failure: ApplyFailure | null = null;
|
||||
try {
|
||||
const create: Record<string, Record<string, unknown>> = {};
|
||||
actions.forEach((action, i) => {
|
||||
create[`reload-${i}`] = { '@type': action };
|
||||
});
|
||||
const responses = await jmapRequest([
|
||||
['x:Action/set', { accountId: getAccountId('x:Action'), create }, 'reload'],
|
||||
]);
|
||||
const [name, result] = responses[responses.length - 1] ?? [];
|
||||
if (name !== 'x:Action/set' || !result) {
|
||||
notApplied = actions;
|
||||
failure = describeRequestFailure(result);
|
||||
} else {
|
||||
const created = (result.created ?? {}) as Record<string, unknown>;
|
||||
const notCreated = (result.notCreated ?? {}) as Record<string, JmapSetError>;
|
||||
for (const [i, action] of actions.entries()) {
|
||||
const key = `reload-${i}`;
|
||||
if (key in created) continue;
|
||||
notApplied.push(action);
|
||||
failure ??= notCreated[key]
|
||||
? describeApplyFailure(notCreated[key])
|
||||
: { message: i18n.t('settingsApply.notConfirmed', 'The server did not confirm the reload.') };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
notApplied = actions;
|
||||
failure = describeRequestFailure(err);
|
||||
}
|
||||
|
||||
set({ applying: false, pending: merge(get().pending, notApplied), failure });
|
||||
if (!failure) {
|
||||
serverApplied = false;
|
||||
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
|
||||
}
|
||||
|
||||
if (applyAgain || serverReported) {
|
||||
applyAgain = false;
|
||||
schedule();
|
||||
}
|
||||
},
|
||||
|
||||
dismiss: () => set({ failure: null }),
|
||||
}));
|
||||
|
||||
setRegistryWriteListener({
|
||||
started() {
|
||||
writesInFlight++;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
finished(writes) {
|
||||
writesInFlight = Math.max(0, writesInFlight - 1);
|
||||
useSettingsApplyStore.getState().noteWrites(writes);
|
||||
},
|
||||
});
|
||||
|
||||
/** Test hook: forget timers and counters between cases. */
|
||||
export function resetSettingsApplyForTests() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
writesInFlight = 0;
|
||||
applyAgain = false;
|
||||
freshlyQueued = false;
|
||||
serverReported = false;
|
||||
serverApplied = false;
|
||||
serverFailures.clear();
|
||||
useSettingsApplyStore.setState({ pending: [], applying: false, failure: null });
|
||||
}
|
||||
+10
-1
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
export interface JmapRequest {
|
||||
@@ -52,11 +55,17 @@ export interface JmapSetError {
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
existingId?: string;
|
||||
objectId?: string;
|
||||
objectId?: string | JmapObjectRef;
|
||||
linkedObjects?: string[];
|
||||
validationErrors?: ValidationError[];
|
||||
}
|
||||
|
||||
/** A registry object as the server names it in an error: its type without `x:`, and its id. */
|
||||
export interface JmapObjectRef {
|
||||
object: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
|
||||
property: string;
|
||||
|
||||
Reference in New Issue
Block a user