Merge pull request #13 from LINUXexpert-org/identity-submission-capability

Ask for the submission capability when using identities
This commit is contained in:
LINUXexpert.org
2026-08-24 06:37:11 -07:00
committed by GitHub
2 changed files with 106 additions and 2 deletions
+86
View File
@@ -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: "[email protected]" } } });
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);
});
});
+20 -2
View File
@@ -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. */ /** Low-level request: send invocations verbatim, return raw response. */
async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> { async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> {
const body: Record<string, unknown> = { using, methodCalls }; const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds; if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) }); const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
if (res.sessionState && this.session && res.sessionState !== this.session.state) { if (res.sessionState && this.session && res.sessionState !== this.session.state) {
@@ -302,8 +315,13 @@ function usingFor(method: string): string[] {
case "Thread": case "Thread":
case "Email": case "Email":
case "SearchSnippet": case "SearchSnippet":
case "Identity":
return [CAP.mail]; 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": case "EmailSubmission":
return [CAP.mail, CAP.submission]; return [CAP.mail, CAP.submission];
case "VacationResponse": case "VacationResponse":