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.
This commit is contained in:
2026-09-13 17:55:36 -07:00
parent 029afc21c4
commit de71572b9d
4 changed files with 101 additions and 12 deletions
+6 -6
View File
@@ -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
+8 -4
View File
@@ -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<string, Handler> = {
"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<string, Handler> = {
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: [] }; },
+49 -1
View File
@@ -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")),
+38 -1
View File
@@ -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`. */