From de71572b9d1915a32f95b17f95544ee6282aed84 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 13 Sep 2026 17:55:36 -0700 Subject: [PATCH] Follow Stalwart 0.16.22 in the mock 0.16.22 changed four things a client sees from CalendarEvent/get and ContactCard/get. Read from its source and the tests that came with it: - baseEventId is the master's id on a synthetic id and null otherwise; an event read by its stored id used to report its own id. A one-off from an expanded query still has a synthetic id, so it still carries a base. - recurrenceRule and recurrenceOverrides named on a synthetic id come back null rather than absent. - useDefaultAlerts is the reader's own and reads false until set. - an empty properties list returns id alone, for both methods. pick already did that, so only a comment changes for contacts. With properties omitted the stored object comes back as before. The README said a one-off now carries a null base, which only holds for one read by its stored id; it now says that, and that the mock follows. --- README.md | 12 +++---- server/src/mock/index.ts | 12 ++++--- server/src/mock/recurrence.test.ts | 50 +++++++++++++++++++++++++++++- server/src/mock/recurrence.ts | 39 ++++++++++++++++++++++- 4 files changed, 101 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 3a363e4..5ffb503 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,12 @@ on the first call. **Validated against 0.16.22**, released 13 September 2026: the live instance runs it and the app has been tested against it. Four of its JMAP changes are visible to a client, all in calendars and contacts: -`CalendarEvent/get` returns `baseEventId` only on a synthetic occurrence, so a -one-off event now carries `null` there; it returns `null` for `recurrenceRule` -and `recurrenceOverrides` asked for on an occurrence; `useDefaultAlerts` is -stored per user and reads `false` when never set; and `CalendarEvent/get` and -`ContactCard/get` return only `id` for an empty `properties` list, rather than -everything. The mock does not follow these yet. +`CalendarEvent/get` returns `baseEventId` only for a synthetic id, so an event +read by its stored id now carries `null` there rather than its own id; it +returns `null` for `recurrenceRule` and `recurrenceOverrides` asked for on a +synthetic id; `useDefaultAlerts` is stored per user and reads `false` when never +set; and `CalendarEvent/get` and `ContactCard/get` return only `id` for an empty +`properties` list, rather than everything. The mock reproduces all four. Before it, **0.16.21**, released 6 September 2026: the app was run against a real instance of it and the mail, calendar and contacts paths were exercised by diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index ceb9569..991c5b8 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -6,7 +6,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomUUID } from "node:crypto"; import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js"; -import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; +import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js"; import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; import { createDirectory, mockRole } from "./directory.js"; @@ -1263,15 +1263,17 @@ const handlers: Record = { "CalendarEvent/get": (a) => { const list = eventsFor(a.accountId); const ids = a.ids as string[] | null | undefined; - if (!ids) return genericGet(list)(a); + const properties = a.properties as string[] | null | undefined; + // With no ids every event comes back under its stored id, none synthetic. + if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] }; const found: Obj[] = []; const notFound: string[] = []; for (const id of ids) { const resolved = resolveEvent(list, id); if (!resolved) { notFound.push(id); continue; } - found.push(resolved.occ ? occurrenceView(resolved.base, resolved.occ) : resolved.base); + found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties)); } - return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound }; + return { accountId: ACCOUNT, state: String(state.n), list: found, notFound }; }, // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards // participants addressed the RFC 8984 way. The mock did neither, which is how @@ -1311,6 +1313,8 @@ const handlers: Record = { return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a); }, "ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; }, + // An empty `properties` list returns `id` alone, which `pick` already does. + // 0.16.22 made Stalwart agree; through 0.16.21 it returned every property. "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), "ContactCard/set": genericSet(cards, "cc"), "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, diff --git a/server/src/mock/recurrence.test.ts b/server/src/mock/recurrence.test.ts index 472a944..74ff4c9 100644 --- a/server/src/mock/recurrence.test.ts +++ b/server/src/mock/recurrence.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js"; +import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId } from "./recurrence.js"; /** * The mock expands recurrences so that per-occurrence editing can be developed @@ -98,6 +98,54 @@ describe("occurrenceView", () => { }); }); +describe("eventGetView", () => { + /* + * What 0.16.22 changed in `CalendarEvent/get`, read from its source and the + * tests that came with it (`tests/src/jmap/calendar/event.rs` and + * `instance.rs`). + */ + it("reports no base for an event read by its stored id", () => { + // 0.16.21 answered with the event's own id here. + assert.deepEqual(eventGetView(oneOff(), false, ["id", "baseEventId"]), { id: "ev2", baseEventId: null }); + assert.equal(eventGetView(series(), false, ["baseEventId"]).baseEventId, null); + }); + + it("still gives a one-off read through its synthetic id a base", () => { + // An expanded query hands a one-off a synthetic id, so this has not + // changed: `baseEventId` is still no evidence of a series. + const base = oneOff(); + const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!), true, ["baseEventId"]); + assert.equal(view.baseEventId, "ev2"); + }); + + it("answers null for the rule and overrides named on an occurrence", () => { + const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Standup (long)" } } }; + const view = eventGetView(occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!), true, + ["recurrenceId", "recurrenceRule", "recurrenceOverrides"]); + assert.deepEqual(view, { id: "ev1-r20260908T090000", recurrenceId: "2026-09-08T09:00:00", recurrenceRule: null, recurrenceOverrides: null }); + }); + + it("leaves the rule on the series itself alone", () => { + assert.deepEqual(eventGetView(series(), false, ["recurrenceRule"]).recurrenceRule, WEEKDAYS); + }); + + it("reads useDefaultAlerts as false until it is set", () => { + // It used to read true until set. + assert.equal(eventGetView(series(), false, ["useDefaultAlerts"]).useDefaultAlerts, false); + assert.equal(eventGetView({ ...series(), useDefaultAlerts: true }, false, ["useDefaultAlerts"]).useDefaultAlerts, true); + assert.equal(eventGetView({ ...series(), useDefaultAlerts: false }, false, ["useDefaultAlerts"]).useDefaultAlerts, false); + }); + + it("returns only the id for an empty list", () => { + // 0.16.21 treated an empty list as asking for everything. + assert.deepEqual(eventGetView(series(), false, []), { id: "ev1" }); + }); + + it("returns the object unchanged when no list is given", () => { + assert.deepEqual(eventGetView(series(), false, null), series()); + }); +}); + describe("parseSyntheticId", () => { it("round-trips", () => { assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")), diff --git a/server/src/mock/recurrence.ts b/server/src/mock/recurrence.ts index c66d6e8..bfa9993 100644 --- a/server/src/mock/recurrence.ts +++ b/server/src/mock/recurrence.ts @@ -1,5 +1,5 @@ /** - * Enough recurrence expansion for the mock to behave like Stalwart 0.16.21. + * Enough recurrence expansion for the mock to behave like Stalwart 0.16.22. * * The mock used to hand a recurring event back once, as its stored self. Three * things that only a live server showed were therefore impossible to develop @@ -188,6 +188,43 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj { return view; } +/** Series properties a synthetic id answers `null` for, when they are named. */ +const NULL_ON_OCCURRENCE = new Set(["recurrenceRule", "recurrenceOverrides"]); + +/** + * The object a `CalendarEvent/get` with a `properties` list returns, as 0.16.22 + * builds it. Omitted or null `properties` returns the stored object unchanged. + * + * Three of the named properties are no longer read off the object: + * + * - `baseEventId` is the master's id on a synthetic id and `null` on anything + * else. Through 0.16.21 an event read by its stored id reported that id as + * its own base. An expanded query still hands a one-off a synthetic id, so + * one read that way still carries a base, and `baseEventId` is still no + * evidence of a series; + * - `recurrenceRule` and `recurrenceOverrides` come back as `null` on a + * synthetic id rather than being left out; + * - `useDefaultAlerts` is the reader's own preference, and `false` when they + * never set one. It used to read `true` until set. The mock has one reader, + * so a value stored on the event stands in for that reader's. + * + * An empty list returns `id` alone, where 0.16.21 treated it as asking for + * everything. `ContactCard/get` changed the same way. + * + * Read from the 0.16.22 source (`calendar_event/get.rs`) and its tests. + */ +export function eventGetView(event: Obj, synthetic: boolean, properties: string[] | null | undefined): Obj { + if (!properties) return event; + const out: Obj = { id: event.id }; + for (const p of properties) { + if (p === "baseEventId") out[p] = synthetic ? event.baseEventId : null; + else if (p === "useDefaultAlerts") out[p] = event.useDefaultAlerts === true; + else if (synthetic && NULL_ON_OCCURRENCE.has(p)) out[p] = null; + else if (p in event) out[p] = event[p]; + } + return out; +} + /* ---------- what a single occurrence will not take ---------- */ /** Refused outright, with `invalidProperties`. */