Files
ihasmail-inbuxa/server/src/mock/engine.ts
T
jcoffey-dev 5b85c254e7 Split the mock server along the section markers it already had
server/src/mock/index.ts was 1,545 lines, the largest file in the repo.
It had carried `/* ---------- data ---------- */` style markers for a
while, so the seams were already drawn; this turns six of them into
files.

  mock/config.ts     51  env-derived constants, `account`, `state`
  mock/data.ts      410  fixtures and the builders that make them
  mock/engine.ts    442  the generic JMAP machinery -- get/set, filters,
                         patches, refs, limits, recurrence plumbing
  mock/handlers.ts  455  the `Method/name` dispatch table
  mock/events.ts     26  SSE fan-out and the Email/changes ring buffer
  mock/auth.ts       11  checkOtp
  mock/index.ts     195  HTTP routing, the session document, listen

TWO THINGS THAT COULD NOT JUST MOVE:

`counter` and `vacation` were module-level `let`s written from both the
fixture builders and the handlers. An ES module can export a `let` and
importers see it update, but they cannot assign to it, so both became
containers: `seq.counter` and `vacationBox.current`. Seven call sites.

`recordEmailChange`, `broadcast`, `sseClients` and `checkOtp` lived in
the HTTP section, but the handlers call them -- and index.ts imports the
handlers. Leaving them there is a cycle, so they became events.ts and
auth.ts rather than being dragged into data.ts, which is fixtures.

`account` is still exported from index.ts, because account.test.ts and
login-guard.test.ts reach for `mock.account` and `mock.server`.

Verified by running it, not only by compiling it: `npm run mock` boots
and listens, `/.well-known/jmap` returns a session, and a POST to
`/jmap/` answers Mailbox/get with the nine seeded folders and
Email/query with the seeded messages.
2026-09-15 23:22:34 -07:00

443 lines
20 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
import { createDirectory, mockRole } from "./directory.js";
import { ACCOUNT, MOCK_LOCALE, Obj, USER, account, nextState, state } from "./config.js";
import { NO_SCHEDULING_SEND, SCHEDULING_FORBIDDEN, blobs, events, mailboxes } from "./data.js";
/* ---------- helpers ---------- */
export function pick(o: Obj, props?: string[] | null): Obj {
if (!props) return o;
const out: Obj = { id: o.id };
for (const p of props) if (p in o) out[p] = o[p];
else if (p.startsWith("header:")) out[p] = null;
return out;
}
export function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record<string, string>): Obj {
const out: Obj = {};
for (const [k, v] of Object.entries(args)) {
if (k.startsWith("#")) {
const r = v as { resultOf: string; name: string; path: string };
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
} else out[k] = resolveCreationIds(v, creations, k);
}
return out;
}
/**
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
* go, pointing at something created earlier in the same request. Sending a
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
* without this the mock quietly declines to create any submission at all.
*
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
* the method that receives them resolves them itself.
*/
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
if (key === "onSuccessUpdateEmail") return value;
if (typeof value === "string") {
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
}
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
if (value && typeof value === "object") {
const out: Obj = {};
for (const [k, v] of Object.entries(value as Obj)) {
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
out[nk] = resolveCreationIds(v, creations, k);
}
return out;
}
return value;
}
export function jsonPointer(obj: unknown, path: string): unknown {
const parts = path.split("/").filter(Boolean);
let cur: unknown = obj;
for (let i = 0; i < parts.length; i++) {
const p = parts[i]!;
if (p === "*") {
const rest = parts.slice(i + 1).join("/");
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
return arr;
}
cur = (cur as Obj)?.[p];
}
return cur;
}
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.operator) {
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
}
const kw = e.keywords as Obj;
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
if (f.notKeyword && kw[f.notKeyword as string]) return false;
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
if (f.after && String(e.receivedAt) < String(f.after)) return false;
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
return true;
}
export function applyPatch(obj: Obj, patch: Obj) {
for (const [k, v] of Object.entries(patch)) {
if (k.includes("/")) {
const [root, ...rest] = k.split("/");
const key = rest.join("/");
const target = (obj[root!] as Obj) ?? {};
if (v === null) delete target[key];
else target[key] = v;
obj[root!] = target;
} else obj[k] = v;
}
}
/* ---------- method handlers ---------- */
export type Handler = (args: Obj) => Obj | [string, Obj][];
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
export class MethodError extends Error {
constructor(
public readonly type: string,
description?: string,
) {
super(description ?? type);
}
}
export const MAX_OBJECTS = 500;
/**
* Stalwart refuses a whole method call that carries more objects than it will
* process at once - it does not quietly handle the first 500. Enforce the same
* ceiling the session advertises, so an unbatched client fails here too.
*/
export function enforceLimits(name: string, args: Obj): void {
const tooLarge = () => {
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
};
if (name.endsWith("/get")) {
const ids = args.ids as unknown[] | null | undefined;
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
}
if (name.endsWith("/set")) {
const n =
Object.keys((args.create as Obj) ?? {}).length +
Object.keys((args.update as Obj) ?? {}).length +
((args.destroy as unknown[] | undefined)?.length ?? 0);
if (n > MAX_OBJECTS) tooLarge();
}
}
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
/*
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
* name: a `/get` with no `properties` comes back without the field at all.
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
* The mock handing it over unasked meant a client that never asked still saw
* every share, and the one place that did not -- the real server -- showed
* 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.
*/
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
if (a.properties) return res;
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
}
export function genericGet(list: Obj[]) {
return (a: Obj) => {
const ids = a.ids as string[] | null | undefined;
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
};
}
/**
* An id, as either a stored event or one occurrence of one.
*
* A synthetic id whose base is gone, or whose date the rule no longer
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
* the way the server answers for an occurrence that is not there any more.
*/
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
const direct = list.find((x) => x.id === id);
if (direct) return { base: direct };
const parsed = parseSyntheticId(id);
if (!parsed) return null;
const base = list.find((x) => x.id === parsed.baseId);
if (!base) return null;
const occ = occurrenceAt(base, parsed.recurrenceId);
return occ ? { base, occ } : null;
}
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
export class SetError extends Error {
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
}
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
return (a: Obj) => {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const id = `${prefix}${randomUUID().slice(0, 6)}`;
const o = { ...(obj as Obj), id };
try {
onCreate?.(o);
} catch (err) {
if (!(err instanceof SetError)) throw err;
notCreated[cid] = err.toJSON();
continue;
}
list.push(o);
created[cid] = { id };
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const o = list.find((x) => x.id === id);
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
}
for (const id of (a.destroy as string[]) ?? []) {
const i = list.findIndex((x) => x.id === id);
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
}
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
};
}
/* ---------- calendar events ---------- */
/**
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
*
* An update or destroy aimed at an occurrence does not touch the series: it
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
*
* The refusals are the point of reproducing this at all:
*
* - a base event and one of its instances in the same request is refused, both
* ids at once, because the server cannot apply them in a defined order;
* - the same id twice is "Duplicate event id.";
* - the ten event-level properties are refused with `invalidProperties`;
* - and the twelve inherited ones are dropped in silence, with the response
* still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server.
*/
/**
* Enough of an iCalendar reader to stand in for Stalwart's.
*
* It reads per VEVENT rather than across the whole file, because a file is the
* case an emailed invitation never was: an export carries a year of them, and a
* regex over the whole text would find the first DTSTART and call that the
* answer. One event still comes back as a bare object, the shape this returned
* when an invitation was all it had to handle.
*
* The synthetic organizer and attendee only go on events that arrived with a
* METHOD. Those are scheduling messages, which is what the invitation fixtures
* are; a plain export is not addressed to anyone, and inventing participants
* for it would make imported events look like invitations nobody sent.
*/
export function calendarEventParse(a: Obj) {
const parsed: Obj = {};
const notParsable: string[] = [];
for (const b of a.blobIds as string[]) {
const blob = blobs.get(b);
if (!blob) { notParsable.push(b); continue; }
const text = blob.data.toString();
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
const method = field(text, "METHOD");
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
const events = bodies.map((body) => {
const g = (k: string) => field(body, k);
const ds = g("DTSTART") ?? "20260101T000000Z";
const de = g("DTEND") ?? ds;
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
const start = new Date(`${toLocal(ds)}Z`);
const end = new Date(`${toLocal(de)}Z`);
return {
"@type": "Event",
uid: g("UID"),
title: g("SUMMARY"),
start: toLocal(ds),
timeZone: "Etc/UTC",
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
method,
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
participants: method
? {
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
}
: undefined,
};
});
if (!events.length) { notParsable.push(b); continue; }
parsed[b] = events.length === 1 ? events[0] : events;
}
return { accountId: ACCOUNT, parsed, notParsable };
}
export function calendarEventSet(a: Obj) {
const created: Obj = {};
const updated: Obj = {};
const destroyed: string[] = [];
const notCreated: Obj = {};
const notUpdated: 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) ?? {})) {
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
// 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 #26 and #30 reached a live server unnoticed — so it does both.
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
const parts = o.participants as Record<string, Obj> | undefined;
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
o.uid = o.uid ?? randomUUID();
events.push(o);
created[cid] = { id: o.id };
}
const updates = Object.entries((a.update as Obj) ?? {});
const destroys = ((a.destroy as string[]) ?? []).slice();
const seen = new Set<string>();
/* A base and one of its instances cannot be settled in the same request. */
const baseOf = (id: string): string | null => {
const r = resolveEvent(events, id);
return r ? (r.base.id as string) : null;
};
const touched = new Map<string, { base: string[]; instance: string[] }>();
for (const id of [...updates.map(([id]) => id), ...destroys]) {
const b = baseOf(id);
if (!b) continue;
const entry = touched.get(b) ?? { base: [], instance: [] };
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
touched.set(b, entry);
}
const conflicted = new Set<string>();
for (const [, e] of touched) {
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
}
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
for (const [id, patch] of updates) {
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
seen.add(id);
const resolved = resolveEvent(events, id);
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
writeOverride(resolved.base, resolved.occ, applied);
updated[id] = null;
}
for (const id of destroys) {
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
const resolved = resolveEvent(events, id);
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
if (resolved.occ) {
// One date off a series, which is an override rather than a deletion.
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
destroyed.push(id);
continue;
}
const i = events.findIndex((x) => x.id === id);
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
}
return setResp({
created, updated, destroyed,
...(Object.keys(notCreated).length ? { notCreated } : {}),
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
});
}
/**
* Merge a patch into the override for one date.
*
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
* an override always carries its own timing; the mock does the same, or a
* client could depend on inheriting them and be right only here.
*/
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
const next: Obj = { ...existing };
if (!replace) {
if (!("start" in next)) next.start = occ.start;
if (!("duration" in next) && base.duration) next.duration = base.duration;
}
applyPatch(next, patch);
overrides[occ.recurrenceId] = next;
base.recurrenceOverrides = overrides;
}
/* ---------- submissions ---------- */
/**
* Held messages, the way Stalwart models them: `sendAt` is derived from the
* envelope's FUTURERELEASE parameter rather than set by the client, and
* `undoStatus` reports whether the message is still in the queue.
*/
export const submissions: Obj[] = [];
export function submissionView(sub: Obj): Obj {
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
}
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
if (!f) return true;
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
return true;
}
/** Who the demo user is, for administration. See mock/directory.ts. */
export const directory = createDirectory({
accountId: ACCOUNT,
user: USER,
locale: MOCK_LOCALE,
role: mockRole(process.env.MOCK_ROLE),
metricsOff: process.env.MOCK_METRICS === "off",
fail: (type, description) => new MethodError(type, description),
});