diff --git a/web/src/jmap/__tests__/using.test.ts b/web/src/jmap/__tests__/using.test.ts new file mode 100644 index 0000000..2d25842 --- /dev/null +++ b/web/src/jmap/__tests__/using.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; + +/** + * The `using` property of a JMAP request is not decoration: Stalwart >= 0.16 + * refuses Identity/get and Identity/set with `unknownMethod` unless the + * submission capability is named, which left users unable to see or create an + * identity — and so unable to send at all (issue #12). + */ + +function session(caps: string[]): JmapSession { + return { + capabilities: Object.fromEntries(caps.map((c) => [c, {}])), + accounts: {}, + primaryAccounts: {}, + state: "s1", + } as unknown as JmapSession; +} + +/** Capture the `using` array of the single request a batch produces. */ +function captureUsing(): () => string[] { + const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string) as { methodCalls: [string, unknown, string][] }; + return { + ok: true, + status: 200, + json: async () => ({ methodResponses: body.methodCalls.map(([, , id]) => ["ok", {}, id]) }), + } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + return () => { + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]![1]; + return (JSON.parse(init.body as string) as { using: string[] }).using; + }; +} + +const ALL = [CAP.core, CAP.mail, CAP.submission, CAP.contacts, CAP.contactsParse]; + +beforeEach(() => { + client.session = session(ALL); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + client.session = null; +}); + +describe("request `using`", () => { + it("names the submission capability for Identity methods", async () => { + const using = captureUsing(); + await client.call("Identity/get", { accountId: "a1", ids: null }); + expect(using()).toContain(CAP.submission); + }); + + it("names it for Identity/set too, so identities can be created", async () => { + const using = captureUsing(); + await client.call("Identity/set", { accountId: "a1", create: { n: { email: "a@b.c" } } }); + expect(using()).toContain(CAP.submission); + }); + + it("unions the capabilities of every call batched into one request", async () => { + const using = captureUsing(); + await Promise.all([ + client.call("Identity/get", { accountId: "a1", ids: null }), + client.call("Mailbox/get", { accountId: "a1", ids: null }), + ]); + expect(using()).toEqual(expect.arrayContaining([CAP.core, CAP.mail, CAP.submission])); + }); + + it("drops capabilities the session never advertised", async () => { + client.session = session([CAP.core, CAP.mail]); + const using = captureUsing(); + await client.call("Identity/get", { accountId: "a1", ids: null }); + expect(using()).toEqual(expect.arrayContaining([CAP.core, CAP.mail])); + expect(using()).not.toContain(CAP.submission); + }); + + it("always keeps core, even before a session is known", async () => { + client.session = null; + const using = captureUsing(); + await client.call("Email/get", { accountId: "a1", ids: [] }); + expect(using()).toContain(CAP.core); + }); +}); diff --git a/web/src/jmap/client.ts b/web/src/jmap/client.ts index ba07fdb..d23c117 100644 --- a/web/src/jmap/client.ts +++ b/web/src/jmap/client.ts @@ -195,9 +195,22 @@ export class JmapClient { } } + /** + * Drop capabilities this session never advertised. + * + * A server MUST reject the whole request with `unknownCapability` when + * `using` names something it does not implement (RFC 8620), which would take + * down every call in the batch — not just the one that wanted the capability. + * Core always stays: it is the one urn every server has. + */ + private supportedUsing(using: string[]): string[] { + if (!this.session?.capabilities) return using; + return using.filter((u) => u === CAP.core || this.hasCapability(u)); + } + /** Low-level request: send invocations verbatim, return raw response. */ async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record): Promise { - const body: Record = { using, methodCalls }; + const body: Record = { using: this.supportedUsing(using), methodCalls }; if (createdIds) body.createdIds = createdIds; const res = await apiFetch("/api/jmap", { method: "POST", body: JSON.stringify(body) }); if (res.sessionState && this.session && res.sessionState !== this.session.state) { @@ -302,8 +315,13 @@ function usingFor(method: string): string[] { case "Thread": case "Email": case "SearchSnippet": - case "Identity": return [CAP.mail]; + // Identity belongs to the submission capability (RFC 8621), not mail: + // Stalwart >= 0.16 rejects Identity/get and Identity/set outright when + // "using" names only mail. Keep mail as well, so the filter in + // supportedUsing() still leaves a usable urn on servers that predate + // advertising submission. + case "Identity": case "EmailSubmission": return [CAP.mail, CAP.submission]; case "VacationResponse":