refactor: carry the JMAP error type on thrown errors through one helper

This commit is contained in:
Linus Rath
2026-09-25 16:04:12 +02:00
parent 70be66a4a0
commit e147835f0c
5 changed files with 82 additions and 9 deletions
+2 -1
View File
@@ -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') {
+2 -2
View File
@@ -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;
+39
View File
@@ -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([]);
});
});
+3 -6
View File
@@ -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);
}
}
+36
View File
@@ -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 ?? [];
}