Stalwart 1.0: load calendars, and keep settings and Files working #67
No files matched your search
@@ -48,6 +48,12 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
||||
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
|
||||
[`stalwart-0.15-support`](https://git.coffeylabs.org/coffey-labs/ihasmail/releases/tag/stalwart-0.15-support).
|
||||
|
||||
- **Stalwart 1.0 renamed FileNode's `nodeType` and `type`, and refuses `recurrenceOverrides` beside `utcStart`.** Both **confirmed against real 0.16.27 and 1.0.0 servers (2026-10-30)**, from containers of the images CI pins, and both reached a reader as [#66](https://git.coffeylabs.org/coffey-labs/ihasmail/issues/66): calendars would not load, and settings reset at every sign-in.
|
||||
- **Calendar.** A `CalendarEvent/get` naming `recurrenceOverrides` together with `utcStart` or `utcEnd` fails whole, *"recurrenceOverrides cannot be requested together with utcStart or utcEnd"*, even with `ids: []`. 0.16.27 answers it. The everyday property list no longer asks for the overrides (an occurrence's are null anyway), and the export, which needs them, asks without the times.
|
||||
- **Files.** 1.0 wants `"@type": "Directory"` or `"File"` where 0.16 had `nodeType`, and `mediaType` where it had `type`. Each server refuses the other's names on create and update and ignores them on get, so ihasmail could make no folder on 1.0: not even its own, which is where the settings file lives. 1.0 also accepts `filter: { nodeType }` and answers it with no ids. The client asks which shape a server wants once per session (a query sorted by `type`, which 1.0 refuses as `unsupportedSort`) and translates at one place, `jmap/fileNodeShape.ts`.
|
||||
- **Not yet handled:** 1.0 returns `myRights` and `shareWith` for folders only. A file read from 1.0 carries no rights, so rename, delete and editing are offered as unavailable on files there.
|
||||
- **Recurrence, for the record.** Both versions take the singular `recurrenceRule` the editor writes. 0.16.27 refuses the RFC 8984 list `recurrenceRules`; 1.0 accepts it and drops it, leaving one occurrence.
|
||||
|
||||
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
|
||||
|
||||
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/375)).
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { argsToServer, resultFromServer, shapeFromProbe } from "@/jmap/fileNodeShape";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Stalwart 1.0 renamed FileNode's `nodeType` and `type` and refuses the old
|
||||
* names, so nothing the client wrote for 0.16 could make a folder or a file
|
||||
* there and the settings file was never written (Gitea issue #66). The shapes
|
||||
* below are what real 0.16.27 and 1.0.0 servers sent on 2026-10-30; the
|
||||
* server compatibility test sends the same calls to both.
|
||||
*/
|
||||
|
||||
describe("the probe", () => {
|
||||
it("reads 1.0's refusal of a sort by `type`", () => {
|
||||
expect(shapeFromProbe(["error", { type: "unsupportedSort", description: "type" }, "shape"])).toBe("1.0");
|
||||
});
|
||||
|
||||
it("reads 0.16's answer", () => {
|
||||
expect(shapeFromProbe(["FileNode/query", { ids: [] }, "shape"])).toBe("0.16");
|
||||
});
|
||||
|
||||
it("decides nothing on any other answer", () => {
|
||||
expect(shapeFromProbe(["error", { type: "forbidden" }, "shape"])).toBeNull();
|
||||
expect(shapeFromProbe(["error", { type: "accountNotFound" }, "shape"])).toBeNull();
|
||||
expect(shapeFromProbe(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("calls to a 1.0 server", () => {
|
||||
it("creates a folder and a file in 1.0's names", () => {
|
||||
const args = argsToServer("1.0", "FileNode/set", {
|
||||
accountId: "a",
|
||||
create: {
|
||||
d: { parentId: null, name: "ihasmail", nodeType: "directory" },
|
||||
f: { parentId: "#d", name: "settings.json", blobId: "B", type: "application/json", nodeType: "file" },
|
||||
},
|
||||
});
|
||||
expect(args.create).toEqual({
|
||||
d: { parentId: null, name: "ihasmail", "@type": "Directory" },
|
||||
f: { parentId: "#d", name: "settings.json", blobId: "B", mediaType: "application/json", "@type": "File" },
|
||||
});
|
||||
});
|
||||
|
||||
it("renames `type` in an update", () => {
|
||||
const args = argsToServer("1.0", "FileNode/set", { accountId: "a", update: { n1: { blobId: "B", type: "application/json", size: 7 } } });
|
||||
expect(args.update).toEqual({ n1: { blobId: "B", mediaType: "application/json", size: 7 } });
|
||||
});
|
||||
|
||||
it("asks for the renamed properties", () => {
|
||||
const args = argsToServer("1.0", "FileNode/get", { accountId: "a", ids: ["n1"], properties: ["id", "nodeType", "type", "name"] });
|
||||
expect(args.properties).toEqual(["id", "@type", "mediaType", "name"]);
|
||||
});
|
||||
|
||||
it("drops a filter by kind, which 1.0 answers with nothing", () => {
|
||||
expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { nodeType: "directory" } }).filter).toEqual({});
|
||||
expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { operator: "AND", conditions: [{ nodeType: "file" }, { parentId: "p" }] } }).filter)
|
||||
.toEqual({ operator: "AND", conditions: [{}, { parentId: "p" }] });
|
||||
});
|
||||
|
||||
it("sorts by `mediaType` where it was `type`", () => {
|
||||
const args = argsToServer("1.0", "FileNode/query", { accountId: "a", sort: [{ property: "type", isAscending: true }, { property: "name" }] });
|
||||
expect(args.sort).toEqual([{ property: "mediaType", isAscending: true }, { property: "name" }]);
|
||||
});
|
||||
|
||||
it("leaves other methods alone", () => {
|
||||
const args = { accountId: "a", properties: ["type"] };
|
||||
expect(argsToServer("1.0", "Email/get", args)).toBe(args);
|
||||
});
|
||||
});
|
||||
|
||||
describe("answers from a 1.0 server", () => {
|
||||
it("reads nodes back in the client's names", () => {
|
||||
const res = resultFromServer("1.0", "FileNode/get", {
|
||||
list: [
|
||||
{ id: "b", parentId: null, "@type": "Directory", name: "ihasmail" },
|
||||
{ id: "c", parentId: "b", "@type": "File", name: "settings.json", mediaType: "application/json", blobId: "B" },
|
||||
],
|
||||
});
|
||||
expect(res.list).toEqual([
|
||||
{ id: "b", parentId: null, nodeType: "directory", name: "ihasmail" },
|
||||
{ id: "c", parentId: "b", nodeType: "file", name: "settings.json", type: "application/json", blobId: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads what a set created and updated", () => {
|
||||
const res = resultFromServer("1.0", "FileNode/set", { created: { f: { id: "c", mediaType: "application/octet-stream" } }, updated: { c: null } });
|
||||
expect(res.created).toEqual({ f: { id: "c", type: "application/octet-stream" } });
|
||||
expect(res.updated).toEqual({ c: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("0.16", () => {
|
||||
it("is the client's own shape, passed through as is", () => {
|
||||
const args = { accountId: "a", create: { d: { nodeType: "directory" } }, filter: { nodeType: "directory" } };
|
||||
expect(argsToServer("0.16", "FileNode/set", args)).toBe(args);
|
||||
const res = { list: [{ nodeType: "file", type: "text/plain" }] };
|
||||
expect(resultFromServer("0.16", "FileNode/get", res)).toBe(res);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
client.session = null;
|
||||
});
|
||||
|
||||
it("asks once per session, then sends and reads in 1.0's names", async () => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: {}, [CAP.filenode]: {} },
|
||||
accounts: {},
|
||||
primaryAccounts: { [CAP.filenode]: "own" },
|
||||
state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
const sent: [string, Record<string, unknown>, string][][] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const calls = (JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls;
|
||||
sent.push(calls);
|
||||
const methodResponses = calls.map(([m, a, id]) =>
|
||||
id === "shape"
|
||||
? ["error", { type: "unsupportedSort" }, id]
|
||||
: [m, { list: [{ id: "b", "@type": "Directory", name: (a.properties as string[]).join(",") }] }, id],
|
||||
);
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses }) } as Response;
|
||||
}));
|
||||
|
||||
const one = await client.request([["FileNode/get", { accountId: "shared", ids: ["b"], properties: ["id", "nodeType"] }, "g"]], [CAP.core, CAP.filenode]);
|
||||
await client.request([["FileNode/get", { accountId: "a", ids: ["b"], properties: ["id"] }, "g"]], [CAP.core, CAP.filenode]);
|
||||
|
||||
expect(sent.map((calls) => calls.map(([m]) => m))).toEqual([["FileNode/query"], ["FileNode/get"], ["FileNode/get"]]);
|
||||
// Of the reader's own account, not the shared one asked about.
|
||||
expect(sent[0]![0]![1].accountId).toBe("own");
|
||||
expect(sent[1]![0]![1].properties).toEqual(["id", "@type"]);
|
||||
expect(one.methodResponses[0]![1].list).toEqual([{ id: "b", nodeType: "directory", name: "id,@type" }]);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,12 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { CAP, STALWART_CAP, usingFor } from "@/jmap/client";
|
||||
import { BODY_PROPS, FULL_PROPS, LIST_PROPS, MAILBOX_PROPS } from "@/store/mail/props";
|
||||
import { CALENDAR_PROPS } from "@/store/calendar";
|
||||
import { CALENDAR_PROPS, EVENT_PROPS, SERIES_PROPS } from "@/store/calendar";
|
||||
import { ADDRESS_BOOK_PROPS } from "@/store/contacts";
|
||||
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe } from "@/jmap/fileNodeShape";
|
||||
import { APP_FOLDER, folderProps, isAppFolder } from "@/lib/appFolder";
|
||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
||||
import type { FileNode } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* Runs against a real, empty Stalwart, not a mock. Skipped unless
|
||||
@@ -186,6 +190,114 @@ describe.skipIf(!BASE)("a real Stalwart server", () => {
|
||||
await expectAnswered(alice, calls);
|
||||
});
|
||||
|
||||
it("reads events with every property list the calendar asks for", async () => {
|
||||
// Stalwart 1.0 refuses recurrenceOverrides together with utcStart/utcEnd
|
||||
// for the whole call, even with no ids (Gitea issue #66), so the empty
|
||||
// gets matter as much as the ones that find something.
|
||||
await expectAnswered(alice, [
|
||||
["CalendarEvent/get", { accountId: aid, ids: [], properties: EVENT_PROPS }, "e"],
|
||||
["CalendarEvent/get", { accountId: aid, ids: [], properties: SERIES_PROPS }, "s"],
|
||||
]);
|
||||
|
||||
const calendars = (await jmap(alice, [["Calendar/get", { accountId: aid, properties: CALENDAR_PROPS }, "c"]]))[0]![1].list as { id: string }[];
|
||||
let calendarId = calendars[0]?.id;
|
||||
if (!calendarId) {
|
||||
const made = await jmap(alice, [["Calendar/set", { accountId: aid, create: { c: { name: "Compat" } } }, "c"]]);
|
||||
calendarId = (made[0]![1].created as Record<string, { id: string }>).c!.id;
|
||||
}
|
||||
const event = {
|
||||
"@type": "Event",
|
||||
uid: crypto.randomUUID(),
|
||||
calendarIds: { [calendarId]: true },
|
||||
title: "Compat weekly",
|
||||
start: "2026-11-02T09:00:00",
|
||||
timeZone: "Etc/UTC",
|
||||
duration: "PT1H",
|
||||
// The singular the editor writes: 0.16 refuses the RFC's list, and 1.0
|
||||
// takes the list but drops it, leaving one event.
|
||||
recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 },
|
||||
};
|
||||
const made = await jmap(alice, [["CalendarEvent/set", { accountId: aid, create: { e: event } }, "e"]]);
|
||||
const masterId = (made[0]![1].created as Record<string, { id: string }>)?.e?.id;
|
||||
expect(masterId, JSON.stringify(made[0]![1])).toBeTruthy();
|
||||
|
||||
// What the grid does: a window, expanded, read with EVENT_PROPS.
|
||||
const grid = await jmap(alice, [
|
||||
["CalendarEvent/query", { accountId: aid, filter: { after: "2026-11-01T00:00:00", before: "2026-11-30T00:00:00" }, timeZone: "Etc/UTC", sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
|
||||
["CalendarEvent/get", { accountId: aid, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: "Etc/UTC" }, "g"],
|
||||
]);
|
||||
expect(grid[1]![0], JSON.stringify(grid[1]![1]).slice(0, 300)).toBe("CalendarEvent/get");
|
||||
const occurrences = grid[1]![1].list as { utcStart?: string }[];
|
||||
expect(occurrences, JSON.stringify(occurrences.map((o) => o.utcStart))).toHaveLength(3);
|
||||
expect(occurrences.every((o) => typeof o.utcStart === "string")).toBe(true);
|
||||
|
||||
// What the export does: the master, read with SERIES_PROPS.
|
||||
const series = await jmap(alice, [["CalendarEvent/get", { accountId: aid, ids: [masterId], properties: SERIES_PROPS }, "g"]]);
|
||||
expect(series[0]![0], JSON.stringify(series[0]![1]).slice(0, 300)).toBe("CalendarEvent/get");
|
||||
expect((series[0]![1].list as Record<string, unknown>[])[0]!.recurrenceRule).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the settings file the way the app does", async () => {
|
||||
// The client's own translation, against whichever shape this server wants
|
||||
// (fileNodeShape.ts). Stalwart 1.0 renamed nodeType and type, and refused
|
||||
// the old names on every create, so the settings were never written
|
||||
// (Gitea issue #66).
|
||||
const shape = shapeFromProbe((await jmap(alice, [shapeProbe(aid) as Call]))[0]);
|
||||
expect(shape, "the probe decided nothing").not.toBeNull();
|
||||
const files = async (calls: Call[]): Promise<Response[]> =>
|
||||
(await jmap(alice, calls.map(([m, a, id]) => [m, argsToServer(shape!, m, a), id] as Call))).map(([m, a, id]) => [m, resultFromServer(shape!, m, a), id] as Response);
|
||||
const topLevel = async () =>
|
||||
((await files([
|
||||
["FileNode/query", { accountId: aid, filter: { isTopLevel: true }, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
|
||||
]))[1]![1].list as FileNode[]);
|
||||
|
||||
// ensureFolder: found, or made and then found by the same test.
|
||||
let folderId = (await topLevel()).find(isAppFolder)?.id;
|
||||
if (!folderId) {
|
||||
const made = await files([["FileNode/set", { accountId: aid, create: { d: directoryCreate(null, APP_FOLDER) } }, "s"]]);
|
||||
expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy();
|
||||
folderId = (await topLevel()).find(isAppFolder)?.id;
|
||||
}
|
||||
expect(folderId).toBeTruthy();
|
||||
|
||||
// The Files tree asks for folders by kind and sorts them out itself.
|
||||
const tree = await files([
|
||||
["FileNode/query", { accountId: aid, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
|
||||
]);
|
||||
expect((tree[1]![1].list as FileNode[]).filter((n) => n.nodeType === "directory").map((n) => n.id)).toContain(folderId);
|
||||
|
||||
// writeSettings: upload, then create the file or update the one there.
|
||||
const upload = async (text: string) => {
|
||||
const res = await fetch(`${BASE}/jmap/upload/${aid}/`, { method: "POST", headers: { "content-type": "application/json", authorization: auth(alice) }, body: text });
|
||||
expect(res.ok, `upload: HTTP ${res.status}`).toBe(true);
|
||||
return ((await res.json()) as { blobId: string }).blobId;
|
||||
};
|
||||
const inFolder = async () =>
|
||||
((await files([
|
||||
["FileNode/query", { accountId: aid, filter: { parentId: folderId }, limit: 1000 }, "q"],
|
||||
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "parentId", "blobId", "size", "type", "nodeType"] }, "g"],
|
||||
]))[1]![1].list as FileNode[]).find((n) => n.name === "settings.json");
|
||||
|
||||
const first = '{"theme":"dark"}';
|
||||
let existing = await inFolder();
|
||||
if (!existing) {
|
||||
const made = await files([["FileNode/set", { accountId: aid, create: { s: fileCreate(folderId!, "settings.json", await upload(first), "application/json") } }, "s"]]);
|
||||
expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy();
|
||||
existing = await inFolder();
|
||||
}
|
||||
expect(existing).toMatchObject({ nodeType: "file", type: "application/json" });
|
||||
|
||||
const second = '{"theme":"light"}';
|
||||
const updated = await files([["FileNode/set", { accountId: aid, update: { [existing!.id]: { blobId: await upload(second), type: "application/json", size: second.length } } }, "s"]]);
|
||||
expect(updated[0]![1].notUpdated, JSON.stringify(updated[0]![1])).toBeFalsy();
|
||||
// A stored blob has an id of its own, so the content is what is compared.
|
||||
const stored = (await inFolder())!.blobId!;
|
||||
const read = await fetch(`${BASE}/jmap/download/${aid}/${stored}/settings.json?accept=application/json`, { headers: { authorization: auth(alice) } });
|
||||
expect(await read.text()).toBe(second);
|
||||
});
|
||||
|
||||
it("answers every read the administration pages make", async () => {
|
||||
const calls: Call[] = [
|
||||
"x:Account/query",
|
||||
|
||||
+38
-1
@@ -1,4 +1,5 @@
|
||||
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
|
||||
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe, type FileNodeShape } from "./fileNodeShape";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { reauthAtFrontDoor } from "@/lib/frontDoor";
|
||||
|
||||
@@ -118,6 +119,8 @@ export class JmapClient {
|
||||
private stateHandlers = new Set<(sessionState: string) => void>();
|
||||
/** The last session state announced, so a burst of replies announces it once. */
|
||||
private announcedState: string | null = null;
|
||||
/** The FileNode shape this session's server wants, asked once (fileNodeShape.ts). */
|
||||
private fileShape: { session: JmapSession | null; shape: Promise<FileNodeShape> } | null = null;
|
||||
|
||||
get maxCallsInRequest(): number {
|
||||
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
|
||||
@@ -264,8 +267,42 @@ export class JmapClient {
|
||||
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u));
|
||||
}
|
||||
|
||||
/** Low-level request: send invocations verbatim, return raw response. */
|
||||
/**
|
||||
* Low-level request: send invocations, return the raw response.
|
||||
*
|
||||
* Verbatim, except FileNode calls, which go out and come back through the
|
||||
* translation in fileNodeShape.ts when the server is 1.0. Done here so that
|
||||
* every caller keeps the one shape it was written for.
|
||||
*/
|
||||
async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> {
|
||||
const fileCall = methodCalls.find(([m]) => m.startsWith("FileNode/"));
|
||||
const accountId = fileCall?.[1].accountId;
|
||||
if (typeof accountId !== "string") return this.send(methodCalls, using, createdIds);
|
||||
const shape = await this.fileNodeShape(accountId);
|
||||
const res = await this.send(methodCalls.map(([m, a, id]) => [m, argsToServer(shape, m, a), id]), using, createdIds);
|
||||
return { ...res, methodResponses: res.methodResponses.map(([m, a, id]) => [m, resultFromServer(shape, m, a), id]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask once per session which FileNode shape the server wants, of the
|
||||
* reader's own Files account where there is one. Until an answer decides
|
||||
* it, the shape this client was written for, and the question stays open.
|
||||
*/
|
||||
private fileNodeShape(accountId: Id): Promise<FileNodeShape> {
|
||||
if (this.fileShape && this.fileShape.session === this.session) return this.fileShape.shape;
|
||||
const undecided = (): FileNodeShape => {
|
||||
this.fileShape = null;
|
||||
return "0.16";
|
||||
};
|
||||
const shape = this.send([shapeProbe(this.primaryAccount(CAP.filenode) ?? accountId)], [CAP.core, CAP.filenode]).then(
|
||||
(res) => shapeFromProbe(res.methodResponses[0]) ?? undecided(),
|
||||
undecided,
|
||||
);
|
||||
this.fileShape = { session: this.session, shape };
|
||||
return shape;
|
||||
}
|
||||
|
||||
private async send(methodCalls: Invocation[], using: string[], createdIds?: Record<string, Id>): Promise<JmapResponse> {
|
||||
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
|
||||
if (createdIds) body.createdIds = createdIds;
|
||||
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* FileNode as Stalwart 1.0 spells it, translated to and from the 0.16 shape the
|
||||
* rest of the client is written against.
|
||||
*
|
||||
* 1.0 renamed two properties and refuses the old names outright, so nothing
|
||||
* written for 0.16 could make a folder or a file there (Gitea issue #66: the
|
||||
* settings file never got written, so settings reset at every sign-in):
|
||||
*
|
||||
* | 0.16 | 1.0 |
|
||||
* | ---------------------------------- | ----------------------------------- |
|
||||
* | `nodeType: "directory" \| "file"` | `"@type": "Directory" \| "File"` |
|
||||
* | `type` (a file's media type) | `mediaType` |
|
||||
*
|
||||
* Each server refuses the other's names on a create or an update, and ignores
|
||||
* them on a get. 1.0 also accepts `filter: { nodeType }` and answers it with no
|
||||
* ids at all, so that filter is dropped rather than translated: 1.0 offers no
|
||||
* filter by kind, and the callers that use it sort folders from files
|
||||
* themselves.
|
||||
*
|
||||
* Checked against real 0.16.27 and 1.0.0 servers on 2026-10-30; the server
|
||||
* compatibility test sends these calls to both.
|
||||
*
|
||||
* Nothing in the session says which shape a server wants, so `PROBE` asks: a
|
||||
* query sorted by `type`, which 0.16 answers and 1.0 refuses as
|
||||
* `unsupportedSort` because the property is gone. It writes nothing and is
|
||||
* sent once per session, before the first FileNode call, to the reader's own
|
||||
* account: a shared one may refuse the reader outright, which says nothing
|
||||
* about the server. An answer that is neither leaves the question open.
|
||||
*/
|
||||
import type { Invocation } from "./types";
|
||||
|
||||
export type FileNodeShape = "0.16" | "1.0";
|
||||
|
||||
/** The probe, for an account the caller already has. */
|
||||
export function shapeProbe(accountId: string): Invocation {
|
||||
return ["FileNode/query", { accountId, sort: [{ property: "type", isAscending: true }], limit: 1 }, "shape"];
|
||||
}
|
||||
|
||||
/** Read the probe's answer: 0.16 answers it, 1.0 refuses the sort, and anything else decides nothing. */
|
||||
export function shapeFromProbe(response: Invocation | undefined): FileNodeShape | null {
|
||||
const [name, args] = response ?? [];
|
||||
if (name === "FileNode/query") return "0.16";
|
||||
if (name === "error" && (args as { type?: string } | undefined)?.type === "unsupportedSort") return "1.0";
|
||||
return null;
|
||||
}
|
||||
|
||||
const TO_TYPE: Record<string, string> = { directory: "Directory", file: "File", symlink: "Symlink" };
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
/** A node, or a patch to one, in 1.0's names. */
|
||||
function nodeToServer(node: Obj): Obj {
|
||||
const { nodeType, type, ...rest } = node;
|
||||
const out: Obj = { ...rest };
|
||||
if (typeof nodeType === "string") out["@type"] = TO_TYPE[nodeType] ?? nodeType;
|
||||
if (type !== undefined) out.mediaType = type;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A node from 1.0, in the names the client reads. */
|
||||
function nodeFromServer(node: unknown): unknown {
|
||||
if (!node || typeof node !== "object") return node;
|
||||
const { "@type": kind, mediaType, ...rest } = node as Obj;
|
||||
const out: Obj = { ...rest };
|
||||
if (typeof kind === "string") out.nodeType = kind.toLowerCase();
|
||||
if (mediaType !== undefined) out.type = mediaType;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapValues(o: unknown, fn: (v: Obj) => unknown): unknown {
|
||||
if (!o || typeof o !== "object") return o;
|
||||
return Object.fromEntries(Object.entries(o as Obj).map(([k, v]) => [k, v && typeof v === "object" ? fn(v as Obj) : v]));
|
||||
}
|
||||
|
||||
/** A filter without `nodeType`, through any operators. */
|
||||
function filterToServer(filter: unknown): unknown {
|
||||
if (!filter || typeof filter !== "object") return filter;
|
||||
const f = filter as Obj;
|
||||
if (Array.isArray(f.conditions)) return { ...f, conditions: f.conditions.map(filterToServer) };
|
||||
const { nodeType: _n, ...rest } = f;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const RENAMED: Record<string, string> = { nodeType: "@type", type: "mediaType" };
|
||||
|
||||
/** A call's arguments, as this shape of server expects them. */
|
||||
export function argsToServer(shape: FileNodeShape, method: string, args: Obj): Obj {
|
||||
if (shape === "0.16" || !method.startsWith("FileNode/")) return args;
|
||||
const out: Obj = { ...args };
|
||||
if (Array.isArray(out.properties)) out.properties = [...new Set((out.properties as string[]).map((p) => RENAMED[p] ?? p))];
|
||||
if (out.create) out.create = mapValues(out.create, nodeToServer);
|
||||
if (out.update) out.update = mapValues(out.update, nodeToServer);
|
||||
if (out.filter) out.filter = filterToServer(out.filter);
|
||||
if (Array.isArray(out.sort)) out.sort = (out.sort as Obj[]).map((c) => (c.property === "type" ? { ...c, property: "mediaType" } : c));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A method's answer, in the names the client reads. */
|
||||
export function resultFromServer(shape: FileNodeShape, method: string, result: Obj): Obj {
|
||||
if (shape === "0.16" || !method.startsWith("FileNode/")) return result;
|
||||
const out: Obj = { ...result };
|
||||
if (Array.isArray(out.list)) out.list = out.list.map(nodeFromServer);
|
||||
if (out.created) out.created = mapValues(out.created, (v) => nodeFromServer(v));
|
||||
if (out.updated) out.updated = mapValues(out.updated, (v) => nodeFromServer(v));
|
||||
return out;
|
||||
}
|
||||
@@ -62,8 +62,13 @@ describe("shared accounts", () => {
|
||||
|
||||
it("are asked about files together when Files wants to know", async () => {
|
||||
await useFiles.getState().discoverShared();
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
|
||||
// The first FileNode call of a session asks the reader's own account which
|
||||
// shape of FileNode the server wants (jmap/fileNodeShape.ts); the shared
|
||||
// accounts are still asked in one request after it.
|
||||
const [probe, ...rest] = requests;
|
||||
expect(probe!.map(([n, a, id]) => `${n} ${a.accountId} ${id}`)).toEqual(["FileNode/query own shape"]);
|
||||
expect(rest).toHaveLength(1);
|
||||
expect(rest[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
|
||||
expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "[email protected]" }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -332,15 +332,31 @@ interface CalendarState {
|
||||
* Explicit property list: when `properties` is null Stalwart omits the JMAP-only
|
||||
* fields baseEventId / utcStart / utcEnd, and we need baseEventId to update
|
||||
* recurring instances (synthetic ids can't be patched directly).
|
||||
*
|
||||
* `recurrenceOverrides` is left out. Stalwart 1.0 refuses a get that asks for
|
||||
* it together with `utcStart` or `utcEnd` -- the whole call, with no ids at
|
||||
* all -- and every read here wants the times: an occurrence is placed on the
|
||||
* grid by them, and an occurrence's overrides are null anyway. The one read
|
||||
* that needs the overrides, the export, asks with `SERIES_PROPS` instead.
|
||||
*/
|
||||
const EVENT_PROPS = [
|
||||
export const EVENT_PROPS = [
|
||||
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
|
||||
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
|
||||
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
|
||||
"recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
|
||||
"recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
|
||||
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
|
||||
];
|
||||
|
||||
/**
|
||||
* A stored event whole, for writing it out: its "just this date" edits
|
||||
* included, its server-computed times not. A master's own times are in
|
||||
* `start`, `duration` and `timeZone`, which is all an export writes.
|
||||
*/
|
||||
export const SERIES_PROPS = [
|
||||
...EVENT_PROPS.filter((p) => p !== "utcStart" && p !== "utcEnd"),
|
||||
"recurrenceOverrides",
|
||||
];
|
||||
|
||||
/**
|
||||
* An event as it arrived, minus everything that belonged to where it came from.
|
||||
*
|
||||
@@ -1029,7 +1045,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
*/
|
||||
async exportIcs(calendarId) {
|
||||
const accountId = get().accountId!;
|
||||
const events = await eventsInCalendar(accountId, calendarId, EVENT_PROPS);
|
||||
const events = await eventsInCalendar(accountId, calendarId, SERIES_PROPS);
|
||||
if (!events.length) throw new Error("there is nothing in it to export");
|
||||
return { text: toIcs(events, get().calendars[calendarId]?.name), count: events.length };
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user