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 **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 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: visible to a client, all in calendars and contacts:
`CalendarEvent/get` returns `baseEventId` only on a synthetic occurrence, so a `CalendarEvent/get` returns `baseEventId` only for a synthetic id, so an event
one-off event now carries `null` there; it returns `null` for `recurrenceRule` read by its stored id now carries `null` there rather than its own id; it
and `recurrenceOverrides` asked for on an occurrence; `useDefaultAlerts` is returns `null` for `recurrenceRule` and `recurrenceOverrides` asked for on a
stored per user and reads `false` when never set; and `CalendarEvent/get` and synthetic id; `useDefaultAlerts` is stored per user and reads `false` when never
`ContactCard/get` return only `id` for an empty `properties` list, rather than set; and `CalendarEvent/get` and `ContactCard/get` return only `id` for an empty
everything. The mock does not follow these yet. `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 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 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 { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js"; 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 { parseOtpauthUrl, verifyTotp } from "../totp.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { createDirectory, mockRole } from "./directory.js"; import { createDirectory, mockRole } from "./directory.js";
@@ -1263,15 +1263,17 @@ const handlers: Record<string, Handler> = {
"CalendarEvent/get": (a) => { "CalendarEvent/get": (a) => {
const list = eventsFor(a.accountId); const list = eventsFor(a.accountId);
const ids = a.ids as string[] | null | undefined; 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 found: Obj[] = [];
const notFound: string[] = []; const notFound: string[] = [];
for (const id of ids) { for (const id of ids) {
const resolved = resolveEvent(list, id); const resolved = resolveEvent(list, id);
if (!resolved) { notFound.push(id); continue; } 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 // 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 // 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); 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 }; }, "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/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a),
"ContactCard/set": genericSet(cards, "cc"), "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: [] }; }, "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 { describe, it } from "node:test";
import assert from "node:assert/strict"; 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 * 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", () => { describe("parseSyntheticId", () => {
it("round-trips", () => { it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")), 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 * 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 * 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; 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 ---------- */ /* ---------- what a single occurrence will not take ---------- */
/** Refused outright, with `invalidProperties`. */ /** Refused outright, with `invalidProperties`. */