16 Commits
Author SHA1 Message Date
jcoffey-dev eb1c3c8b0e Merge pull request 'Let the server apply saved settings, and reload only for older servers' (#16) from feature/server-settings-reload into main
ci / build (push) Successful in 1m58s
ci / publish (push) Skipped
2026-09-24 20:13:15 +00:00
jcoffey-dev cf6047eb8b Let the server's own settings reload stand, and reload only for older servers
ci / build (pull_request) Successful in 57s
ci / publish (pull_request) Skipped
The server now applies a registry write to the running settings itself and
says how it went in the set response: a top-level "x:settingsReload" of
{"applied": true} or {"applied": false, "description": "Saved, but the
running settings were not reloaded. <object>: <error>"}, absent when the
write needs no reload. Sending ReloadSettings after it is a second reload
per burst of saves, and a refused one only repeats what the server said.

The JMAP client now hands the listener each written type with the server's
report, when there is one. A write that carries it queues nothing: applied
removes that reload from what's pending (the server's reload came after
every earlier write of that kind), and a refusal queues it as known
unapplied, as a failed reload from the admin would, without sending it.
A write without the report is an older server's, and queues the admin's
reload as before. When the burst settles there is still one result: the
admin's reload decides it when it had to send one; otherwise a "Saved and
applied" toast, or the banner with the server's reason and the object it
named, and "Apply now" sending ReloadSettings. A later write the server
applies clears an earlier refusal.

"Saved and applied" stays for settings objects; a directory or the default
authentication, which the server also reloads, keeps its form's own toast.

The type table follows the server's list of what each write needs. Alert,
DnsServer, Enterprise and SpamLlm are read when used, and MetricsStore and
TracingStore are stores, so they need no reload; the admin sent one for
each. Allowed IPs are part of the full settings, but the server answers an
AllowedIp write with the blocked-IP reload, which doesn't rebuild them, so
for those the admin still sends ReloadSettings whatever the report says.

New string, in src/i18n/en.json (the only catalogue) under settingsApply:
notReloaded, shown when a refusal comes without a description.
2026-09-24 13:06:44 -07:00
jcoffey-dev a7111b90af Merge pull request 'Apply saved settings on the server after a save' (#15) from feature/apply-saved-settings into main
ci / build (push) Successful in 1m48s
ci / publish (push) Skipped
2026-09-24 18:18:25 +00:00
jcoffey-dev 28c49b4056 Apply saved settings on the server after a save
ci / build (pull_request) Successful in 1m13s
ci / publish (pull_request) Skipped
A registry write is stored at once, but the running server only picks up
most settings when it rebuilds its configuration, which it does on a write
for directories and the default authentication alone. Everything else, a
delivery schedule for one, sat unapplied until someone ran Management >
Actions > Reload > Server settings by hand.

Every x:<Type>/set now goes past a listener in the JMAP client. A write that
changed a settings object queues the reload action it needs, and once no
write has been in flight for 600 ms the queued actions go out together in
one x:Action/set. A bulk edit or a page that saves several objects in a row
costs one reload, not one per object; a save still in flight holds the
reload back however long it takes.

Which action a type needs lives in lib/settingsApply.ts. Certificates, lookup
stores and blocked IPs have their own reload actions, and the full settings
reload doesn't rebuild them. Directories and authentication, data read live
or kept current by cache invalidation (accounts, domains, DKIM keys, tenants,
roles, lists and the like), operations and records, and stores, which a
reload never reopens, get none. A type the list doesn't know is reloaded: an
unneeded reload costs a second, a missing one leaves a setting unapplied. If
the server later applies these writes by itself, this becomes one extra,
harmless reload per burst of saves.

Applying straight after a save is safe because a reload is all or nothing:
the new configuration replaces the running one only when every settings
object builds. When one doesn't, the server keeps what it had and names the
object and the problem. That now shows as a banner above the page, "Saved,
but the server couldn't apply the settings: <reason>", naming the object with
a link to it, and an Apply now button. It stays until a reload succeeds or it
is dismissed. A failure that is already known is not retried on unrelated
saves, only on Apply now or a save that needs a reload.

On success a "Saved and applied" toast replaces the form's own "Saved
successfully" and "Created successfully" for settings objects, so a save
shows one message, not two.

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

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

The server's schema gains the menu link separately, after this release.
2026-09-22 22:08:33 -07:00
jcoffey-dev fa8f5abbad Merge pull request 'Use the server's renamed registry capability and web interface client' (#12) from fork/rename-upstream-identifiers into main
ci / build (push) Successful in 6m0s
ci / publish (push) Skipped
Reviewed-on: #12
2026-09-23 04:15:49 +00:00
jcoffey-dev ba1b8b74b7 Use the server's renamed registry capability and web interface client
ci / build (pull_request) Successful in 1m9s
ci / publish (pull_request) Skipped
inbuxa-server renames the identifiers that carried the upstream name
(its SPEC.md §2.4): upstream's JMAP capability for the registry (x:)
objects is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap,
and the web interface it serves signs in as inbuxa-webui. There are no
aliases, so this lands with the server change and deploys with it.
2026-09-22 18:59:49 -07:00
jcoffey-dev 0191f7e10c Merge pull request 'Write the name in lowercase where people see it' (#11) from fix/brand-lowercase into main
ci / build (push) Successful in 1m23s
ci / publish (push) Skipped
2026-09-22 22:21:02 +00:00
jcoffey-dev 7e3418079c Write the name in lowercase where people see it
ci / build (pull_request) Successful in 1m1s
ci / publish (pull_request) Skipped
The brand is lowercase inbuxa. This changes the setup wizard's welcome and
completion text, the logo's accessible name, the version line in the top bar,
the document title, the legacy-protocols screens and banner, the protocol
table's JMAP row, one help text, and the issuer new authenticator enrollments
are labeled with.

Keys are ids here, not English text, so nothing moved: each catalog value and
its inline fallback changed together. This console ships English only, so
there is no other catalog to follow. Comments, identifiers and env var names
are untouched.
2026-09-22 15:19:25 -07:00
jcoffey-dev 0ea86b6be0 Merge pull request 'Point links at git.coffeylabs.org after the move from GitHub' (#10) from fix/links-after-move into main
ci / build (push) Successful in 1m4s
ci / publish (push) Skipped
2026-09-22 16:11:23 +00:00
jcoffey-dev 3e7bb80f73 Point links at git.coffeylabs.org after the move from GitHub
ci / build (pull_request) Successful in 1m6s
ci / publish (pull_request) Skipped
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:22 -07:00
jcoffey-dev 4eb838042c Merge pull request 'ci: run socket-free jobs on the light label' (#9) from ci/light-runner into main
ci / build (push) Successful in 1m6s
ci / publish (push) Skipped
2026-09-22 13:54:26 +00:00
jcoffey-dev 14ccb05e52 ci: run socket-free jobs on the light label
ci / build (pull_request) Successful in 1m55s
ci / publish (pull_request) Skipped
Both runners carry `light` (host1, and host2 over the wg-hosts link), so
these jobs run on whichever host is free. Jobs that mount the docker socket
keep `runs-on: docker`, which only host1 has.
2026-09-22 06:45:44 -07:00
34 changed files with 2323 additions and 47 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ concurrency:
jobs: jobs:
# A release tag is built and tested again before its image is published. # A release tag is built and tested again before its image is published.
build: build:
runs-on: docker runs-on: light
container: container:
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
env: env:
+1 -1
View File
@@ -55,7 +55,7 @@ concurrency:
jobs: jobs:
weekly-release: weekly-release:
runs-on: docker runs-on: light
container: container:
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
env: env:
+1 -1
View File
@@ -32,6 +32,6 @@ session.
You'll get an acknowledgement within a few days. A report that turns out to You'll get an acknowledgement within a few days. A report that turns out to
affect the mail server rather than this interface will be moved to affect the mail server rather than this interface will be moved to
[inbuxa-server](https://github.com/inbuxa/inbuxa-server), and one that affects [inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server), and one that affects
upstream Stalwart's web interface will be passed to Stalwart Labs with credit upstream Stalwart's web interface will be passed to Stalwart Labs with credit
to you. to you.
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "2026.9.21.2" "version": "2026.9.23"
} }
+3 -3
View File
@@ -336,7 +336,7 @@ export function BootstrapWizard() {
<WizardShell> <WizardShell>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to INBUXA')}</h2> <h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")} {t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p> </p>
@@ -471,7 +471,7 @@ function SuccessScreen({
'bootstrap.credentialsCreated', 'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.', 'Your administrator account has been created. Write these down now: the password will not be shown again.',
) )
: t('bootstrap.configuredSuccessfully', 'INBUXA has been configured successfully.')} : t('bootstrap.configuredSuccessfully', 'inbuxa has been configured successfully.')}
</p> </p>
</div> </div>
</div> </div>
@@ -503,7 +503,7 @@ function SuccessScreen({
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '} <span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t( {t(
'bootstrap.nextStepBody', 'bootstrap.nextStepBody',
'restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.', 'restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
)} )}
</p> </p>
</div> </div>
+1 -1
View File
@@ -20,7 +20,7 @@ export function DefaultLogo() {
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="165 35 616 130" viewBox="165 35 616 130"
aria-label={t('logo.inbuxaAlt', 'INBUXA')} aria-label={t('logo.inbuxaAlt', 'inbuxa')}
className="h-7 w-auto max-w-[320px]" className="h-7 w-auto max-w-[320px]"
> >
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} /> <image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
+37 -2
View File
@@ -10,6 +10,7 @@
import { humanize } from '@/lib/humanize'; import { humanize } from '@/lib/humanize';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel'; import { HelpPanel } from '@/help/HelpPanel';
import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
import { iconForView } from '@/lib/viewIcon'; import { iconForView } from '@/lib/viewIcon';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
@@ -62,6 +63,7 @@ import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget'; import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard'; import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
import { isSieveScriptField } from '@/lib/sievepad'; import { isSieveScriptField } from '@/lib/sievepad';
import { reloadActionFor } from '@/lib/settingsApply';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
@@ -305,7 +307,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setSelectedVariant(newVariant); setSelectedVariant(newVariant);
const newData = buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant); const newData = {
...buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant),
// inbuxa: e.g. the default prompt when the AI classifier is switched on
...variantPrefill(obj.objectName, newVariant),
};
setFormData(newData); setFormData(newData);
}, },
[schema, resolved], [schema, resolved],
@@ -524,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setCreatedObjectId(newId); setCreatedObjectId(newId);
setServerCreatedProps(extraProps); setServerCreatedProps(extraProps);
} else { } else {
// inbuxa: a settings object says "Saved and applied" once the server has it.
if (!reloadActionFor(obj.objectName)) {
toast({ toast({
title: t('form.createdSuccess', 'Created successfully'), title: t('form.createdSuccess', 'Created successfully'),
variant: 'success', variant: 'success',
}); });
}
setOriginalData({ ...formData }); setOriginalData({ ...formData });
navigate(`/${section}/${viewName}`); navigate(`/${section}/${viewName}`);
} }
@@ -611,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const setResult = setResponse[1] as unknown as JmapSetResponse; const setResult = setResponse[1] as unknown as JmapSetResponse;
if (setResult.updated && updateId in setResult.updated) { 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({ toast({
title: t('form.savedSuccess', 'Saved successfully'), title: t('form.savedSuccess', 'Saved successfully'),
variant: 'success', variant: 'success',
}); });
}
if (isSingleton) { if (isSingleton) {
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties); const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
@@ -782,6 +794,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</div> </div>
)} )}
{/* inbuxa: notices for the AI objects (locality warning, AI-2) */}
{formNotices(resolved.obj.objectName, formData).map((notice) => (
<div
key={notice.key}
role={notice.tone === 'warning' ? 'alert' : 'note'}
className={
notice.tone === 'warning'
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm'
: 'rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground'
}
>
{t(notice.key, notice.text)}
</div>
))}
{sectionsToRender.map((section, sectionIdx) => ( {sectionsToRender.map((section, sectionIdx) => (
<Card key={sectionIdx}> <Card key={sectionIdx}>
{section.title && ( {section.title && (
@@ -838,7 +865,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
<FieldWidget <FieldWidget
key={formField.name} key={formField.name}
field={field} field={field}
formField={formField} formField={
formField.placeholder
? formField
: {
...formField,
// inbuxa: local example addresses on the AI model form
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
}
}
value={fieldValue} value={fieldValue}
onChange={(v) => handleFieldChange(formField.name, v)} onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={fieldReadOnly} readOnly={fieldReadOnly}
+1 -1
View File
@@ -38,7 +38,7 @@ function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
function generateTotp(): { totp: OTPAuth.TOTP; url: string } { function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({ const totp = new OTPAuth.TOTP({
issuer: 'INBUXA', issuer: 'inbuxa',
label: 'account', label: 'account',
algorithm: 'SHA1', algorithm: 'SHA1',
digits: 6, digits: 6,
+8
View File
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/LegacyProtocolsPage'), () => import('@/features/hardening/LegacyProtocolsPage'),
(m) => m.LegacyProtocolsPage, (m) => m.LegacyProtocolsPage,
); );
// inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec).
const LocalAiPage = lazyFeature(
() => import('@/features/ai/LocalAiPage'),
(m) => m.LocalAiPage,
);
const TenantLegacyProtocols = lazyFeature( const TenantLegacyProtocols = lazyFeature(
() => import('@/features/hardening/TenantLegacyProtocols'), () => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols, (m) => m.TenantLegacyProtocols,
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LegacyProtocols') { if (componentName === 'LegacyProtocols') {
return <LegacyProtocolsPage />; return <LegacyProtocolsPage />;
} }
if (componentName === 'LocalAi') {
return <LocalAiPage />;
}
return ( return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground"> <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName} Unknown component: {componentName}
@@ -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>
);
}
+1 -1
View File
@@ -104,7 +104,7 @@ export function TopBar() {
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })} {t('version.label', 'inbuxa Admin {{version}}', { version: __APP_VERSION__ })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
+507
View File
@@ -0,0 +1,507 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Settings › Spam Filter › Local AI. Spam filtering with a language
* model the operator runs, off until someone turns it on here or on the
* classifier's own form.
*
* Setting it up asks "Guided or manual?" each time (admin UX roadmap): guided
* walks through what it does, the model's address and the prompt, then turns
* it on; manual goes to the ordinary forms. Turning it off is one click and
* keeps the model, so turning it back on is easy too.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Brain, Loader2, Power, PowerOff, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast';
import {
DEFAULT_LIMITS,
DEFAULT_PROMPT,
disableClassifier,
enableWithModel,
EXAMPLE_URLS,
fetchLimits,
fetchStatus,
LIMIT_FIELDS,
LimitsUnavailable,
locality,
RECOMMENDED_MODEL,
saveLimits,
type AiLimits,
type Status,
} from './localAi';
import { formNotices } from './formExtras';
export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi';
type Load<T> = { kind: 'loading' } | { kind: 'ready'; value: T } | { kind: 'error'; message: string };
export function LocalAiPage() {
const { t } = useTranslation();
const canUpdate = useAccountStore(
(s) => s.hasObjectPermission('sysSpamLlm', 'Update') && s.hasObjectPermission('sysAiModel', 'Create'),
);
const [status, setStatus] = useState<Load<Status>>({ kind: 'loading' });
const [mode, setMode] = useState<'idle' | 'choose' | 'guided'>('idle');
const [busy, setBusy] = useState(false);
const reload = useCallback((signal?: AbortSignal) => {
fetchStatus(signal)
.then((value) => {
if (!signal?.aborted) setStatus({ kind: 'ready', value });
})
.catch((e: unknown) => {
if (!signal?.aborted) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
});
}, []);
useEffect(() => {
const ctl = new AbortController();
reload(ctl.signal);
return () => ctl.abort();
}, [reload]);
const turnOff = async () => {
setBusy(true);
const outcome = await disableClassifier().catch((e: unknown) => ({
ok: false,
message: e instanceof Error ? e.message : String(e),
}));
setBusy(false);
if (outcome.ok) {
toast({ title: t('localAi.turnedOff', 'Local AI spam filtering is off.') });
reload();
} else {
toast({ title: t('localAi.failed', 'That 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>
);
}
+80
View File
@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: what the schema-driven forms add for the AI objects (spec, "INBUXA
* Admin"): the default prompt when the classifier is switched on, local
* example placeholders on the model form, and the notices above both forms.
* DynamicForm calls these at three marked points; everything else about the
* forms stays as the schema draws them.
*/
import { DEFAULT_PROMPT, EXAMPLE_URLS, locality, RECOMMENDED_MODEL } from './localAi';
/** Values to prefill when a form switches to a variant. */
export function variantPrefill(objectName: string, variant: string): Record<string, unknown> {
if (objectName === 'x:SpamLlm' && variant === 'Enable') return { prompt: DEFAULT_PROMPT };
return {};
}
/** A placeholder for a field the schema gives none. */
export function fieldPlaceholder(objectName: string, fieldName: string): string | undefined {
if (objectName !== 'x:AiModel') return undefined;
if (fieldName === 'url') return EXAMPLE_URLS.llamaCpp;
if (fieldName === 'model') return RECOMMENDED_MODEL.model;
return undefined;
}
export interface FormNotice {
tone: 'info' | 'warning';
/** An i18n key and its English default. */
key: string;
text: string;
}
/** Notices to show above a form, from what it currently holds. */
export function formNotices(objectName: string, data: Record<string, unknown>): FormNotice[] {
if (objectName === 'x:AiModel') {
const url = typeof data.url === 'string' ? data.url.trim() : '';
if (!url) return [];
switch (locality(url)) {
case 'remote':
return [
{
tone: 'warning',
key: 'localAi.remoteWarning',
text:
'This address is outside your network. The spam filter will send the subject and text of ' +
'incoming mail to it. For privacy, run the model on your own machines.',
},
];
case 'unknown':
return [
{
tone: 'info',
key: 'localAi.nameNotice',
text:
'If this name points outside your network, message text will leave it. The server checks when ' +
'you save and warns in its log.',
},
];
default:
return [];
}
}
if (objectName === 'x:SpamLlm') {
return [
{
tone: 'info',
key: 'localAi.neverHoldsMail',
text:
"The model's opinion is one signal among many, and adds at most a few points. If the model is " +
'slow or unavailable, mail is never held up: the message is scored without it.',
},
];
}
return [];
}
+173
View File
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const jmapRequest = vi.fn();
vi.mock('@/services/jmap/client', () => ({
getAccountId: () => 'a',
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
}));
import {
DEFAULT_LIMITS,
DEFAULT_PROMPT,
enableWithModel,
fetchLimits,
fetchStatus,
LimitsUnavailable,
locality,
parseLimits,
saveLimits,
} from './localAi';
import { fieldPlaceholder, formNotices, variantPrefill } from './formExtras';
beforeEach(() => jmapRequest.mockReset());
describe('locality (AI-2)', () => {
it.each([
['http://127.0.0.1:8080/v1/chat/completions', 'local'],
['http://localhost:11434/v1/chat/completions', 'local'],
['http://10.0.0.5/v1', 'local'],
['http://172.16.1.1/v1', 'local'],
['http://172.31.255.1/v1', 'local'],
['http://192.168.1.20/v1', 'local'],
['http://[::1]:8080/v1', 'local'],
['http://[fd12:3456::1]/v1', 'local'],
['http://172.32.0.1/v1', 'remote'],
['https://8.8.8.8/v1', 'remote'],
['https://[2001:db8::1]/v1', 'remote'],
['https://api.example.com/v1/chat/completions', 'unknown'],
['ai.lan', 'invalid'],
['', 'invalid'],
])('%s is %s', (url, expected) => {
expect(locality(url)).toBe(expected);
});
});
describe('limits', () => {
it('fills anything unset with the defaults', () => {
expect(parseLimits({ spamMaxAdded: 1.5 })).toEqual({ ...DEFAULT_LIMITS, spamMaxAdded: 1.5 });
});
it('reads the singleton under the fork capability', async () => {
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/get', { list: [{ maxContentBytes: 4096 }] }, '0']]);
expect((await fetchLimits()).maxContentBytes).toBe(4096);
expect(jmapRequest.mock.calls[0][2]).toEqual(['urn:inbuxa:jmap']);
});
it('says so when the server has no AI limits', async () => {
jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }, '0']]);
await expect(fetchLimits()).rejects.toBeInstanceOf(LimitsUnavailable);
});
it('sends only what changed, and a return to the default as null', async () => {
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }, '0']]);
const current = { ...DEFAULT_LIMITS, spamMaxAdded: 3 };
const next = { ...current, spamMaxAdded: DEFAULT_LIMITS.spamMaxAdded, userCallsPerHour: 10 };
expect(await saveLimits(current, next)).toEqual({ ok: true });
const [, args] = jmapRequest.mock.calls[0][0][0];
expect(args.update.singleton).toEqual({ spamMaxAdded: null, userCallsPerHour: 10 });
});
it('sends nothing when nothing changed', async () => {
expect(await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS })).toEqual({ ok: true });
expect(jmapRequest).not.toHaveBeenCalled();
});
it('reports the property the server rejected', async () => {
jmapRequest.mockResolvedValue([
[
'inbuxa:AiLimits/set',
{
notUpdated: {
singleton: { type: 'invalidProperties', properties: ['spamMaxAdded'], description: 'too big' },
},
},
'0',
],
]);
const outcome = await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS, spamMaxAdded: 99 });
expect(outcome).toEqual({ ok: false, property: 'spamMaxAdded', message: 'too big' });
});
});
describe('status', () => {
it('is off by default, and names the model when on', async () => {
jmapRequest.mockResolvedValueOnce([
['x:SpamLlm/get', { list: [{ '@type': 'Disable' }] }, 'c'],
['x:AiModel/get', { list: [] }, 'm'],
]);
expect(await fetchStatus()).toEqual({ enabled: false, modelId: null, models: [] });
jmapRequest.mockResolvedValueOnce([
['x:SpamLlm/get', { list: [{ '@type': 'Enable', modelId: 'm1' }] }, 'c'],
['x:AiModel/get', { list: [{ id: 'm1', name: 'local', model: 'q', url: 'http://127.0.0.1/v1' }] }, 'm'],
]);
const on = await fetchStatus();
expect(on.enabled).toBe(true);
expect(on.modelId).toBe('m1');
});
});
describe('guided setup', () => {
const input = { name: 'local', url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'q', prompt: 'p' };
it('creates the model, then enables the classifier with its real id', async () => {
jmapRequest
.mockResolvedValueOnce([['x:AiModel/set', { created: { m: { id: 'm9' } } }, '0']])
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
expect(await enableWithModel(input, [])).toEqual({ ok: true });
const enable = jmapRequest.mock.calls[1][0][0][1];
expect(enable.update.singleton).toEqual({ '@type': 'Enable', modelId: 'm9', prompt: 'p' });
});
it('reuses a model of the same name instead of creating a duplicate', async () => {
jmapRequest
.mockResolvedValueOnce([['x:AiModel/set', { updated: { m1: null } }, '0']])
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
await enableWithModel(input, [{ id: 'm1', name: 'local', model: 'old', url: 'http://10.0.0.1/v1' }]);
const update = jmapRequest.mock.calls[0][0][0][1];
expect(update.update).toHaveProperty('m1');
expect(update.create).toBeUndefined();
expect(jmapRequest.mock.calls[1][0][0][1].update.singleton.modelId).toBe('m1');
});
it('never switches the classifier on when the model could not be made', async () => {
jmapRequest.mockResolvedValueOnce([
['x:AiModel/set', { notCreated: { m: { type: 'invalidProperties', properties: ['url'] } } }, '0'],
]);
const outcome = await enableWithModel(input, []);
expect(outcome.ok).toBe(false);
expect(outcome.property).toBe('url');
expect(jmapRequest).toHaveBeenCalledTimes(1);
});
});
describe('form extras', () => {
it('prefills the default prompt only when the classifier is switched on', () => {
expect(variantPrefill('x:SpamLlm', 'Enable')).toEqual({ prompt: DEFAULT_PROMPT });
expect(variantPrefill('x:SpamLlm', 'Disable')).toEqual({});
expect(variantPrefill('x:Domain', 'Enable')).toEqual({});
});
it('suggests local addresses on the model form only', () => {
expect(fieldPlaceholder('x:AiModel', 'url')).toMatch(/^http:\/\/127\.0\.0\.1/);
expect(fieldPlaceholder('x:AiModel', 'name')).toBeUndefined();
expect(fieldPlaceholder('x:Domain', 'url')).toBeUndefined();
});
it('warns about a model outside the network, and never about a local one', () => {
expect(formNotices('x:AiModel', { url: 'https://8.8.8.8/v1' })[0]?.tone).toBe('warning');
expect(formNotices('x:AiModel', { url: 'https://api.example.com/v1' })[0]?.tone).toBe('info');
expect(formNotices('x:AiModel', { url: 'http://127.0.0.1:8080/v1' })).toEqual([]);
expect(formNotices('x:AiModel', {})).toEqual([]);
});
it('tells the classifier form that failures never hold up mail', () => {
expect(formNotices('x:SpamLlm', {})[0]?.key).toBe('localAi.neverHoldsMail');
});
});
+292
View File
@@ -0,0 +1,292 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: local AI spam filtering (inbuxa-server's ai-spam-classification
* spec). The wire and the rules; the page and the form hooks draw from it.
*
* The feature is off until an administrator turns it on, and meant for a
* model running on the operator's own machines: nothing here presets a hosted
* endpoint, and the locality check says so when one is chosen (AI-2).
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { JmapMethodResponse, JmapSetError } from '@/types/jmap';
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
const LIMITS = 'inbuxa:AiLimits';
const CLASSIFIER = 'x:SpamLlm';
const MODEL = 'x:AiModel';
/**
* The fork's default classification prompt, prefilled only when an
* administrator enables the classifier (spec, "Default prompt"). The
* calibration measured it; the server adds its own framing around it.
*/
export const DEFAULT_PROMPT =
'Classify the email below as one of: Unsolicited, Commercial, Harmful, Legitimate. ' +
"Unsolicited: bulk mail the recipient didn't ask for. Commercial: selling something. " +
'Harmful: phishing, fraud or malware. Legitimate: anything else. Then give your confidence: ' +
'High, Medium or Low. Answer on one line as Category,Confidence,Reason with a reason of at most 20 words.';
/** The calibration's recommendation (spec, "Calibration"): Apache-2.0, 4 vCPU minimum on CPU only. */
export const RECOMMENDED_MODEL = {
label: 'Qwen3 4B Instruct 2507',
model: 'qwen3-4b-instruct-2507',
license: 'Apache-2.0',
minCpus: 4,
};
/** Where a model served beside the mail server usually answers: llama.cpp's server, or Ollama. */
export const EXAMPLE_URLS = {
llamaCpp: 'http://127.0.0.1:8080/v1/chat/completions',
ollama: 'http://127.0.0.1:11434/v1/chat/completions',
};
// ── Locality (AI-2) ──────────────────────────────────────────────────────
export type Locality = 'local' | 'unknown' | 'remote' | 'invalid';
function ipv4Octets(host: string): number[] | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (!m) return null;
const o = m.slice(1).map(Number);
return o.every((n) => n <= 255) ? o : null;
}
/**
* Is the endpoint on this network? `local` for localhost, loopback, RFC 1918
* and RFC 4193 addresses; `remote` for any other address; `unknown` for a
* name the browser can't resolve, where only the server can tell (it logs
* its own warning); `invalid` when there's no URL to judge. Advisory only: it
* never blocks an endpoint the operator chose.
*/
export function locality(url: string): Locality {
let host: string;
try {
host = new URL(url).hostname.toLowerCase();
} catch {
return 'invalid';
}
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
if (host === 'localhost' || host.endsWith('.localhost')) return 'local';
const v4 = ipv4Octets(host);
if (v4) {
const [a, b] = v4;
if (a === 127 || a === 10) return 'local';
if (a === 172 && b >= 16 && b <= 31) return 'local';
if (a === 192 && b === 168) return 'local';
return 'remote';
}
if (host.includes(':')) {
if (host === '::1') return 'local';
const first = parseInt(host.split(':')[0] || '0', 16);
if ((first & 0xfe00) === 0xfc00) return 'local';
return 'remote';
}
return 'unknown';
}
// ── The limits, inbuxa:AiLimits ──────────────────────────────────────────
export interface AiLimits {
spamMaxAdded: number;
spamMaxSubtracted: number;
/** Milliseconds. */
spamCallCeiling: number;
maxConcurrentCalls: number;
maxContentBytes: number;
/** Milliseconds. */
failureBackoff: number;
userCallsPerHour: number;
}
/** The spec's defaults, which the server also applies to anything unset. */
export const DEFAULT_LIMITS: AiLimits = {
spamMaxAdded: 2.0,
spamMaxSubtracted: 1.0,
spamCallCeiling: 20_000,
maxConcurrentCalls: 4,
maxContentBytes: 2048,
failureBackoff: 60_000,
userCallsPerHour: 60,
};
export const LIMIT_FIELDS = Object.keys(DEFAULT_LIMITS) as (keyof AiLimits)[];
export class LimitsUnavailable extends Error {}
export function parseLimits(raw: Record<string, unknown>): AiLimits {
const out = { ...DEFAULT_LIMITS };
for (const key of LIMIT_FIELDS) {
const v = raw[key];
if (typeof v === 'number' && Number.isFinite(v)) out[key] = v;
}
return out;
}
function methodError(responses: JmapMethodResponse[]): string | null {
const [name, result] = responses[0] ?? [];
if (name !== 'error') return null;
return typeof result?.description === 'string' ? result.description : String(result?.type ?? 'error');
}
export async function fetchLimits(signal?: AbortSignal): Promise<AiLimits> {
const accountId = getAccountId('x:');
const responses = await jmapRequest([[`${LIMITS}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
const [name, result] = responses[0] ?? [];
if (name === 'error') {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new LimitsUnavailable();
throw new Error(methodError(responses) ?? 'error');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
return parseLimits(list[0] ?? {});
}
export interface SaveOutcome {
ok: boolean;
/** The property the server rejected, and why. */
property?: string;
message?: string;
}
/**
* Saves what changed. A value equal to the default is sent as null, so the
* server keeps following the default rather than pinning today's number.
*/
export async function saveLimits(current: AiLimits, next: AiLimits): Promise<SaveOutcome> {
const patch: Record<string, number | null> = {};
for (const key of LIMIT_FIELDS) {
if (next[key] === current[key]) continue;
patch[key] = next[key] === DEFAULT_LIMITS[key] ? null : next[key];
}
if (Object.keys(patch).length === 0) return { ok: true };
const accountId = getAccountId('x:');
const responses = await jmapRequest(
[[`${LIMITS}/set`, { accountId, update: { singleton: patch } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const notUpdated = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated;
const failure = notUpdated?.singleton;
if (failure) {
return {
ok: false,
property: failure.properties?.[0],
message: failure.description ?? failure.type,
};
}
return { ok: true };
}
// ── The classifier and its models ────────────────────────────────────────
export interface AiModelSummary {
id: string;
name: string;
model: string;
url: string;
}
export interface Status {
enabled: boolean;
/** The model the classifier asks, when enabled. */
modelId: string | null;
models: AiModelSummary[];
}
export async function fetchStatus(signal?: AbortSignal): Promise<Status> {
const accountId = getAccountId('x:');
const responses = await jmapRequest(
[
[`${CLASSIFIER}/get`, { accountId, ids: ['singleton'] }, 'c'],
[`${MODEL}/get`, { accountId, ids: null, properties: ['name', 'model', 'url'] }, 'm'],
],
signal,
);
const err = methodError(responses);
if (err) throw new Error(err);
const classifier = ((responses[0][1] as { list?: Record<string, unknown>[] }).list ?? [])[0] ?? {};
const models = ((responses[1]?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? []).map((m) => ({
id: String(m.id),
name: String(m.name ?? ''),
model: String(m.model ?? ''),
url: String(m.url ?? ''),
}));
const enabled = classifier['@type'] === 'Enable';
return { enabled, modelId: enabled ? String(classifier.modelId ?? '') || null : null, models };
}
export interface SetupInput {
name: string;
url: string;
model: string;
prompt: string;
}
/**
* The guided setup: makes sure the model exists, then switches the
* classifier on with it. A model already configured under the same name is
* reused (and its address and model name updated), so running the setup again
* after a failure never piles up duplicates. The classifier is only switched
* on once the model is known to exist, with its real id.
*/
export async function enableWithModel(input: SetupInput, existing: AiModelSummary[]): Promise<SaveOutcome> {
const accountId = getAccountId('x:');
const fields = { name: input.name, url: input.url, model: input.model, modelType: 'Chat' };
const same = existing.find((m) => m.name === input.name);
let modelId: string;
if (same) {
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, update: { [same.id]: fields } }, '0']]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.[same.id];
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
modelId = same.id;
} else {
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, create: { m: fields } }, '0']]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const result = responses[0][1] as {
created?: Record<string, { id?: string }>;
notCreated?: Record<string, JmapSetError>;
};
const f = result.notCreated?.m;
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
const id = result.created?.m?.id;
if (!id) return { ok: false, message: 'The server created the model but did not return its id.' };
modelId = id;
}
const responses = await jmapRequest([
[
`${CLASSIFIER}/set`,
{ accountId, update: { singleton: { '@type': 'Enable', modelId, prompt: input.prompt } } },
'0',
],
]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
return { ok: true };
}
/** Switches the classifier off. The model stays configured, for turning it back on. */
export async function disableClassifier(): Promise<SaveOutcome> {
const accountId = getAccountId('x:');
const responses = await jmapRequest([
[`${CLASSIFIER}/set`, { accountId, update: { singleton: { '@type': 'Disable' } } }, '0'],
]);
const err = methodError(responses);
if (err) return { ok: false, message: err };
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
return f ? { ok: false, message: f.description ?? f.type } : { ok: true };
}
@@ -62,10 +62,10 @@ export function LegacyProtocolsBanner() {
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '} <strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
{off === 'server' {off === 'server'
? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.') ? t('legacyProtocols.bannerTail', 'on this server. Only inbuxa webmail and JMAP apps can sign in.')
: t( : t(
'legacyProtocols.bannerTailTenant', 'legacyProtocols.bannerTailTenant',
'for your organization. Only INBUXA webmail and JMAP apps can sign in.', 'for your organization. Only inbuxa webmail and JMAP apps can sign in.',
)} )}
</span> </span>
{off === 'server' && ( {off === 'server' && (
@@ -115,7 +115,7 @@ export function LegacyProtocolsPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t( {t(
'legacyProtocols.subtitle', 'legacyProtocols.subtitle',
'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only INBUXA webmail and JMAP apps can reach this server.', 'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.',
)} )}
</p> </p>
</header> </header>
@@ -206,7 +206,7 @@ function StatusCard({
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{off {off
? t('legacyProtocols.statusOffBody', 'Only INBUXA webmail and JMAP apps can sign in.') ? t('legacyProtocols.statusOffBody', 'Only inbuxa webmail and JMAP apps can sign in.')
: t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')}
{policy.changedAt !== null && ( {policy.changedAt !== null && (
<> <>
@@ -276,7 +276,7 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground"> <p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
{t( {t(
'legacyProtocols.lockNote', 'legacyProtocols.lockNote',
'Incoming mail (SMTP) and INBUXA webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.', 'Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.',
)} )}
</p> </p>
</div> </div>
@@ -102,7 +102,7 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
{off {off
? t( ? t(
'legacyProtocols.tenantOff', 'legacyProtocols.tenantOff',
'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.', 'Off for {{organization}}. Only inbuxa webmail and JMAP apps can sign in to its domains.',
{ organization: name }, { organization: name },
) )
: t( : t(
+5 -5
View File
@@ -128,7 +128,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
return ( return (
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed"> <section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
<p className="text-base font-semibold"> <p className="text-base font-semibold">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} {t('legacyProtocols.statementTitle', 'Only inbuxa webmail and JMAP apps will work.')}
</p> </p>
<p> <p>
{scope.kind === 'server' {scope.kind === 'server'
@@ -152,7 +152,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li> <li>
{t( {t(
'legacyProtocols.statementFilters', 'legacyProtocols.statementFilters',
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in inbuxa webmail keep working.',
)} )}
</li> </li>
<li> <li>
@@ -164,7 +164,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li> <li>
{t( {t(
'legacyProtocols.statementWebmail', 'legacyProtocols.statementWebmail',
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', 'People keep full access through inbuxa webmail, which can be installed as an app on phones and computers.',
)} )}
</li> </li>
</ul> </ul>
@@ -183,7 +183,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<p> <p>
{t( {t(
'legacyProtocols.statementSubmission', 'legacyProtocols.statementSubmission',
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and inbuxa webmail (JMAP) are not affected and cannot be turned off here.',
)} )}
</p> </p>
{scope.kind === 'server' && ( {scope.kind === 'server' && (
@@ -191,7 +191,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '} <strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
{t( {t(
'legacyProtocols.firewallBody', 'legacyProtocols.firewallBody',
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', 'inbuxa stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
)} )}
</p> </p>
)} )}
+1 -1
View File
@@ -244,7 +244,7 @@ export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]
}, },
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever"). // Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] }, { key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] }, { key: 'jmap', label: 'JMAP (inbuxa webmail)', state: 'locked', ports: [] },
]; ];
} }
+1 -1
View File
@@ -104,7 +104,7 @@ export const PAGE_HELP: Record<string, PageHelp> = {
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.', about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
}, },
'x:OAuthClient': { 'x:OAuthClient': {
about: 'Apps allowed to sign people in through this server, like INBUXA webmail and INBUXA Admin.', about: 'Apps allowed to sign people in through this server, like inbuxa webmail and inbuxa Admin.',
}, },
'x:DkimSignature': { 'x:DkimSignature': {
about: 'The keys that sign outgoing mail so receivers can check it really came from you.', about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
+1 -1
View File
@@ -9,7 +9,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
const APP_NAME = 'INBUXA Admin'; const APP_NAME = 'inbuxa Admin';
export function useDocumentTitle(title?: string | null) { export function useDocumentTitle(title?: string | null) {
useEffect(() => { useEffect(() => {
+17 -5
View File
@@ -15,7 +15,7 @@
"bootstrap": { "bootstrap": {
"clipboardBlocked": "Your browser blocked clipboard access.", "clipboardBlocked": "Your browser blocked clipboard access.",
"complete": "Setup complete", "complete": "Setup complete",
"configuredSuccessfully": "INBUXA has been configured successfully.", "configuredSuccessfully": "inbuxa has been configured successfully.",
"copyFailed": "Copy failed", "copyFailed": "Copy failed",
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.", "credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.", "emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
@@ -23,11 +23,11 @@
"failedToLoad": "Failed to load bootstrap state.", "failedToLoad": "Failed to load bootstrap state.",
"finishSetup": "Finish setup", "finishSetup": "Finish setup",
"loadingSetup": "Loading setup...", "loadingSetup": "Loading setup...",
"nextStepBody": "restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.", "nextStepBody": "restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
"nextStepLabel": "Next step:", "nextStepLabel": "Next step:",
"noConfirm": "The server did not confirm the update.", "noConfirm": "The server did not confirm the update.",
"stepOf": "Step {{current}} of {{total}}", "stepOf": "Step {{current}} of {{total}}",
"welcome": "Welcome to INBUXA", "welcome": "Welcome to inbuxa",
"welcomeSubtitle": "Let's get your server set up." "welcomeSubtitle": "Let's get your server set up."
}, },
"common": { "common": {
@@ -295,11 +295,11 @@
}, },
"logo": { "logo": {
"alt": "Logo", "alt": "Logo",
"inbuxaAlt": "INBUXA" "inbuxaAlt": "inbuxa"
}, },
"logout": "Logout", "logout": "Logout",
"version": { "version": {
"label": "INBUXA Admin {{version}}" "label": "inbuxa Admin {{version}}"
}, },
"nav": { "nav": {
"layoutLegacy": "Legacy", "layoutLegacy": "Legacy",
@@ -345,6 +345,18 @@
"periodValue": "Period value" "periodValue": "Period value"
}, },
"sections": "Sections", "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": { "sievepad": {
"debug": "Debug", "debug": "Debug",
"defaultName": "Sieve script", "defaultName": "Sieve script",
+5
View File
@@ -71,6 +71,11 @@ function checkSpecialLink(
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false }; return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
} }
// inbuxa: the Local AI page is for whoever may see the AI classifier.
if (viewName === 'CustomComponent/LocalAi') {
return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false };
}
if (viewName.startsWith('CustomComponent/')) { if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false }; return { visible: true, enterprise: false };
} }
+3 -3
View File
@@ -36,17 +36,17 @@ describe('getOAuthClientId', () => {
it('falls back to the built-in default when the placeholder is empty', async () => { it('falls back to the built-in default when the placeholder is empty', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />'); const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('falls back to the built-in default when the placeholder is only whitespace', async () => { it('falls back to the built-in default when the placeholder is only whitespace', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />'); const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('falls back to the built-in default when the placeholder is absent', async () => { it('falls back to the built-in default when the placeholder is absent', async () => {
const getOAuthClientId = await loadWithMeta(''); const getOAuthClientId = await loadWithMeta('');
expect(getOAuthClientId()).toBe('stalwart-webui'); expect(getOAuthClientId()).toBe('inbuxa-webui');
}); });
it('is inbuxa-admin when hosted apart from the server', async () => { it('is inbuxa-admin when hosted apart from the server', async () => {
+2 -2
View File
@@ -9,10 +9,10 @@
// INBUXA requires OAuth clients to be registered, and registers these two on // INBUXA requires OAuth clients to be registered, and registers these two on
// every start (inbuxa-server contract C-6). Served by the server itself, this // every start (inbuxa-server contract C-6). Served by the server itself, this
// is the web interface at /admin, registered as `stalwart-webui`. Hosted // is the web interface at /admin, registered as `inbuxa-webui`. Hosted
// anywhere else, with the server's address in <meta name="api-base-url">, it // anywhere else, with the server's address in <meta name="api-base-url">, it
// is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL. // is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL.
const SERVED_BY_SERVER_CLIENT_ID = 'stalwart-webui'; const SERVED_BY_SERVER_CLIENT_ID = 'inbuxa-webui';
const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin'; const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin';
let cached: string | undefined; let cached: string | undefined;
+194
View File
@@ -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.');
});
});
+239
View File
@@ -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 };
}
+1 -1
View File
@@ -11,4 +11,4 @@
* this fork. The version shown beside the link names the build, which is what * this fork. The version shown beside the link names the build, which is what
* makes the offer something a person can act on. * makes the offer something a person can act on.
*/ */
export const SOURCE_URL = 'https://github.com/inbuxa/inbuxa-admin'; export const SOURCE_URL = 'https://git.coffeylabs.org/inbuxa/inbuxa-admin';
+2
View File
@@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar'; import { Sidebar } from '@/components/layout/Sidebar';
import { SectionNav } from '@/components/layout/SectionNav'; import { SectionNav } from '@/components/layout/SectionNav';
import { MainContent } from '@/components/layout/MainContent'; import { MainContent } from '@/components/layout/MainContent';
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { import {
@@ -320,6 +321,7 @@ export default function AdminPanel() {
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'), !useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
)} )}
> >
<SettingsApplyBanner />
<ErrorBoundary key={activeAccountId ?? 'none'}> <ErrorBoundary key={activeAccountId ?? 'none'}>
<MainContent viewName={viewName} id={id} section={section} /> <MainContent viewName={viewName} id={id} section={section} />
</ErrorBoundary> </ErrorBoundary>
+37 -1
View File
@@ -12,10 +12,11 @@ import { apiFetch } from '@/services/api';
import { logJmapExchange } from '@/lib/debug'; import { logJmapExchange } from '@/lib/debug';
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap'; import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
import type { Schema } from '@/types/schema'; import type { Schema } from '@/types/schema';
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
const JMAP_USING = [ const JMAP_USING = [
'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:core',
'urn:stalwart:jmap', 'urn:inbuxa:jmap:registry',
'urn:ietf:params:jmap:blob', 'urn:ietf:params:jmap:blob',
'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:calendars', 'urn:ietf:params:jmap:calendars',
@@ -35,10 +36,45 @@ export function getAccountId(objectType: string): string {
return activeAccountId; 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( export async function jmapRequest(
methodCalls: JmapMethodCall[], methodCalls: JmapMethodCall[],
signal?: AbortSignal, signal?: AbortSignal,
extraUsing: string[] = [], 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[]> { ): Promise<JmapMethodResponse[]> {
const { apiUrl } = useAuthStore.getState(); const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap'; let path = apiUrl || '/jmap';
+349
View File
@@ -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' },
});
});
});
});
+240
View File
@@ -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
View File
@@ -1,7 +1,10 @@
/* /*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* SPDX-FileCopyrightText: 2026 Coffey Labs
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
export interface JmapRequest { export interface JmapRequest {
@@ -52,11 +55,17 @@ export interface JmapSetError {
description?: string; description?: string;
properties?: string[]; properties?: string[];
existingId?: string; existingId?: string;
objectId?: string; objectId?: string | JmapObjectRef;
linkedObjects?: string[]; linkedObjects?: string[];
validationErrors?: ValidationError[]; 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 { export interface ValidationError {
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue'; type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
property: string; property: string;