From e147835f0c26feb144bd9c6c8b86a2cba718af43 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:04:12 +0200 Subject: [PATCH] refactor: carry the JMAP error type on thrown errors through one helper --- components/mail/mail-app.tsx | 3 +- components/settings/folder-settings.tsx | 4 +-- lib/__tests__/jmap-error.test.ts | 39 +++++++++++++++++++++++++ lib/jmap/client.ts | 9 ++---- lib/jmap/jmap-error.ts | 36 +++++++++++++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/jmap-error.test.ts create mode 100644 lib/jmap/jmap-error.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b47592297..c69594007 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -69,6 +69,7 @@ import { PromptDialog } from "@/components/ui/prompt-dialog"; import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; +import { jmapErrorType } from "@/lib/jmap/jmap-error"; import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; import type { ContactSuggestion } from "@/lib/search-suggestions"; import type { Attachment } from "@/lib/jmap/types"; @@ -2878,7 +2879,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { await deleteMailbox(client, mailboxId); toast.success(tCtxMenu('mailbox_context_menu.toast_folder_deleted')); } catch (err: unknown) { - const jmapType = (err as Error & { jmapType?: string })?.jmapType; + const jmapType = jmapErrorType(err); if (jmapType === 'mailboxHasChild') { toast.error(tCtxMenu('mailbox_context_menu.toast_error_delete_has_children')); } else if (jmapType === 'mailboxHasEmail') { diff --git a/components/settings/folder-settings.tsx b/components/settings/folder-settings.tsx index bb6831542..4fe16b66b 100644 --- a/components/settings/folder-settings.tsx +++ b/components/settings/folder-settings.tsx @@ -17,6 +17,7 @@ import { type AppIcon, } from '@/components/icons'; import { cn, buildMailboxTree, flattenMailboxTree, type MailboxNode } from '@/lib/utils'; +import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { flattenVisibleTree, removeSubtree, getProjection, getDropPlan, type FlatFolder, @@ -347,8 +348,7 @@ export function FolderSettings() { setDeletingId(null); toast.success(t('folder_deleted')); } catch (err: unknown) { - const jmapType = (err as Error & { jmapType?: string })?.jmapType; - switch (jmapType) { + switch (jmapErrorType(err)) { case 'mailboxHasChild': toast.error(t('error_delete_has_children')); break; diff --git a/lib/__tests__/jmap-error.test.ts b/lib/__tests__/jmap-error.test.ts new file mode 100644 index 000000000..c0dfb50a2 --- /dev/null +++ b/lib/__tests__/jmap-error.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { jmapError, jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error'; + +describe('jmapError', () => { + it('keeps the SetError type and properties on the thrown error', () => { + const error = jmapError('Could not save the contact', { + type: 'invalidProperties', + description: 'blobIds in media is not supported.', + properties: ['media'], + }); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Could not save the contact'); + expect(jmapErrorType(error)).toBe('invalidProperties'); + expect(jmapErrorProperties(error)).toEqual(['media']); + }); + + it('copies the properties so later edits to the response do not leak in', () => { + const properties = ['name']; + const error = jmapError('rejected', { type: 'invalidProperties', properties }); + properties.push('parentId'); + + expect(jmapErrorProperties(error)).toEqual(['name']); + }); + + it('works without error details', () => { + const error = jmapError('failed', null); + + expect(jmapErrorType(error)).toBeUndefined(); + expect(jmapErrorProperties(error)).toEqual([]); + }); + + it('reads nothing from values that are not JMAP errors', () => { + expect(jmapErrorType(new Error('plain'))).toBeUndefined(); + expect(jmapErrorType(undefined)).toBeUndefined(); + expect(jmapErrorType('overQuota')).toBeUndefined(); + expect(jmapErrorProperties(null)).toEqual([]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index bb9f975b1..23788efc2 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -15,6 +15,7 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { contactFromWire, contactToWire } from "./contact-wire"; import { SchedulingDeniedError } from "./scheduling-error"; +import { jmapError } from "./jmap-error"; export { SchedulingDeniedError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; @@ -642,9 +643,7 @@ function numberedFileName(name: string, n: number): string { function assertQuerySucceeded(response: JMAPResponse, what: string): void { const [name, result] = response.methodResponses?.[0] ?? []; if (name === 'error') { - const error = new Error(`${what} failed: ${result?.description || result?.type || 'unknown error'}`); - (error as Error & { jmapType?: string }).jmapType = result?.type; - throw error; + throw jmapError(`${what} failed: ${result?.description || result?.type || 'unknown error'}`, result); } } @@ -3017,9 +3016,7 @@ export class JMAPClient implements IJMAPClient { const result = response.methodResponses?.[0]?.[1]; if (result?.notDestroyed?.[mailboxId]) { const err = result.notDestroyed[mailboxId]; - const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`); - (error as Error & { jmapType?: string }).jmapType = err.type; - throw error; + throw jmapError(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`, err); } } diff --git a/lib/jmap/jmap-error.ts b/lib/jmap/jmap-error.ts new file mode 100644 index 000000000..bff0daa6c --- /dev/null +++ b/lib/jmap/jmap-error.ts @@ -0,0 +1,36 @@ +/** + * JMAP errors carried on a thrown `Error`. + * + * A method error or a SetError names what went wrong in its `type` + * (`overQuota`, `forbidden`, `invalidProperties`, ...) and, for + * `invalidProperties`, which `properties` the server refused. Callers + * branch on those instead of parsing the human-readable description, which + * differs between servers and Stalwart releases. + */ + +/** The parts of a JMAP method error or SetError a caller can branch on. */ +export interface JmapErrorInfo { + type?: string; + description?: string; + properties?: string[]; +} + +export type JmapError = Error & { jmapType?: string; jmapProperties?: string[] }; + +/** An `Error` with `message` that keeps the server's error type and properties. */ +export function jmapError(message: string, info?: JmapErrorInfo | null): JmapError { + const error: JmapError = new Error(message); + if (info?.type) error.jmapType = info.type; + if (info?.properties?.length) error.jmapProperties = [...info.properties]; + return error; +} + +/** The JMAP error type a thrown value carries, if any. */ +export function jmapErrorType(error: unknown): string | undefined { + return (error as JmapError | null | undefined)?.jmapType; +} + +/** The properties an `invalidProperties` error named, if any. */ +export function jmapErrorProperties(error: unknown): string[] { + return (error as JmapError | null | undefined)?.jmapProperties ?? []; +}