Follow Stalwart 0.16.21 in the mock

Four changes, each confirmed against a real 0.16.21 rather than read from
the changelog.

Synthetic recurrence ids are now built from an occurrence's recurrenceId
instead of its position, so they survive a write. This reverses a hazard
the mock reproduced on purpose: up to 0.16.20 writing one override
renumbered the series and a held id silently named a different date. A
five-week series was expanded live, its third occurrence retitled through
its synthetic id, and all five original ids re-read; every one still
resolved to its own date. The test that pinned the instability now pins
the stability, with two more around it.

Calendar/get and AddressBook/get return every property when properties is
omitted or null, shareWith included. Mailbox/get on the same server still
omits it, so that stripping stays and now applies to mailboxes alone.

EventSource ping events advertise the interval in seconds, not
milliseconds. The mock parses the parameter it used to ignore: a 30 s
floor, larger values honoured, 0 disables pings, a non-numeric value is a
400. The first ping now arrives one interval in rather than on connect,
which is what the server does.

CalendarEvent/set rejects create, update and destroy with forbidden when
the request asks for scheduling messages and the account may not send
them. MOCK_NO_SCHEDULING_SEND=1 develops against that account.
This commit is contained in:
2026-09-06 15:43:12 -07:00
parent db7b103a08
commit 6432e11beb
3 changed files with 177 additions and 88 deletions
+88 -18
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, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { 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";
@@ -358,6 +358,24 @@ function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number {
/** A server that does not implement sorting on keywords, so the fallback can be developed against. */ /** A server that does not implement sorting on keywords, so the fallback can be developed against. */
const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1"; const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1";
/** The floor Stalwart puts under a requested EventSource ping interval. */
const PING_FLOOR_SECONDS = 30;
/*
* An account that may not send calendar invitations.
*
* 0.16.21 rejects a `CalendarEvent/set` that asks for scheduling messages when
* the account lacks the `calendarSchedulingSend` permission, rather than
* accepting the write and quietly sending nothing. **Confirmed live on 0.16.21
* (2026-09-06)** against an account holding a role with that permission
* disabled: `sendSchedulingMessages: true` came back `notCreated` with
* `forbidden` and the text below, while the identical request with the flag
* false was created normally. Set MOCK_NO_SCHEDULING_SEND=1 to develop against
* that account.
*/
const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1";
const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages.";
const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks); const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks);
/** One per contact, by index; a gap means that card has no birthday. */ /** One per contact, by index; a gap means that card has no birthday. */
const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [ const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [
@@ -542,12 +560,19 @@ function enforceLimits(name: string, args: Obj): void {
const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
/* /*
* Stalwart does not return `shareWith` unless a client asks for it by name: a * `Mailbox/get` does not return `shareWith` unless a client asks for it by
* `/get` with no `properties` comes back without the field at all. Confirmed on * name: a `/get` with no `properties` comes back without the field at all.
* 0.16.19 (2026-08-27) against a calendar and an address book that really were * Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
* shared. The mock handing it over unasked meant a client that never asked * The mock handing it over unasked meant a client that never asked still saw
* still saw every share, and the one place that did not -- the real server -- * every share, and the one place that did not -- the real server -- showed
* showed nothing shared at all. * nothing shared at all.
*
* Calendars and address books used to behave the same way and no longer do.
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
* on 0.16.21 (2026-09-06):** both come back with the full set, while
* `Mailbox/get` on the same server still omits it — so this stays, and it
* stays applied to mailboxes alone.
*/ */
function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } { function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
if (a.properties) return res; if (a.properties) return res;
@@ -564,9 +589,9 @@ function genericGet(list: Obj[]) {
/** /**
* An id, as either a stored event or one occurrence of one. * An id, as either a stored event or one occurrence of one.
* *
* A synthetic id whose base is gone, or whose index falls outside the series * A synthetic id whose base is gone, or whose date the rule no longer
* (deleted, or past a `count`), resolves to nothing — `notFound`, the way the * generates (excluded, or past a `count`), resolves to nothing — `notFound`,
* server answers for an occurrence that is not there any more. * the way the server answers for an occurrence that is not there any more.
*/ */
function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null { function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id); const direct = list.find((x) => x.id === id);
@@ -575,7 +600,7 @@ function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence }
if (!parsed) return null; if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId); const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null; if (!base) return null;
const occ = occurrenceAt(base, parsed.slot); const occ = occurrenceAt(base, parsed.recurrenceId);
return occ ? { base, occ } : null; return occ ? { base, occ } : null;
} }
@@ -697,6 +722,27 @@ function calendarEventSet(a: Obj) {
const notUpdated: Obj = {}; const notUpdated: Obj = {};
const notDestroyed: Obj = {}; const notDestroyed: Obj = {};
/*
* An account that may not send invitations refuses the whole request the
* moment it asks for them, and refuses it per object rather than as a method
* error. Confirmed live on 0.16.21 for all three of create, update and
* destroy; the same requests with the flag absent or false went through.
* The flag alone decides it — the server does not first check whether the
* event has anyone to notify.
*/
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` }; const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards // Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
@@ -1154,7 +1200,7 @@ const handlers: Record<string, Handler> = {
"SieveScript/get": genericGet(sieveScripts), "SieveScript/get": genericGet(sieveScripts),
"SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; }, "SieveScript/set": (a) => { const r = genericSet(sieveScripts, "sv", (o) => Object.assign(o, { isActive: false, ...o }))(a); const act = (a.onSuccessActivateScript as string | undefined); if (act) { const id = act.startsWith("#") ? ((r.created as Obj)[act.slice(1)] as Obj)?.id : act; for (const s of sieveScripts) s.isActive = s.id === id; } if (a.onSuccessDeactivateScript) for (const s of sieveScripts) s.isActive = false; return r; },
"SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }), "SieveScript/validate": () => ({ accountId: ACCOUNT, error: null }),
"Calendar/get": (a) => hideShareWithUnlessAsked(a, genericGet(calendarsFor(a.accountId))(a) as { list: Obj[] }) as never, "Calendar/get": (a) => genericGet(calendarsFor(a.accountId))(a),
"Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a), "Calendar/set": (a) => genericSet(calendarsFor(a.accountId), "c", (o) => Object.assign(o, { color: "#0f766e", isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", timeZone: null, shareWith: null, myRights: rightsCal(), description: null, sortOrder: 0, ...o }))(a),
/* /*
* With `expandRecurrences` every id that comes back is synthetic — a one-off * With `expandRecurrences` every id that comes back is synthetic — a one-off
@@ -1173,7 +1219,7 @@ const handlers: Record<string, Handler> = {
const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000); const from = filter.after ? new Date(filter.after as string) : new Date(-8640000000000);
const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000); const to = filter.before ? new Date(filter.before as string) : new Date(8640000000000);
const ids: string[] = []; const ids: string[] = [];
for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, slotOfOccurrence(e, occ))); for (const e of matching) for (const occ of expandOccurrences(e, from, to)) ids.push(syntheticId(e.id as string, occ.recurrenceId));
return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length }; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids, total: ids.length };
}, },
"CalendarEvent/get": (a) => { "CalendarEvent/get": (a) => {
@@ -1211,7 +1257,7 @@ const handlers: Record<string, Handler> = {
} }
return { accountId: ACCOUNT, list }; return { accountId: ACCOUNT, list };
}, },
"AddressBook/get": (a) => hideShareWithUnlessAsked(a, genericGet(booksFor(a.accountId))(a) as { list: Obj[] }) as never, "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a),
"AddressBook/set": (a) => { "AddressBook/set": (a) => {
/* Stalwart refuses any update to a book shared read-only, `isSubscribed` /* Stalwart refuses any update to a book shared read-only, `isSubscribed`
included -- "You are not allowed to modify this address book", confirmed included -- "You are not allowed to modify this address book", confirmed
@@ -1393,13 +1439,37 @@ export const server = createServer(async (req, res) => {
res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length }); res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length });
return res.end(b.data); return res.end(b.data);
} }
/*
* The `ping` query parameter, and what comes back for it.
*
* **Confirmed live on 0.16.21 (2026-09-06):** the interval is in **seconds**
* — `data: {"interval": 30}` — where up to 0.16.20 the same field carried
* milliseconds. The server floors it at 30 s (asking for 1, 2 or 5 all
* answered 30 and pinged every 30 s) and honours anything above (45 pinged
* at 45 s and said 45, 60 at 60 and said 60). `ping=0` disables pings
* altogether; a value that is not a number at all — `abc`, or empty — is a
* 400 before the stream opens.
*
* The first ping arrives one whole interval in, not on connect, so nothing
* is written here: `flushHeaders` opens the stream on its own. A mock that
* pinged immediately would let a client treat the first ping as an
* connection-established signal and hang forever against the real thing.
*/
if (url.pathname.startsWith("/jmap/eventsource")) { if (url.pathname.startsWith("/jmap/eventsource")) {
const raw = url.searchParams.get("ping");
const asked = Number(raw);
if (raw === null || raw === "" || !Number.isInteger(asked) || asked < 0) {
res.writeHead(400, { "content-type": "application/json" });
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:notRequest", status: 400 }));
}
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
res.write(`event: ping\ndata: {}\n\n`); res.flushHeaders();
sseClients.add(res); sseClients.add(res);
const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000); const interval = asked === 0 ? 0 : Math.max(asked, PING_FLOOR_SECONDS);
req.on("close", () => { clearInterval(t); sseClients.delete(res); }); const t = interval
// Simulate a new message every 90s ? setInterval(() => res.write(`event: ping\ndata: {"interval": ${interval}}\n\n`), interval * 1000)
: null;
req.on("close", () => { if (t) clearInterval(t); sseClients.delete(res); });
return; return;
} }
res.writeHead(404, { "content-type": "application/json" }); res.writeHead(404, { "content-type": "application/json" });
+45 -29
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, slotOfOccurrence, splitOccurrencePatch, syntheticId } from "./recurrence.js"; import { 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
@@ -68,9 +68,9 @@ describe("expandOccurrences", () => {
describe("occurrenceView", () => { describe("occurrenceView", () => {
it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => { it("strips the rule, sets recurrenceId, and points baseEventId at the master", () => {
const base = series(); const base = series();
const occ = occurrenceAt(base, 1)!; const occ = occurrenceAt(base, "2026-09-08T09:00:00")!;
const view = occurrenceView(base, occ); const view = occurrenceView(base, occ);
assert.equal(view.id, syntheticId("ev1", 1)); assert.equal(view.id, syntheticId("ev1", "2026-09-08T09:00:00"));
assert.equal(view.baseEventId, "ev1"); assert.equal(view.baseEventId, "ev1");
assert.equal(view.recurrenceId, "2026-09-08T09:00:00"); assert.equal(view.recurrenceId, "2026-09-08T09:00:00");
assert.equal(view.recurrenceRule, undefined); assert.equal(view.recurrenceRule, undefined);
@@ -81,8 +81,8 @@ describe("occurrenceView", () => {
// Both halves matter. The id is why `baseEventId` proves nothing about a // Both halves matter. The id is why `baseEventId` proves nothing about a
// series; the absent `recurrenceId` is why a one-off does not read as one. // series; the absent `recurrenceId` is why a one-off does not read as one.
const base = oneOff(); const base = oneOff();
const view = occurrenceView(base, occurrenceAt(base, 0)!); const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T12:00:00")!);
assert.equal(view.id, "ev2-o0"); assert.equal(view.id, "ev2-r20260908T120000");
assert.equal(view.baseEventId, "ev2"); assert.equal(view.baseEventId, "ev2");
assert.notEqual(view.id, view.baseEventId); assert.notEqual(view.id, view.baseEventId);
assert.equal(view.recurrenceId, undefined); assert.equal(view.recurrenceId, undefined);
@@ -90,9 +90,9 @@ describe("occurrenceView", () => {
it("lets an override win over the series", () => { it("lets an override win over the series", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } }; const base = { ...series(), recurrenceOverrides: { "2026-09-08T09:00:00": { title: "Moved" } } };
// Slot 2, not 1: one override has already shifted the numbering. Reaching // The same recurrence id as before the override was written, because that
// for the id this occurrence had *before* the write is the bug below. // is now the whole point: the write does not move any other occurrence.
const view = occurrenceView(base, occurrenceAt(base, 2)!); const view = occurrenceView(base, occurrenceAt(base, "2026-09-08T09:00:00")!);
assert.equal(view.start, "2026-09-08T09:00:00"); assert.equal(view.start, "2026-09-08T09:00:00");
assert.equal(view.title, "Moved"); assert.equal(view.title, "Moved");
}); });
@@ -100,11 +100,15 @@ describe("occurrenceView", () => {
describe("parseSyntheticId", () => { describe("parseSyntheticId", () => {
it("round-trips", () => { it("round-trips", () => {
assert.deepEqual(parseSyntheticId(syntheticId("ev1", 12)), { baseId: "ev1", slot: 12 }); assert.deepEqual(parseSyntheticId(syntheticId("ev1", "2026-09-08T09:00:00")),
{ baseId: "ev1", recurrenceId: "2026-09-08T09:00:00" });
}); });
it("does not claim a stored id", () => { it("does not claim a stored id", () => {
assert.equal(parseSyntheticId("ev1"), null); assert.equal(parseSyntheticId("ev1"), null);
}); });
it("does not claim an id that merely ends in digits", () => {
assert.equal(parseSyntheticId("ev1-r2026"), null);
});
}); });
describe("splitOccurrencePatch", () => { describe("splitOccurrencePatch", () => {
@@ -135,35 +139,47 @@ describe("splitOccurrencePatch", () => {
}); });
describe("synthetic ids are only true until the next write", () => { describe("synthetic ids survive a write", () => {
/* /*
* Confirmed live on 0.16.20 (2026-08-31): writing one `recurrenceOverrides` * This used to assert the opposite, and the reversal is the point.
* entry renumbered a five-week series so that the *same* ids addressed *
* different dates. Nothing was rejected. The mock reproduces the shape of * Up to 0.16.20 a synthetic id encoded a position, so writing one override
* that rather than the exact permutation, because the property that bites is * renumbered the series and a held id silently began naming a different
* not which date an id moves to but that it moves at all, silently. * date — confirmed live on 2026-08-31, and reproduced here on purpose so a
* client could not be written against a comfort the server did not offer.
*
* 0.16.21 identifies an occurrence by its recurrence id instead.
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week series was
* expanded, its third occurrence retitled through the synthetic id, and all
* five original ids re-read. Every one resolved, and every one still named
* its own date. So the hazard is gone, and the mock stops teaching it.
*/ */
it("makes a cached id address a different date after an override is written", () => { it("keeps a cached id on the same date after an override is written", () => {
const before = series(); const before = series();
const held = syntheticId("ev1", slotOfOccurrence(before, occurrenceAt(before, 3)!)); const held = syntheticId("ev1", occurrenceAt(before, "2026-09-10T09:00:00")!.recurrenceId);
const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.slot)!.start; const dateBefore = occurrenceAt(before, parseSyntheticId(held)!.recurrenceId)!.start;
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } }; const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } };
const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.slot)!.start; const dateAfter = occurrenceAt(after, parseSyntheticId(held)!.recurrenceId)!.start;
assert.notEqual(dateAfter, dateBefore); assert.equal(dateAfter, dateBefore);
// And crucially it still resolves — a stale id is wrong, not invalid, so a
// client that trusts it gets a confident answer about the wrong day.
assert.ok(dateAfter);
}); });
it("keeps recurrenceId meaning the same date across a write, which is why it is the handle", () => { it("resolves every id of a series after one of them is overridden", () => {
const before = series(); const before = series();
const occ = occurrenceAt(before, 3)!; const held = expandOccurrences(before, new Date("2026-09-07T00:00:00"), new Date("2026-09-12T00:00:00"))
const after = { ...before, recurrenceOverrides: { "2026-09-07T09:00:00": { title: "changed" } } }; .map((o) => syntheticId("ev1", o.recurrenceId));
const same = expandOccurrences(after, new Date("2026-09-01T00:00:00"), new Date("2026-10-01T00:00:00")) const after = { ...before, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "changed" } } };
.find((o) => o.recurrenceId === occ.recurrenceId); for (const id of held) {
assert.equal(same!.start, occ.start); const occ = occurrenceAt(after, parseSyntheticId(id)!.recurrenceId);
assert.ok(occ, `${id} should still resolve`);
assert.equal(syntheticId("ev1", occ.recurrenceId), id);
}
});
it("still refuses an id whose date the rule no longer generates", () => {
const base = { ...series(), recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
assert.equal(occurrenceAt(base, "2026-09-09T09:00:00"), null);
}); });
}); });
+44 -41
View File
@@ -1,5 +1,5 @@
/** /**
* Enough recurrence expansion for the mock to behave like Stalwart 0.16.20. * Enough recurrence expansion for the mock to behave like Stalwart 0.16.21.
* *
* 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
@@ -8,8 +8,8 @@
* - an expanded query gives *everything* a synthetic id over a `baseEventId`, * - an expanded query gives *everything* a synthetic id over a `baseEventId`,
* a one-off included, so `baseEventId` is no evidence of a series; * a one-off included, so `baseEventId` is no evidence of a series;
* - an occurrence carries a `recurrenceId` and no rule of its own; * - an occurrence carries a `recurrenceId` and no rule of its own;
* - 0.16.20 takes a write aimed at a synthetic id and turns it into a * - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
* `recurrenceOverrides` entry rather than touching the series. * rather than touching the series.
* *
* A mock that agrees with the client rather than with the server is how #26 and * A mock that agrees with the client rather than with the server is how #26 and
* #30 reached a live instance, so the refusals matter as much as the successes: * #30 reached a live instance, so the refusals matter as much as the successes:
@@ -25,41 +25,41 @@ const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"]; const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/** /**
* The id an occurrence is addressed by, which is only true until the next write. * The id an occurrence is addressed by: its `recurrenceId`, not its position.
* *
* Stalwart's are opaque; the mock's are parseable because it has to resolve * Stalwart's are opaque; the mock's are parseable because it has to resolve
* them, and nothing in ihasmail may read either. * them, and nothing in ihasmail may read either.
* *
* They are also deliberately **unstable**, because the real ones are. * **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
* **Confirmed live on 0.16.20 (2026-08-31):** a synthetic id encodes a position * encoded a *position* in the expanded series, so writing one override
* in the expanded series, and writing a `recurrenceOverrides` entry adds a * renumbered the rest and a held id silently began addressing a different
* component that renumbers it. A five-week series held `e i m q u` over * date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
* 03-01…03-29; after one override was written to 03-08 the same ids addressed * an occurrence is now identified by its recurrence id.
* 03-01, 03-15, 03-29, 03-08, 03-22. Nothing was rejected — they just meant
* different dates.
* *
* That is the hazard worth reproducing, and note which way round it goes: a * **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
* stale id is not *invalid*, it is *wrong*. A mock that expired them instead * expanded, the third occurrence retitled through its synthetic id, and all
* would hand back a loud `notFound` and let a client that caches ids look * five original ids re-read afterwards. Every one still resolved, and every
* careful. So the numbering is shifted by the number of overrides — an * one still named its own date; nothing was renumbered and nothing was
* arbitrary stand-in for Stalwart's renumbering, with the one property that * `notFound`. Only the *order* of the ids from an expanded query changed —
* matters: hold an id across a write and it silently addresses another date. * the overridden occurrence moved to the end of the list — which is why a
* client sorts by `start` rather than trusting query order.
*
* The real ids look nothing like these (`h1fo9uaaaaab` for the first of that
* series); what has to match is that holding one across a write stays correct.
*/ */
export const syntheticId = (baseId: string, slot: number): string => `${baseId}-o${slot}`; const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
export function parseSyntheticId(id: string): { baseId: string; slot: number } | null { export const syntheticId = (baseId: string, recurrenceId: string): string =>
const m = /^(.+)-o(\d+)$/.exec(id); `${baseId}-r${compact(recurrenceId)}`;
return m ? { baseId: m[1]!, slot: Number(m[2]) } : null;
}
/** How far the id numbering has been rotated away from the series order. */ export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
function rotation(base: Obj): number { const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
return Object.keys((base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {}).length; if (!m) return null;
} const c = m[2]!;
const recurrenceId =
/** The id slot this occurrence currently answers to. */ `${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
export function slotOfOccurrence(base: Obj, occ: Occurrence): number { `T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
return occ.index + rotation(base); return { baseId: m[1]!, recurrenceId };
} }
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */ /** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
@@ -104,8 +104,8 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
const emit = (index: number, at: Date): boolean => { const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at); const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId]; const override = overrides[recurrenceId];
// An excluded date is simply gone from the expansion. Its slot is not // An excluded date is simply gone from the expansion. Nothing is
// reserved -- see `syntheticId` for why nothing here pretends otherwise. // reserved in its place, and no other occurrence's id moves because of it.
if (override?.excluded === true) return true; if (override?.excluded === true) return true;
/* /*
* An override may move the occurrence, and then `start` and `recurrenceId` * An override may move the occurrence, and then `start` and `recurrenceId`
@@ -115,9 +115,9 @@ export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[]
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still * came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
* `2027-06-14T09:00:00`. * `2027-06-14T09:00:00`.
* *
* Which is exactly why `recurrenceId` is what a client holds on to. It is * Which is exactly why `recurrenceId` is what a client holds on to, and
* the one name for this instance that neither a renumbering nor a move * since 0.16.21 what the id is built from: the one name for this instance
* changes. * that a move does not change.
*/ */
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId; const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
const shown = parseLocal(start); const shown = parseLocal(start);
@@ -178,7 +178,7 @@ export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base }; const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k]; for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {}); Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, slotOfOccurrence(base, occ)); view.id = syntheticId(base.id as string, occ.recurrenceId);
view.baseEventId = base.id; view.baseEventId = base.id;
view.start = occ.start; view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into // Only a genuine instance of a series carries one. A one-off expanded into
@@ -229,10 +229,13 @@ export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied:
return { applied }; return { applied };
} }
/** The occurrence a slot currently addresses — which is not a fixed thing. */ /**
export function occurrenceAt(base: Obj, slot: number): Occurrence | null { * The occurrence a recurrence id addresses, which no later write moves.
const index = slot - rotation(base); *
if (index < 0) return null; * An id whose date the rule no longer generates — excluded, or past a `count`
* — resolves to nothing, and the caller turns that into `notFound`.
*/
export function occurrenceAt(base: Obj, recurrenceId: string): Occurrence | null {
const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000)); const all = expandOccurrences(base, new Date(-8640000000000), new Date(8640000000000));
return all.find((o) => o.index === index) ?? null; return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
} }