Ask for the submission capability when using identities
Identity is defined by RFC 8621 under urn:ietf:params:jmap:submission, not under mail. ihasmail asked for mail alone, so Stalwart 0.16 rejected both Identity/get and Identity/set with unknownMethod: no identities were ever listed, none could be created, and sending then failed with "No sending identity available". Older Stalwart builds accepted the calls anyway, which is why this went unnoticed. Also filter `using` down to the capabilities the session actually advertises. A server must reject the entire request with unknownCapability when `using` names something it does not implement, so one over-eager urn would take down every call sharing the batch — including, on a server predating the submission capability, the mailbox and message loads batched alongside an identity fetch. Fixes #12
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