Merge pull request #293 from Coffey-Labs/mock-0-16-21

Follow Stalwart 0.16.21 in the mock
This commit is contained in:
Coffey Labs
2026-09-06 15:48:06 -07:00
committed by GitHub
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;
} }