diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx
index 24bceb7..5df4bd4 100644
--- a/src/components/forms/DynamicForm.tsx
+++ b/src/components/forms/DynamicForm.tsx
@@ -63,6 +63,7 @@ import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
import { isSieveScriptField } from '@/lib/sievepad';
+import { reloadActionFor } from '@/lib/settingsApply';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
@@ -529,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
setCreatedObjectId(newId);
setServerCreatedProps(extraProps);
} else {
- toast({
- title: t('form.createdSuccess', 'Created successfully'),
- variant: 'success',
- });
+ // inbuxa: a settings object says "Saved and applied" once the server has it.
+ if (!reloadActionFor(obj.objectName)) {
+ toast({
+ title: t('form.createdSuccess', 'Created successfully'),
+ variant: 'success',
+ });
+ }
setOriginalData({ ...formData });
navigate(`/${section}/${viewName}`);
}
@@ -616,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const setResult = setResponse[1] as unknown as JmapSetResponse;
if (setResult.updated && updateId in setResult.updated) {
- toast({
- title: t('form.savedSuccess', 'Saved successfully'),
- variant: 'success',
- });
+ // inbuxa: a settings object says "Saved and applied" once the server has it.
+ if (!reloadActionFor(obj.objectName)) {
+ toast({
+ title: t('form.savedSuccess', 'Saved successfully'),
+ variant: 'success',
+ });
+ }
if (isSingleton) {
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
diff --git a/src/components/layout/SettingsApplyBanner.tsx b/src/components/layout/SettingsApplyBanner.tsx
new file mode 100644
index 0000000..5811024
--- /dev/null
+++ b/src/components/layout/SettingsApplyBanner.tsx
@@ -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 (
+
+
+
+
+ {t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", {
+ reason: failure.message,
+ })}
+
+ {objectLabel && (
+
+ {t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '}
+ {objectLink && (
+
+ {t('settingsApply.openObject', 'Open it')}
+
+ )}
+
+ )}
+
+ {t(
+ 'settingsApply.stillRunning',
+ 'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.',
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index f0c63f8..a83442f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -345,6 +345,17 @@
"periodValue": "Period value"
},
"sections": "Sections",
+ "settingsApply": {
+ "applied": "Saved and applied",
+ "applyNow": "Apply now",
+ "dismiss": "Dismiss",
+ "failed": "Saved, but the server couldn't apply the settings: {{reason}}",
+ "failedObject": "The problem is in {{object}}.",
+ "noAnswer": "The server did not answer.",
+ "notConfirmed": "The server did not confirm the reload.",
+ "openObject": "Open it",
+ "stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed."
+ },
"sievepad": {
"debug": "Debug",
"defaultName": "Sieve script",
diff --git a/src/lib/settingsApply.test.ts b/src/lib/settingsApply.test.ts
new file mode 100644
index 0000000..07faa5d
--- /dev/null
+++ b/src/lib/settingsApply.test.ts
@@ -0,0 +1,119 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ describeApplyFailure,
+ describeRequestFailure,
+ reloadActionFor,
+ reloadActionsFor,
+ writesRegistry,
+ writtenRegistryTypes,
+} 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('skips data read live, operations and stores', () => {
+ for (const type of ['Account', 'Domain', 'DkimSignature', 'Tenant', 'Action', 'QueuedMessage', 'DataStore']) {
+ 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(writtenRegistryTypes(responses)).toEqual(['x:MtaRoute', 'x:Certificate']);
+ });
+});
+
+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.');
+ });
+});
diff --git a/src/lib/settingsApply.ts b/src/lib/settingsApply.ts
new file mode 100644
index 0000000..bb52df5
--- /dev/null
+++ b/src/lib/settingsApply.ts
@@ -0,0 +1,174 @@
+/*
+ * 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. The server does that by itself on a write for a few
+ * types only (directories and the default authentication). Everything else
+ * waited for someone to open Management > Actions and reload by hand.
+ *
+ * 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 = {
+ Certificate: 'ReloadTlsCertificates',
+ StoreLookup: 'ReloadLookupStores',
+ HttpLookup: 'ReloadLookupStores',
+ MemoryLookupKey: 'ReloadLookupStores',
+ MemoryLookupKeyValue: 'ReloadLookupStores',
+ BlockedIp: 'ReloadBlockedIps',
+};
+
+/** Types that need no reload after a write. Anything not listed here is reloaded. */
+const NOTHING_TO_APPLY = new Set([
+ // 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',
+ 'ApiKey',
+ 'AppPassword',
+ 'Domain',
+ 'DkimSignature',
+ 'MailingList',
+ 'MaskedEmail',
+ 'OAuthClient',
+ 'PublicKey',
+ 'Role',
+ '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',
+ 'SearchStore',
+ // 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:/set` call. */
+export function writesRegistry(methodCalls: JmapMethodCall[]): boolean {
+ return methodCalls.some(([name]) => REGISTRY_SET.test(name));
+}
+
+/** The registry types a response says were created, changed or destroyed. */
+export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): string[] {
+ const types = new Set();
+ for (const [name, result] of methodResponses) {
+ const match = REGISTRY_SET.exec(name);
+ if (!match || !result) continue;
+ const created = result.created as Record | null | undefined;
+ const updated = result.updated as Record | 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)
+ ) {
+ types.add(match[1]);
+ }
+ }
+ return [...types];
+}
+
+/** The actions due for a set of written types, in the order they run. */
+export function reloadActionsFor(objectNames: Iterable): ReloadAction[] {
+ const due = new Set();
+ 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.') };
+}
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx
index 6118343..12db2b1 100644
--- a/src/pages/AdminPanel.tsx
+++ b/src/pages/AdminPanel.tsx
@@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
import { SectionNav } from '@/components/layout/SectionNav';
import { MainContent } from '@/components/layout/MainContent';
+import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import {
@@ -320,6 +321,7 @@ export default function AdminPanel() {
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
)}
>
+
diff --git a/src/services/jmap/client.ts b/src/services/jmap/client.ts
index 1a5fd12..2218a89 100644
--- a/src/services/jmap/client.ts
+++ b/src/services/jmap/client.ts
@@ -12,6 +12,7 @@ import { apiFetch } from '@/services/api';
import { logJmapExchange } from '@/lib/debug';
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
import type { Schema } from '@/types/schema';
+import { writesRegistry, writtenRegistryTypes } from '@/lib/settingsApply';
const JMAP_USING = [
'urn:ietf:params:jmap:core',
@@ -35,10 +36,44 @@ export function getAccountId(objectType: string): string {
return activeAccountId;
}
+/**
+ * inbuxa: told about every registry write, so that saved settings can be
+ * applied on the server (settingsApplyStore). `started` comes before the
+ * request goes out and `finished` after it settles, with the types it changed.
+ */
+export interface RegistryWriteListener {
+ started(): void;
+ finished(objectNames: string[]): void;
+}
+
+let registryWriteListener: RegistryWriteListener | null = null;
+
+export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
+ registryWriteListener = listener;
+}
+
export async function jmapRequest(
methodCalls: JmapMethodCall[],
signal?: AbortSignal,
extraUsing: string[] = [],
+): Promise {
+ const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
+ if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
+ listener.started();
+ let written: string[] = [];
+ try {
+ const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
+ written = writtenRegistryTypes(responses);
+ return responses;
+ } finally {
+ listener.finished(written);
+ }
+}
+
+async function sendJmapRequest(
+ methodCalls: JmapMethodCall[],
+ signal?: AbortSignal,
+ extraUsing: string[] = [],
): Promise {
const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap';
diff --git a/src/stores/settingsApplyStore.test.ts b/src/stores/settingsApplyStore.test.ts
new file mode 100644
index 0000000..897480f
--- /dev/null
+++ b/src/stores/settingsApplyStore.test.ts
@@ -0,0 +1,167 @@
+/*
+ * 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 { JmapMethodCall, JmapMethodResponse } from '@/types/jmap';
+
+const mocks = vi.hoisted(() => ({
+ jmapRequest: vi.fn<(calls: JmapMethodCall[]) => Promise>(),
+ 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';
+
+function save(...types: string[]) {
+ mocks.listener!.started();
+ mocks.listener!.finished(types);
+}
+
+function reloadCreates(call: JmapMethodCall): unknown[] {
+ expect(call[0]).toBe('x:Action/set');
+ return Object.values((call[1] as { create: Record }).create);
+}
+
+function answer(created: string[], notCreated: Record = {}): 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(['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'] });
+ });
+});
diff --git a/src/stores/settingsApplyStore.ts b/src/stores/settingsApplyStore.ts
new file mode 100644
index 0000000..7694d00
--- /dev/null
+++ b/src/stores/settingsApplyStore.ts
@@ -0,0 +1,168 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: applies saved settings on the running server.
+ *
+ * Every registry write 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. A reload that fails stays queued and shows in
+ * SettingsApplyBanner until it is applied, by "Apply now" or by the next save.
+ *
+ * If the server one day applies these writes by itself, the reload sent here
+ * is a second, harmless one: one per burst of saves, never one per object.
+ */
+
+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,
+ reloadActionsFor,
+ type ApplyFailure,
+ type ReloadAction,
+} from '@/lib/settingsApply';
+import type { JmapSetError } from '@/types/jmap';
+
+export const APPLY_DELAY_MS = 600;
+
+interface SettingsApplyState {
+ /** Actions waiting to be sent: 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;
+ /** Queues the actions the written types need, and applies them once writes settle. */
+ noteWrites: (objectNames: string[]) => void;
+ /** Sends whatever is queued now, without waiting. */
+ applyNow: () => Promise;
+ /** Hides the failure. What failed stays queued for the next save. */
+ dismiss: () => void;
+}
+
+let timer: ReturnType | 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;
+
+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;
+ const { pending, failure } = useSettingsApplyStore.getState();
+ if (pending.length === 0 || writesInFlight > 0) return;
+ if (failure && !freshlyQueued) return;
+ timer = setTimeout(() => {
+ timer = null;
+ void useSettingsApplyStore.getState().applyNow();
+ }, APPLY_DELAY_MS);
+}
+
+export const useSettingsApplyStore = create()((set, get) => ({
+ pending: [],
+ applying: false,
+ failure: null,
+
+ noteWrites: (objectNames) => {
+ const due = reloadActionsFor(objectNames);
+ if (due.length > 0) {
+ freshlyQueued = true;
+ set({ pending: merge(get().pending, due) });
+ }
+ 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;
+ set({ applying: true, pending: [] });
+ let notApplied: ReloadAction[] = [];
+ let failure: ApplyFailure | null = null;
+ try {
+ const create: Record> = {};
+ 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;
+ const notCreated = (result.notCreated ?? {}) as Record;
+ 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) {
+ toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
+ }
+
+ if (applyAgain) {
+ applyAgain = false;
+ schedule();
+ }
+ },
+
+ dismiss: () => set({ failure: null }),
+}));
+
+setRegistryWriteListener({
+ started() {
+ writesInFlight++;
+ if (timer) clearTimeout(timer);
+ timer = null;
+ },
+ finished(objectNames) {
+ writesInFlight = Math.max(0, writesInFlight - 1);
+ useSettingsApplyStore.getState().noteWrites(objectNames);
+ },
+});
+
+/** Test hook: forget timers and counters between cases. */
+export function resetSettingsApplyForTests() {
+ if (timer) clearTimeout(timer);
+ timer = null;
+ writesInFlight = 0;
+ applyAgain = false;
+ freshlyQueued = false;
+ useSettingsApplyStore.setState({ pending: [], applying: false, failure: null });
+}
diff --git a/src/types/jmap.ts b/src/types/jmap.ts
index c16eabb..318f9d2 100644
--- a/src/types/jmap.ts
+++ b/src/types/jmap.ts
@@ -1,7 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ *
+ * Modified by Coffey Labs in 2026 for INBUXA.
*/
export interface JmapRequest {
@@ -52,11 +55,17 @@ export interface JmapSetError {
description?: string;
properties?: string[];
existingId?: string;
- objectId?: string;
+ objectId?: string | JmapObjectRef;
linkedObjects?: string[];
validationErrors?: ValidationError[];
}
+/** A registry object as the server names it in an error: its type without `x:`, and its id. */
+export interface JmapObjectRef {
+ object: string;
+ id: string;
+}
+
export interface ValidationError {
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
property: string;