Merge pull request #13 from LINUXexpert-org/identity-submission-capability
Ask for the submission capability when using identities
This commit is contained in:
@@ -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
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user