Files
ihasmail/server/src/mock/recurrence.ts
T

279 lines
12 KiB
TypeScript

/**
* 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
* against, and all three had already cost a debugging session:
*
* - an expanded query gives *everything* a synthetic id over a `baseEventId`,
* a one-off included, so `baseEventId` is no evidence of a series;
* - an occurrence carries a `recurrenceId` and no rule of its own;
* - a write aimed at a synthetic id becomes a `recurrenceOverrides` entry
* rather than touching the series.
*
* 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:
* what Stalwart rejects is rejected here, and what it drops in silence is
* dropped here, in silence, on purpose.
*/
export type Obj = Record<string, unknown>;
/** How far the expander will walk before giving up on a rule. */
const MAX_ITERATIONS = 750;
const DAYS = ["su", "mo", "tu", "we", "th", "fr", "sa"];
/**
* 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
* them, and nothing in ihasmail may read either.
*
* **They are stable, and that is a change.** Up to 0.16.20 a synthetic id
* encoded a *position* in the expanded series, so writing one override
* renumbered the rest and a held id silently began addressing a different
* date — a hazard this file used to reproduce on purpose. 0.16.21 fixed it:
* an occurrence is now identified by its recurrence id.
*
* **Confirmed live on 0.16.21 (2026-09-06):** a five-week weekly series was
* expanded, the third occurrence retitled through its synthetic id, and all
* five original ids re-read afterwards. Every one still resolved, and every
* one still named its own date; nothing was renumbered and nothing was
* `notFound`. Only the *order* of the ids from an expanded query changed —
* 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.
*/
const compact = (recurrenceId: string): string => recurrenceId.replace(/[-:]/g, "");
export const syntheticId = (baseId: string, recurrenceId: string): string =>
`${baseId}-r${compact(recurrenceId)}`;
export function parseSyntheticId(id: string): { baseId: string; recurrenceId: string } | null {
const m = /^(.+)-r(\d{8}T\d{6})$/.exec(id);
if (!m) return null;
const c = m[2]!;
const recurrenceId =
`${c.slice(0, 4)}-${c.slice(4, 6)}-${c.slice(6, 8)}` +
`T${c.slice(9, 11)}:${c.slice(11, 13)}:${c.slice(13, 15)}`;
return { baseId: m[1]!, recurrenceId };
}
/** `2026-08-31T09:00:00` — the naive local form the mock stores `start` in. */
export function localDateTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
const parseLocal = (s: string): Date => new Date(s);
export interface Occurrence {
index: number;
/** The slot in the series this instance fills, which keys any override. */
recurrenceId: string;
start: string;
/** Set when a `recurrenceOverrides` entry applies to this date. */
override?: Obj;
}
interface Rule {
frequency?: string;
interval?: number;
count?: number;
until?: string;
byDay?: { day: string }[];
}
/**
* Every occurrence of `base` between `from` and `to`, in series order.
*
* An event with no rule has exactly one, at index 0 — which is what gives a
* one-off the synthetic id a real server would give it.
*/
export function expandOccurrences(base: Obj, from: Date, to: Date): Occurrence[] {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const startStr = base.start as string;
if (!startStr) return [];
const first = parseLocal(startStr);
const rule = base.recurrenceRule as Rule | undefined;
const out: Occurrence[] = [];
const emit = (index: number, at: Date): boolean => {
const recurrenceId = localDateTime(at);
const override = overrides[recurrenceId];
// An excluded date is simply gone from the expansion. Nothing is
// reserved in its place, and no other occurrence's id moves because of it.
if (override?.excluded === true) return true;
/*
* An override may move the occurrence, and then `start` and `recurrenceId`
* are two different times: the slot it fills stays where the rule put it,
* and only the clock time moves. **Confirmed live on 0.16.20
* (2026-08-31)**: one occurrence of a weekly 09:00 series moved to 14:00
* came back `start: 2027-06-14T14:00:00` with `recurrenceId` still
* `2027-06-14T09:00:00`.
*
* Which is exactly why `recurrenceId` is what a client holds on to, and
* since 0.16.21 what the id is built from: the one name for this instance
* that a move does not change.
*/
const start = (typeof override?.start === "string" ? override.start : null) ?? recurrenceId;
const shown = parseLocal(start);
if (shown >= from && shown < to) {
out.push({ index, recurrenceId, start, ...(override ? { override } : {}) });
}
return at < to;
};
if (!rule?.frequency) {
emit(0, first);
return out;
}
const interval = Math.max(1, rule.interval ?? 1);
const until = rule.until ? parseLocal(rule.until) : null;
const byDay = rule.byDay?.length ? new Set(rule.byDay.map((d) => d.day.toLowerCase())) : null;
let index = 0;
let emitted = 0;
const cursor = new Date(first);
for (let step = 0; step < MAX_ITERATIONS; step++) {
if (until && cursor > until) break;
if (rule.count != null && emitted >= rule.count) break;
const matches = !byDay || byDay.has(DAYS[cursor.getDay()]!);
if (matches) {
emitted++;
const keepGoing = emit(index, new Date(cursor));
index++;
if (!keepGoing) break;
}
// A rule with byDay walks day by day and keeps the days it names; without
// one it steps by its own frequency.
if (byDay) cursor.setDate(cursor.getDate() + 1);
else if (rule.frequency === "daily") cursor.setDate(cursor.getDate() + interval);
else if (rule.frequency === "weekly") cursor.setDate(cursor.getDate() + 7 * interval);
else if (rule.frequency === "monthly") cursor.setMonth(cursor.getMonth() + interval);
else if (rule.frequency === "yearly") cursor.setFullYear(cursor.getFullYear() + interval);
else break;
}
return out;
}
/** Fields that describe the series and never travel down to one instance. */
const SERIES_ONLY = ["recurrenceRule", "recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides"];
/**
* The object a `CalendarEvent/get` returns for one occurrence.
*
* The rule is stripped, `recurrenceId` is set, and `baseEventId` points at the
* master — so an occurrence is recognizable by its `recurrenceId` and by
* nothing else, which is the shape `isRecurring` was written against.
*/
export function occurrenceView(base: Obj, occ: Occurrence): Obj {
const view: Obj = { ...base };
for (const k of SERIES_ONLY) delete view[k];
Object.assign(view, occ.override ?? {});
view.id = syntheticId(base.id as string, occ.recurrenceId);
view.baseEventId = base.id;
view.start = occ.start;
// Only a genuine instance of a series carries one. A one-off expanded into
// its single occurrence does not, or every one-off would look recurring.
if (base.recurrenceRule) view.recurrenceId = occ.recurrenceId;
delete view.excluded;
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`. */
export const OCCURRENCE_REJECTED = new Set([
"baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd",
"useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
]);
/**
* Dropped from the patch, with the response still reporting success.
*
* This is the half that has to be reproduced most carefully. A mock that
* *applied* these would agree with a client that sends them, and the belief
* would ship — which is exactly the road #26 took to a live server.
*/
export const OCCURRENCE_INHERITED = new Set([
"@type", "method", "organizerCalendarAddress", "privacy", "prodId",
"recurrenceId", "recurrenceIdTimeZone", "sentBy", "uid",
"recurrenceOverrides", "recurrenceRule", "relatedTo",
]);
/**
* Split a per-occurrence patch the way the server's validator does.
*
* `rejected` is the first property that would be refused, if any; `applied` is
* what actually lands on the override. Everything else vanishes without a word.
*/
export function splitOccurrencePatch(patch: Obj): { rejected?: string; applied: Obj } {
const applied: Obj = {};
for (const [key, value] of Object.entries(patch)) {
const [head, , third] = key.split("/");
const root = head ?? key;
if (OCCURRENCE_REJECTED.has(root)) return { rejected: root, applied };
if (OCCURRENCE_INHERITED.has(root)) continue;
if (root === "participants" && third === "calendarAddress") continue;
if (root === "id") continue;
applied[key] = value;
}
return { applied };
}
/**
* The occurrence a recurrence id addresses, which no later write moves.
*
* 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));
return all.find((o) => o.recurrenceId === recurrenceId) ?? null;
}