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.
293 lines
8.5 KiB
TypeScript
293 lines
8.5 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*
|
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
*/
|
|
|
|
import { useAuthStore } from '@/stores/authStore';
|
|
import { apiFetch } from '@/services/api';
|
|
import { logJmapExchange } from '@/lib/debug';
|
|
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
|
import type { Schema } from '@/types/schema';
|
|
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
|
|
|
|
const JMAP_USING = [
|
|
'urn:ietf:params:jmap:core',
|
|
'urn:inbuxa:jmap:registry',
|
|
'urn:ietf:params:jmap:blob',
|
|
'urn:ietf:params:jmap:mail',
|
|
'urn:ietf:params:jmap:calendars',
|
|
'urn:ietf:params:jmap:contacts',
|
|
'urn:ietf:params:jmap:principals',
|
|
'urn:ietf:params:jmap:sieve',
|
|
'urn:ietf:params:jmap:vacationresponse',
|
|
];
|
|
|
|
export function getAccountId(objectType: string): string {
|
|
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
|
if (objectType.startsWith('x:')) {
|
|
if (!primaryAccountId) throw new Error('No primary account ID available');
|
|
return primaryAccountId;
|
|
}
|
|
if (!activeAccountId) throw new Error('No active account ID available');
|
|
return activeAccountId;
|
|
}
|
|
|
|
/**
|
|
* inbuxa: told about every registry write, so that saved settings can be
|
|
* applied on the server (settingsApplyStore). `started` comes before the
|
|
* request goes out and `finished` after it settles, with the types it changed
|
|
* and what the server said about applying them.
|
|
*/
|
|
export interface RegistryWriteListener {
|
|
started(): void;
|
|
finished(writes: RegistryWrite[]): void;
|
|
}
|
|
|
|
let registryWriteListener: RegistryWriteListener | null = null;
|
|
|
|
export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
|
|
registryWriteListener = listener;
|
|
}
|
|
|
|
export async function jmapRequest(
|
|
methodCalls: JmapMethodCall[],
|
|
signal?: AbortSignal,
|
|
extraUsing: string[] = [],
|
|
): Promise<JmapMethodResponse[]> {
|
|
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
|
|
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
|
|
listener.started();
|
|
let written: RegistryWrite[] = [];
|
|
try {
|
|
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
|
|
written = registryWrites(responses);
|
|
return responses;
|
|
} finally {
|
|
listener.finished(written);
|
|
}
|
|
}
|
|
|
|
async function sendJmapRequest(
|
|
methodCalls: JmapMethodCall[],
|
|
signal?: AbortSignal,
|
|
extraUsing: string[] = [],
|
|
): Promise<JmapMethodResponse[]> {
|
|
const { apiUrl } = useAuthStore.getState();
|
|
let path = apiUrl || '/jmap';
|
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
try {
|
|
path = new URL(path).pathname;
|
|
} catch {
|
|
path = '/jmap';
|
|
}
|
|
}
|
|
|
|
const startedAt = performance.now();
|
|
const response = await apiFetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
using: [...JMAP_USING, ...extraUsing],
|
|
methodCalls,
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
const data = (await response.json()) as JmapResponse;
|
|
|
|
logJmapExchange(methodCalls, data.methodResponses, performance.now() - startedAt);
|
|
|
|
if (import.meta.env.DEV) {
|
|
for (const [methodName, result, callId] of data.methodResponses) {
|
|
if (methodName === 'error') {
|
|
console.error(`JMAP error [${callId}]:`, result);
|
|
}
|
|
}
|
|
}
|
|
|
|
return data.methodResponses;
|
|
}
|
|
|
|
export async function jmapGet(
|
|
objectType: string,
|
|
accountId: string,
|
|
ids: string[] | null,
|
|
properties?: string[],
|
|
signal?: AbortSignal,
|
|
): Promise<JmapMethodResponse[]> {
|
|
const args: Record<string, unknown> = { accountId, ids };
|
|
if (properties) {
|
|
args.properties = properties;
|
|
}
|
|
return jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
|
}
|
|
|
|
interface JmapQueryOptions {
|
|
filter?: Record<string, unknown>;
|
|
sort?: Record<string, unknown>[];
|
|
limit?: number;
|
|
position?: number;
|
|
anchor?: string;
|
|
anchorOffset?: number;
|
|
calculateTotal?: boolean;
|
|
}
|
|
|
|
export async function jmapQuery(
|
|
objectType: string,
|
|
accountId: string,
|
|
options: JmapQueryOptions = {},
|
|
): Promise<JmapMethodResponse[]> {
|
|
const args: Record<string, unknown> = { accountId, ...options };
|
|
return jmapRequest([[`${objectType}/query`, args, '0']]);
|
|
}
|
|
|
|
interface JmapSetOptions {
|
|
create?: Record<string, Record<string, unknown>>;
|
|
update?: Record<string, Record<string, unknown>>;
|
|
destroy?: string[];
|
|
}
|
|
|
|
export async function jmapSet(
|
|
objectType: string,
|
|
accountId: string,
|
|
options: JmapSetOptions = {},
|
|
): Promise<JmapMethodResponse[]> {
|
|
const args: Record<string, unknown> = { accountId, ...options };
|
|
return jmapRequest([[`${objectType}/set`, args, '0']]);
|
|
}
|
|
|
|
export async function jmapQueryAndGet(
|
|
objectType: string,
|
|
accountId: string,
|
|
queryOptions: JmapQueryOptions = {},
|
|
properties?: string[],
|
|
): Promise<JmapMethodResponse[]> {
|
|
const queryArgs: Record<string, unknown> = {
|
|
accountId,
|
|
...queryOptions,
|
|
};
|
|
|
|
const getArgs: Record<string, unknown> = {
|
|
accountId,
|
|
'#ids': {
|
|
resultOf: '0',
|
|
name: `${objectType}/query`,
|
|
path: '/ids',
|
|
},
|
|
};
|
|
if (properties) {
|
|
getArgs.properties = properties;
|
|
}
|
|
|
|
return jmapRequest([
|
|
[`${objectType}/query`, queryArgs, '0'],
|
|
[`${objectType}/get`, getArgs, '1'],
|
|
]);
|
|
}
|
|
|
|
export async function jmapGetBatched(
|
|
objectType: string,
|
|
accountId: string,
|
|
ids: string[],
|
|
properties?: string[],
|
|
signal?: AbortSignal,
|
|
): Promise<Record<string, unknown>[]> {
|
|
if (ids.length === 0) return [];
|
|
|
|
const batchSize = useAuthStore.getState().maxObjectsInGet;
|
|
const allItems: Record<string, unknown>[] = [];
|
|
|
|
for (let offset = 0; offset < ids.length; offset += batchSize) {
|
|
if (signal?.aborted) break;
|
|
const batchIds = ids.slice(offset, offset + batchSize);
|
|
const args: Record<string, unknown> = { accountId, ids: batchIds };
|
|
if (properties) args.properties = properties;
|
|
const responses = await jmapRequest([[`${objectType}/get`, args, '0']], signal);
|
|
const result = responses[0];
|
|
if (result[0] === 'error') {
|
|
throw new Error(`JMAP ${objectType}/get error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
|
}
|
|
const list = (result[1] as { list?: Record<string, unknown>[] }).list ?? [];
|
|
allItems.push(...list);
|
|
}
|
|
|
|
return allItems;
|
|
}
|
|
|
|
export async function jmapQueryAll(
|
|
objectType: string,
|
|
accountId: string,
|
|
queryOptions: JmapQueryOptions = {},
|
|
signal?: AbortSignal,
|
|
): Promise<string[]> {
|
|
const allIds: string[] = [];
|
|
let anchor: string | undefined;
|
|
const MAX_PAGES = 1000;
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
if (signal?.aborted) break;
|
|
|
|
const args: Record<string, unknown> = { accountId, ...queryOptions };
|
|
if (anchor) {
|
|
delete args.position;
|
|
args.anchor = anchor;
|
|
args.anchorOffset = 1;
|
|
}
|
|
|
|
const responses = await jmapRequest([[`${objectType}/query`, args, '0']], signal);
|
|
const result = responses[0];
|
|
if (result[0] === 'error') {
|
|
throw new Error(`JMAP ${objectType}/query error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
|
|
}
|
|
|
|
const data = result[1] as unknown as JmapQueryResponse;
|
|
const pageIds = data.ids ?? [];
|
|
if (pageIds.length === 0) break;
|
|
|
|
allIds.push(...pageIds);
|
|
|
|
if (data.limit === undefined || data.limit === null) break;
|
|
|
|
anchor = pageIds[pageIds.length - 1];
|
|
}
|
|
|
|
return allIds;
|
|
}
|
|
|
|
export async function jmapQueryAllAndGet(
|
|
objectType: string,
|
|
accountId: string,
|
|
queryOptions: JmapQueryOptions = {},
|
|
properties?: string[],
|
|
signal?: AbortSignal,
|
|
): Promise<{ ids: string[]; list: Record<string, unknown>[] }> {
|
|
const ids = await jmapQueryAll(objectType, accountId, queryOptions, signal);
|
|
if (ids.length === 0) return { ids, list: [] };
|
|
const list = await jmapGetBatched(objectType, accountId, ids, properties, signal);
|
|
return { ids, list };
|
|
}
|
|
|
|
export async function fetchSession(): Promise<Record<string, unknown>> {
|
|
const response = await apiFetch('/jmap/session');
|
|
return response.json() as Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
export async function fetchSchema(): Promise<Schema> {
|
|
const response = await apiFetch('/api/schema');
|
|
return response.json() as Promise<Schema>;
|
|
}
|
|
|
|
interface AccountInfoResponse {
|
|
permissions: string[];
|
|
edition: 'enterprise' | 'community' | 'oss';
|
|
locale: string;
|
|
}
|
|
|
|
export async function fetchAccountInfo(): Promise<AccountInfoResponse> {
|
|
const response = await apiFetch('/api/account');
|
|
return response.json() as Promise<AccountInfoResponse>;
|
|
}
|