Merge pull request #9 from LINUXexpert-org/set-error-detail

Fix sending: never send null for an empty header property
This commit is contained in:
LINUXexpert.org
2026-08-23 14:02:32 -07:00
committed by GitHub
11 changed files with 163 additions and 50 deletions
+14
View File
@@ -346,3 +346,17 @@ export function chunk<T>(arr: T[], size: number): T[][] {
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out; return out;
} }
/**
* A readable message for a JMAP SetError.
*
* Servers name the offending field in `properties`, which is usually the whole
* answer to "why was this rejected" — Stalwart's description alone is often
* just "Invalid property or value." Keep both.
*/
export function setErrorMessage(err: { type: string; description?: string; properties?: string[] } | null | undefined): string {
if (!err) return "Unknown error";
const base = err.description ?? err.type;
const props = err.properties?.length ? ` (${err.properties.join(", ")})` : "";
return `${base}${props}`;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { setErrorMessage } from "@/jmap/client";
describe("setErrorMessage", () => {
it("names the offending properties, which is usually the whole answer", () => {
expect(setErrorMessage({ type: "invalidProperties", description: "Invalid property or value.", properties: ["sentAt"] }))
.toBe("Invalid property or value. (sentAt)");
expect(setErrorMessage({ type: "invalidProperties", properties: ["to", "cc"] }))
.toBe("invalidProperties (to, cc)");
});
it("falls back cleanly when the server says less", () => {
expect(setErrorMessage({ type: "forbidden" })).toBe("forbidden");
expect(setErrorMessage({ type: "forbidden", description: "Not allowed" })).toBe("Not allowed");
expect(setErrorMessage({ type: "x", properties: [] })).toBe("x");
expect(setErrorMessage(null)).toBe("Unknown error");
});
});
+4 -4
View File
@@ -4,7 +4,7 @@
* blobs) under an "ihasmail" folder and reference them by blob URL; the composer * blobs) under an "ihasmail" folder and reference them by blob URL; the composer
* turns such references into inline cid: parts when sending. * turns such references into inline cid: parts when sending.
*/ */
import { CAP, client } from "@/jmap/client"; import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
@@ -31,7 +31,7 @@ async function ensureFolder(accountId: string): Promise<string> {
if (existing) return existing.id; if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } }); const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } });
const err = set.notCreated?.d; const err = set.notCreated?.d;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id; return set.created!.d!.id;
} }
@@ -53,7 +53,7 @@ export async function uploadSignatureImage(file: File): Promise<string> {
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`; const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } });
const err = res.notCreated?.f; const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
const created = res.created?.f as Partial<FileNode> | undefined; const created = res.created?.f as Partial<FileNode> | undefined;
// Prefer the node's (persistent) blobId if the server returned one. // Prefer the node's (persistent) blobId if the server returned one.
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId; const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
@@ -73,7 +73,7 @@ export async function storeSignatureHtml(html: string): Promise<string> {
const name = `signature-${Date.now()}.html`; const name = `signature-${Date.now()}.html`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } });
const err = res.notCreated?.f; const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
const created = res.created?.f as Partial<FileNode> | undefined; const created = res.created?.f as Partial<FileNode> | undefined;
return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId; return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
} }
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it } from "vitest";
import { buildEmailObject, type Draft } from "@/store/compose";
import { useMail } from "@/store/mail";
/**
* JMAP servers may reject `null` for a header property — Stalwart parses these
* as address lists and fails the whole create, which took down every send.
* Empty header fields must be omitted, not nulled.
*/
function draft(over: Partial<Draft> = {}): Draft {
return {
key: "k", draftId: null, identityId: "i1",
to: [{ name: null, email: "[email protected]" }],
cc: [], bcc: [], replyTo: [],
subject: "Hello", html: "", text: "Hi there", format: "text",
attachments: [], inReplyTo: null, references: null,
relatedEmailId: null, relatedKeyword: null,
requestReceipt: false, priority: "normal",
showCc: false, showBcc: false, showReplyTo: false,
minimized: false, maximized: false, dirty: false, savedAt: null,
saving: false, sending: false, error: null, signatureHtml: "", replyMode: null,
...over,
};
}
beforeEach(() => {
useMail.setState({
accountId: "a1",
identities: [{ id: "i1", name: "John", email: "[email protected]", replyTo: null }] as never,
mailboxes: { mb1: { id: "mb1", role: "sent", name: "Sent" }, mb2: { id: "mb2", role: "drafts", name: "Drafts" } } as never,
});
});
describe("buildEmailObject", () => {
it("omits empty header properties rather than sending null", async () => {
const obj = await buildEmailObject(draft(), { forSend: true });
expect(obj).not.toHaveProperty("cc");
expect(obj).not.toHaveProperty("bcc");
expect(obj).not.toHaveProperty("replyTo");
expect(obj).not.toHaveProperty("inReplyTo");
expect(obj).not.toHaveProperty("references");
expect(obj.to).toEqual([{ name: null, email: "[email protected]" }]);
});
it("includes header properties that have a value", async () => {
const obj = await buildEmailObject(
draft({
cc: [{ name: null, email: "[email protected]" }],
bcc: [{ name: null, email: "[email protected]" }],
replyTo: [{ name: null, email: "[email protected]" }],
inReplyTo: ["<a@b>"],
references: ["<a@b>"],
}),
{ forSend: true },
);
expect(obj.cc).toHaveLength(1);
expect(obj.bcc).toHaveLength(1);
expect(obj.replyTo).toHaveLength(1);
expect(obj.inReplyTo).toEqual(["<a@b>"]);
expect(obj.references).toEqual(["<a@b>"]);
});
it("never emits a null value for any property", async () => {
for (const forSend of [true, false]) {
const obj = await buildEmailObject(draft({ subject: "" }), { forSend });
for (const [k, v] of Object.entries(obj)) {
expect(v, `${k} is null`).not.toBeNull();
}
}
});
it("files a sent message in Sent and a draft in Drafts", async () => {
expect((await buildEmailObject(draft(), { forSend: true })).mailboxIds).toEqual({ mb1: true });
expect((await buildEmailObject(draft(), { forSend: false })).mailboxIds).toEqual({ mb2: true });
});
});
+7 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates"; import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings"; 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 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 res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites });
const err = res.notCreated?.e; const err = res.notCreated?.e;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
get().invalidate(); get().invalidate();
return res.created!.e!.id; return res.created!.e!.id;
}, },
@@ -177,7 +177,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites }); const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
get().invalidate(); get().invalidate();
}, },
@@ -185,7 +185,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites }); const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
const err = res.notDestroyed?.[id]; const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
set((s) => { set((s) => {
const events = { ...s.events }; const events = { ...s.events };
delete events[id]; delete events[id];
@@ -212,7 +212,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse<Calendar>>("Calendar/set", { accountId, create: { c: { name: "Calendar", ...data } } }); const res = await client.call<SetResponse<Calendar>>("Calendar/set", { accountId, create: { c: { name: "Calendar", ...data } } });
const err = res.notCreated?.c; const err = res.notCreated?.c;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars(); await get().loadCalendars();
return res.created!.c!.id; return res.created!.c!.id;
}, },
@@ -221,7 +221,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [id]: patch } }); const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars(); await get().loadCalendars();
}, },
@@ -229,7 +229,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, destroy: [id], onDestroyRemoveEvents: true }); const res = await client.call<SetResponse>("Calendar/set", { accountId, destroy: [id], onDestroyRemoveEvents: true });
const err = res.notDestroyed?.[id]; const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadCalendars(); await get().loadCalendars();
get().invalidate(); get().invalidate();
}, },
+18 -13
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format"; import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address"; import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
@@ -481,7 +481,7 @@ function scheduleAutosave(key: string, get: () => ComposeState) {
} }
/** Build the JMAP Email creation object from a draft. */ /** Build the JMAP Email creation object from a draft. */
async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> { export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> {
const mail = useMail.getState(); const mail = useMail.getState();
const accountId = mail.accountId!; const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
@@ -561,18 +561,23 @@ async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<R
const obj: Record<string, unknown> = { const obj: Record<string, unknown> = {
from: [from], from: [from],
to: d.to.length ? d.to : null,
cc: d.cc.length ? d.cc : null,
bcc: d.bcc.length ? d.bcc : null,
replyTo: d.replyTo.length ? d.replyTo : ident.replyTo?.length ? ident.replyTo : null,
subject: d.subject, subject: d.subject,
sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
inReplyTo: d.inReplyTo,
references: d.references,
bodyStructure, bodyStructure,
bodyValues, bodyValues,
"header:User-Agent:asText": "ihasmail/2.0", "header:User-Agent:asText": "ihasmail/2.0",
}; };
// Header properties are omitted when empty, never sent as null: JMAP servers
// are entitled to reject null for a header field (Stalwart parses these as
// address lists and fails the whole create), and on a create there is no
// previous value that would need clearing.
const replyTo = d.replyTo.length ? d.replyTo : (ident.replyTo ?? []);
if (d.to.length) obj.to = d.to;
if (d.cc.length) obj.cc = d.cc;
if (d.bcc.length) obj.bcc = d.bcc;
if (replyTo.length) obj.replyTo = replyTo;
if (d.inReplyTo?.length) obj.inReplyTo = d.inReplyTo;
if (d.references?.length) obj.references = d.references;
if (d.priority === "high") { if (d.priority === "high") {
obj["header:X-Priority:asText"] = "1 (Highest)"; obj["header:X-Priority:asText"] = "1 (Highest)";
obj["header:Importance:asText"] = "High"; obj["header:Importance:asText"] = "High";
@@ -611,7 +616,7 @@ async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s
if (d.draftId) args.destroy = [d.draftId]; if (d.draftId) args.destroy = [d.draftId];
const res = await client.call<SetResponse<Email>>("Email/set", args); const res = await client.call<SetResponse<Email>>("Email/set", args);
const err = res.notCreated?.draft; 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; 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)) })); 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(); void mail.loadMailboxes();
@@ -654,16 +659,16 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
} }
const res = await client.chain(calls, { allowErrors: true }); const res = await client.chain(calls, { allowErrors: true });
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } }; 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.__error) throw new Error(setErrorMessage(e.__error));
if (e.notCreated?.m) throw new Error(e.notCreated.m.description ?? e.notCreated.m.type); 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 } }; 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) { if (s.notCreated?.s) {
const err = s.notCreated.s; const err = s.notCreated.s;
// Clean up the created (unsent) email so it doesn't linger in Sent. // Clean up the created (unsent) email so it doesn't linger in Sent.
const created = e.created?.m?.id; const created = e.created?.m?.id;
if (created) void client.call("Email/set", { accountId, destroy: [created] }); 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) { if (d.relatedEmailId && d.relatedKeyword) {
useMail.setState((st) => { useMail.setState((st) => {
+7 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { useSession } from "./session"; 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 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 res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: { c: obj } });
const err = res.notCreated?.c; 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; const id = res.created!.c!.id;
await get().getCard(id); await get().getCard(id);
return id; return id;
@@ -143,7 +143,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } }); const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id]; 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); await get().getCard(id);
}, },
@@ -151,7 +151,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: ids }); const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: ids });
const failed = Object.values(res.notDestroyed ?? {})[0]; 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) => { set((s) => {
const cards = { ...s.cards }; const cards = { ...s.cards };
for (const id of ids) delete cards[id]; for (const id of ids) delete cards[id];
@@ -163,7 +163,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } }); const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
const err = res.notCreated?.b; const err = res.notCreated?.b;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadBooks(); await get().loadBooks();
return res.created!.b!.id; return res.created!.b!.id;
}, },
@@ -172,7 +172,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } }); const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadBooks(); await get().loadBooks();
}, },
@@ -180,7 +180,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true }); const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true });
const err = res.notDestroyed?.[id]; 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().loadBooks();
await get().loadAll(); await get().loadAll();
}, },
+6 -6
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -114,7 +114,7 @@ export const useFiles = create<FilesState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } }); const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } });
const err = res.notCreated?.d; 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); await get().loadChildren(parentId);
return res.created!.d!.id; 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" } }, create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } },
}); });
const err = res.notCreated?.f; 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) })); set((s) => ({ uploads: s.uploads.filter((u) => u.id !== id) }));
} catch (err) { } catch (err) {
set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, error: (err as Error).message } : u)) })); 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 accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } }); const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id]; 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); 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 from = get().nodes[id]?.parentId ?? null;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } }); const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
const err = res.notUpdated?.[id]; 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)]); 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 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 res = await client.call<SetResponse>("FileNode/set", { accountId, destroy: ids, onDestroyRemoveChildren: true });
const failed = Object.values(res.notDestroyed ?? {})[0]; 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); for (const p of parents) await get().loadChildren(p);
}, },
+8 -8
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import { client, chunk, JmapMethodError } from "@/jmap/client"; import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
import type { import type {
Comparator, Comparator,
Email, Email,
@@ -626,7 +626,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } }); const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } });
const err = res.notCreated?.n; const err = res.notCreated?.n;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes(); await get().loadMailboxes();
return res.created!.n!.id; return res.created!.n!.id;
}, },
@@ -635,7 +635,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } }); const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes(); await get().loadMailboxes();
}, },
@@ -643,7 +643,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails }); const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
const err = res.notDestroyed?.[id]; const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadMailboxes(); 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, update: { [id]: patch } })
: await client.call<SetResponse<Identity>>("Identity/set", { accountId, create: { n: patch } }); : await client.call<SetResponse<Identity>>("Identity/set", { accountId, create: { n: patch } });
const err = id ? res.notUpdated?.[id] : res.notCreated?.n; 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(); await get().loadIdentities();
}, },
@@ -692,7 +692,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("Identity/set", { accountId, destroy: [id] }); const res = await client.call<SetResponse>("Identity/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[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(); await get().loadIdentities();
}, },
@@ -711,7 +711,7 @@ export const useMail = create<MailState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("VacationResponse/set", { accountId, update: { singleton: patch } }); const res = await client.call<SetResponse>("VacationResponse/set", { accountId, update: { singleton: patch } });
const err = res.notUpdated?.singleton; const err = res.notUpdated?.singleton;
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
await get().loadVacation(); await get().loadVacation();
}, },
@@ -823,7 +823,7 @@ export const useMail = create<MailState>((set, get) => ({
accountId, accountId,
emails: { i: { blobId, mailboxIds: { [mailboxId]: true }, keywords } }, 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().refreshList();
void get().loadMailboxes(); void get().loadMailboxes();
return res.created?.i?.id ?? null; return res.created?.i?.id ?? null;
+3 -3
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve"; import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -97,7 +97,7 @@ export const useSieve = create<SieveState>((set, get) => ({
if (activate) args.onSuccessActivateScript = id ?? "#s"; if (activate) args.onSuccessActivateScript = id ?? "#s";
const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args); const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args);
const err = id ? res.notUpdated?.[id] : res.notCreated?.s; 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; const newId = id ?? res.created!.s!.id;
set((s) => ({ contents: { ...s.contents, [newId]: content } })); set((s) => ({ contents: { ...s.contents, [newId]: content } }));
await get().load(); await get().load();
@@ -118,7 +118,7 @@ export const useSieve = create<SieveState>((set, get) => ({
const accountId = get().accountId!; const accountId = get().accountId!;
const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] }); const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[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(); await get().load();
}, },
+2 -2
View File
@@ -4,7 +4,7 @@ import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
import { client } from "@/jmap/client"; import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types"; import type { Id, Principal } from "@/jmap/types";
@@ -70,7 +70,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId; const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } }); const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id]; const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type); if (err) throw new Error(setErrorMessage(err));
toast.success("Sharing updated"); toast.success("Sharing updated");
if (kind === "Mailbox") void useMail.getState().loadMailboxes(); if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars(); if (kind === "Calendar") void useCalendar.getState().loadCalendars();