/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ 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'; const JMAP_USING = [ 'urn:ietf:params:jmap:core', 'urn:stalwart:jmap', '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; } export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortSignal): Promise { 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, 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 { const args: Record = { accountId, ids }; if (properties) { args.properties = properties; } return jmapRequest([[`${objectType}/get`, args, '0']], signal); } interface JmapQueryOptions { filter?: Record; sort?: Record[]; limit?: number; position?: number; anchor?: string; anchorOffset?: number; calculateTotal?: boolean; } export async function jmapQuery( objectType: string, accountId: string, options: JmapQueryOptions = {}, ): Promise { const args: Record = { accountId, ...options }; return jmapRequest([[`${objectType}/query`, args, '0']]); } interface JmapSetOptions { create?: Record>; update?: Record>; destroy?: string[]; } export async function jmapSet( objectType: string, accountId: string, options: JmapSetOptions = {}, ): Promise { const args: Record = { accountId, ...options }; return jmapRequest([[`${objectType}/set`, args, '0']]); } export async function jmapQueryAndGet( objectType: string, accountId: string, queryOptions: JmapQueryOptions = {}, properties?: string[], ): Promise { const queryArgs: Record = { accountId, ...queryOptions, }; const getArgs: Record = { 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[]> { if (ids.length === 0) return []; const batchSize = useAuthStore.getState().maxObjectsInGet; const allItems: Record[] = []; for (let offset = 0; offset < ids.length; offset += batchSize) { if (signal?.aborted) break; const batchIds = ids.slice(offset, offset + batchSize); const args: Record = { 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).type ?? 'unknown'}`); } const list = (result[1] as { list?: Record[] }).list ?? []; allItems.push(...list); } return allItems; } export async function jmapQueryAll( objectType: string, accountId: string, queryOptions: JmapQueryOptions = {}, signal?: AbortSignal, ): Promise { 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 = { 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).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[] }> { 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> { const response = await apiFetch('/jmap/session'); return response.json() as Promise>; } export async function fetchSchema(): Promise { const response = await apiFetch('/api/schema'); return response.json() as Promise; } interface AccountInfoResponse { permissions: string[]; edition: 'enterprise' | 'community' | 'oss'; locale: string; } export async function fetchAccountInfo(): Promise { const response = await apiFetch('/api/account'); return response.json() as Promise; }