Say which property a JMAP SetError rejected

"Send failed: Invalid property or value." is Stalwart's description for
invalidProperties, and on its own it says nothing about what to fix. The
SetError also carries a `properties` array naming the offending fields, which
every call site was discarding.

setErrorMessage appends them, and the 35 places that surfaced a SetError -
send, save draft, mailboxes, calendars, contacts, sieve, files, signature
images, sharing - now go through it.
This commit is contained in:
2026-08-23 13:51:02 -07:00
parent 55af4eab8f
commit e05880eefc
10 changed files with 75 additions and 43 deletions
+7 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings";
@@ -168,7 +168,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const obj = { "@type": "Event", uid: crypto.randomUUID(), ...event, calendarIds: { [calendarId]: true } };
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites });
const err = res.notCreated?.e;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
get().invalidate();
return res.created!.e!.id;
},
@@ -177,7 +177,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
get().invalidate();
},
@@ -185,7 +185,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
set((s) => {
const events = { ...s.events };
delete events[id];
@@ -212,7 +212,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse<Calendar>>("Calendar/set", { accountId, create: { c: { name: "Calendar", ...data } } });
const err = res.notCreated?.c;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars();
return res.created!.c!.id;
},
@@ -221,7 +221,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars();
},
@@ -229,7 +229,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, destroy: [id], onDestroyRemoveEvents: true });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars();
get().invalidate();
},
+6 -6
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { client } from "@/jmap/client";
import { client, setErrorMessage } from "@/jmap/client";
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
@@ -611,7 +611,7 @@ async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s
if (d.draftId) args.destroy = [d.draftId];
const res = await client.call<SetResponse<Email>>("Email/set", args);
const err = res.notCreated?.draft;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
const newId = res.created?.draft?.id ?? null;
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, draftId: newId, saving: false, dirty: false, savedAt: Date.now(), error: null } : x)) }));
void mail.loadMailboxes();
@@ -654,16 +654,16 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
}
const res = await client.chain(calls, { allowErrors: true });
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
if (e.__error) throw new Error(e.__error.description ?? e.__error.type);
if (e.notCreated?.m) throw new Error(e.notCreated.m.description ?? e.notCreated.m.type);
if (e.__error) throw new Error(setErrorMessage(e.__error));
if (e.notCreated?.m) throw new Error(setErrorMessage(e.notCreated.m));
const s = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
if (s.__error) throw new Error(s.__error.description ?? s.__error.type);
if (s.__error) throw new Error(setErrorMessage(s.__error));
if (s.notCreated?.s) {
const err = s.notCreated.s;
// Clean up the created (unsent) email so it doesn't linger in Sent.
const created = e.created?.m?.id;
if (created) void client.call("Email/set", { accountId, destroy: [created] });
throw new Error(err.description ?? err.type);
throw new Error(setErrorMessage(err));
}
if (d.relatedEmailId && d.relatedKeyword) {
useMail.setState((st) => {
+7 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { useSession } from "./session";
@@ -133,7 +133,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: { c: obj } });
const err = res.notCreated?.c;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
const id = res.created!.c!.id;
await get().getCard(id);
return id;
@@ -143,7 +143,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().getCard(id);
},
@@ -151,7 +151,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: ids });
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(failed.description ?? failed.type);
if (failed) throw new Error(setErrorMessage(failed));
set((s) => {
const cards = { ...s.cards };
for (const id of ids) delete cards[id];
@@ -163,7 +163,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
const err = res.notCreated?.b;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
return res.created!.b!.id;
},
@@ -172,7 +172,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
},
@@ -180,7 +180,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadBooks();
await get().loadAll();
},
+6 -6
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { CAP, client, JmapMethodError } from "@/jmap/client";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
@@ -114,7 +114,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } });
const err = res.notCreated?.d;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(parentId);
return res.created!.d!.id;
},
@@ -134,7 +134,7 @@ export const useFiles = create<FilesState>((set, get) => ({
create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } },
});
const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
set((s) => ({ uploads: s.uploads.filter((u) => u.id !== id) }));
} catch (err) {
set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, error: (err as Error).message } : u)) }));
@@ -147,7 +147,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(get().nodes[id]?.parentId ?? null);
},
@@ -156,7 +156,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const from = get().nodes[id]?.parentId ?? null;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
},
@@ -165,7 +165,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const parents = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
const res = await client.call<SetResponse>("FileNode/set", { accountId, destroy: ids, onDestroyRemoveChildren: true });
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(failed.description ?? failed.type);
if (failed) throw new Error(setErrorMessage(failed));
for (const p of parents) await get().loadChildren(p);
},
+8 -8
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { client, chunk, JmapMethodError } from "@/jmap/client";
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type {
Comparator,
Email,
@@ -626,7 +626,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } });
const err = res.notCreated?.n;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes();
return res.created!.n!.id;
},
@@ -635,7 +635,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes();
},
@@ -643,7 +643,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes();
},
@@ -684,7 +684,7 @@ export const useMail = create<MailState>((set, get) => ({
? await client.call<SetResponse<Identity>>("Identity/set", { accountId, update: { [id]: patch } })
: await client.call<SetResponse<Identity>>("Identity/set", { accountId, create: { n: patch } });
const err = id ? res.notUpdated?.[id] : res.notCreated?.n;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadIdentities();
},
@@ -692,7 +692,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Identity/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadIdentities();
},
@@ -711,7 +711,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("VacationResponse/set", { accountId, update: { singleton: patch } });
const err = res.notUpdated?.singleton;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().loadVacation();
},
@@ -823,7 +823,7 @@ export const useMail = create<MailState>((set, get) => ({
accountId,
emails: { i: { blobId, mailboxIds: { [mailboxId]: true }, keywords } },
});
if (res.notCreated?.i) throw new Error(res.notCreated.i.description ?? res.notCreated.i.type);
if (res.notCreated?.i) throw new Error(setErrorMessage(res.notCreated.i));
void get().refreshList();
void get().loadMailboxes();
return res.created?.i?.id ?? null;
+3 -3
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
import { useSession } from "./session";
@@ -97,7 +97,7 @@ export const useSieve = create<SieveState>((set, get) => ({
if (activate) args.onSuccessActivateScript = id ?? "#s";
const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args);
const err = id ? res.notUpdated?.[id] : res.notCreated?.s;
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
const newId = id ?? res.created!.s!.id;
set((s) => ({ contents: { ...s.contents, [newId]: content } }));
await get().load();
@@ -118,7 +118,7 @@ export const useSieve = create<SieveState>((set, get) => ({
const accountId = get().accountId!;
const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
if (err) throw new Error(setErrorMessage(err));
await get().load();
},