diff --git a/server/src/mock/auth.ts b/server/src/mock/auth.ts new file mode 100644 index 0000000..bc4403d --- /dev/null +++ b/server/src/mock/auth.ts @@ -0,0 +1,9 @@ +import { account } from "./config.js"; +import { parseOtpauthUrl, verifyTotp } from "../totp.js"; + +/* Shared by the HTTP layer and by the handlers that re-check a code. */ +export function checkOtp(code: string | undefined): boolean { + if (!account.otpUrl) return true; + const params = parseOtpauthUrl(account.otpUrl); + return Boolean(code && params && verifyTotp(params, code)); +} diff --git a/server/src/mock/config.ts b/server/src/mock/config.ts new file mode 100644 index 0000000..6004552 --- /dev/null +++ b/server/src/mock/config.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; + + +export const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions; + +export const PORT = Number(process.env.MOCK_PORT ?? 8788); +/** + * Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested + * against a server ihasmail does not support. This is only that: the rest of + * the mock still behaves like 0.16. Emulating 0.15 properly went with the + * support for it. + */ +export const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1"; +/** + * Stalwart advertises FUTURERELEASE in the session but only honors it when + * the MTA's own `futureRelease` setting is on -- and that setting defaults to + * off, in which case the hold is dropped without a word and the message goes + * out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap. + */ +export const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1"; +/** What the session advertises, matching Stalwart's own 30 days. */ +export const MAX_DELAYED_SEND = 86400 * 30; +export const ACCOUNT = "a1"; +/** How long a push subscription lives before the server drops it. */ +export const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** An account somebody has shared with the demo user. See the session below. */ +export const SHARED_ACCOUNT = "a2"; +export const SHARED_CAPS: Obj = { + "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, + "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, + "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, +}; +export const USER = process.env.MOCK_USER ?? "demo@example.com"; +/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ +export const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; +/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */ +export const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss"; +export const PASS = process.env.MOCK_PASS ?? "demo"; +/** + * Credential state, mutable so the self-service flows can be exercised against + * the mock the way they run against a real 0.16 server: the password changes, + * 2FA starts demanding a code on every request, and app passwords keep working + * without one. + */ +export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] }; +export const MASKED = "[********]"; + +export type Obj = Record; +export const state = { n: 1 }; +export const nextState = () => String(state.n++); + diff --git a/server/src/mock/data.ts b/server/src/mock/data.ts new file mode 100644 index 0000000..542a2ae --- /dev/null +++ b/server/src/mock/data.ts @@ -0,0 +1,410 @@ +import { randomUUID } from "node:crypto"; +import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js"; +import { Obj, SHARED_ACCOUNT, USER, account } from "./config.js"; + +/* ---------- data ---------- */ +/* + * The names are Stalwart's own defaults, which follow the Exchange convention: + * "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the + * short forms, so anything built from a folder's name read differently here + * than in production -- "Empty Trash" against the mock, "Empty Deleted Items" + * against a real server -- and every screenshot in the README showed a folder + * list no user has. The role is what the client branches on; the name is only + * ever displayed, which is exactly why it has to look right. + */ +/** Push subscriptions, as a fresh account has none. */ +export const pushSubscriptions: Obj[] = []; + +export const mailboxes: Obj[] = [ + mb("inbox", "Inbox", "inbox"), + mb("drafts", "Drafts", "drafts"), + mb("sent", "Sent Items", "sent"), + mb("junk", "Junk Mail", "junk"), + mb("trash", "Deleted Items", "trash"), + mb("archive", "Archive", "archive"), + mb("work", "Work", null), + mb("work-inv", "Invoices", null, "work"), + mb("news", "Newsletters", null), +]; +export function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj { + return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } }; +} + +export const blobs = new Map(); +export function putBlob(data: Buffer | string, type: string): string { + const id = `b${randomUUID().slice(0, 8)}`; + blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) }); + return id; +} + +export const people = [ + ["Ada Lovelace", "ada@example.org"], ["Grace Hopper", "grace@example.org"], ["Linus Torvalds", "linus@kernel.example"], + ["Margaret Hamilton", "margaret@nasa.example"], ["Alan Turing", "alan@bletchley.example"], ["GitHub", "noreply@github.example"], + ["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "digest@newsletter.example"], ["Finance Team", "finance@example.org"], +]; +export const subjects = [ + "Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff", + "Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?", + "Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in", +]; +export const emails: Obj[] = []; +export const seq = { counter: 1 }; +/** + * A real TNEF blob, built to the format description, so the winmail.dat + * decoder has something to open that is not a hand-made fixture in its own + * test file. Two files inside, one of them carrying a long name in the MAPI + * stream behind an 8.3 title -- which is the case the decoder exists for. + */ +export function winmailDat(): Buffer { + const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]); + const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]); + const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; }; + const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]); + const asciiProp = (id: number, value: string) => { + const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]); + const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4); + return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]); + }; + const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]); + + const renddata = Buffer.alloc(14); + const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]); + const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1"); + const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1"); + + return Buffer.concat([ + u32(0x223e9f78), u16(0x1234), + attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion + attr(2, 0x00069002, renddata), + attr(2, 0x00018010, title("QUARTE~1.CSV")), + attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])), + attr(2, 0x0006800f, csv), + attr(2, 0x00069002, renddata), + attr(2, 0x00018010, title("notes.txt")), + attr(2, 0x0006800f, notes), + ]); +} + +/** + * A really signed message, served as the raw blob a client verifies against. + * + * The signature is over exact bytes, so this deliberately does not go through + * addEmail: that builds a message out of parts and would hand back a body it + * had assembled rather than the one that was signed. Here the blob *is* the + * fixture, byte for byte, and the JMAP metadata is arranged around it. + * + * `bodyStructure` says multipart/signed because that is what the client checks + * before deciding to download anything -- a mock that omitted it would leave + * the whole path unreachable while every stored byte was still correct. + */ +export function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) { + const id = `e${seq.counter++}`; + const raw = signedMessage(o.which); + const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z"); + const body = "The Analytical Engine has no pretensions whatever to originate anything."; + const textBlob = putBlob(body, "text/plain"); + const e: Obj = { + id, + blobId: putBlob(raw, "message/rfc822"), + threadId: `t${id}`, + mailboxIds: { [o.mailbox]: true }, + keywords: o.unread ? {} : { $seen: true }, + size: raw.length, + receivedAt: received, + sentAt: received, + messageId: [`${id}@mock`], + inReplyTo: null, + references: null, + from: [{ name: o.from[0], email: o.from[1] }], + to: [{ name: "Demo User", email: USER }], + cc: null, bcc: null, replyTo: null, sender: null, + subject: o.subject, + hasAttachment: false, + preview: body.slice(0, 120), + textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], + htmlBody: [], + attachments: [], + bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } }, + bodyStructure: { + partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null, + subParts: [ + { partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, + { partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null }, + ], + }, + }; + emails.push(e); + return e; +} + +/* + * A marketing template of the shape #290 was reported against. + * + * Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a + * ` +
+ + + +

Your order is on its way

+

Thanks for shopping with us. Your parcel left the warehouse this morning.

+ +
+ Track your parcel +
+

Order #4471 · placed 2 September

+
+ You are receiving this because you bought something. Unsubscribe +
+
`; + +export function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { + const id = `e${seq.counter++}`; + const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); + const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; + const html = `

Hi,

This is a sample HTML message about “${o.subject}”. It was generated by the ihasmail mock server.

logo

Cheers,
${o.from[0]}

On Monday, someone wrote:
This is the quoted part of an earlier message. It should be collapsed by default.
`; + const textBlob = putBlob(text, "text/plain"); + const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); + const attachments: Obj[] = []; + if (o.attach) { + attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null }); + attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null }); + } + if (o.winmail) { + const dat = winmailDat(); + attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null }); + } + if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" }); + const e: Obj = { + id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"), + threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true }, + keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) }, + size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received, + messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null, + from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, + subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), + textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], + htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], + attachments, + bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, + bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, + "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, + "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, + // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so + // delivered mail carries it and mail this account wrote does not. + "header:X-Spam-Status:asText": + o.mailbox === "junk" + ? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no" + : o.mailbox === "inbox" + ? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]" + : null, + }; + emails.push(e); + return e; +} +// Seed +for (let i = 0; i < 45; i++) { + const p = people[i % people.length]!; + const subj = subjects[i % subjects.length]!; + const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 }); + if (i % 4 === 0) { + // thread replies + addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true }); + addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 }); + } +} +addEmail({ from: ["Shop Updates", "orders@example.com"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true }); +addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; + +/* + * Three signed messages, so every branch of the signature banner can be seen + * without staging a certificate authority. Read "A note" first: that pins Ada's + * certificate, after which the other two have something to disagree with. + */ +addSignedEmail({ which: "good", from: ["Ada Lovelace", "ada@example.com"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true }); +addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "ada@example.com"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true }); +addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "ada@example.com"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true }); +addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); +addEmail({ from: ["Outlook User", "sales@partner.example"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true }); +addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true }); +addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true }); +// A thread whose unread message is not the last one: someone's server queued +// their reply for hours, so it landed after messages that answer it and sits in +// the middle of the conversation. Opening this thread at the newest message +// left that reply above the fold until the mark-read timer swept it (#87). +{ + const subj = "Compiler timings for the release"; + const t = addEmail({ from: ["Grace Hopper", "grace@example.org"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true }); + const tid = t.threadId as string; + const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) => + addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` }); + reply({ from: ["Alan Turing", "alan@example.org"], daysAgo: 5.5, mailbox: "inbox", unread: true }); + // Long enough after the unread one that the thread scrolls: opening at the + // bottom put four messages between the reader and the mail they had not read. + reply({ from: ["Demo User", USER], to: "grace@example.org", daysAgo: 5, mailbox: "sent", html: true }); + reply({ from: ["Grace Hopper", "grace@example.org"], daysAgo: 4.5, mailbox: "inbox" }); + reply({ from: ["Margaret Hamilton", "margaret@example.org"], daysAgo: 4, mailbox: "inbox", html: true }); + reply({ from: ["Demo User", USER], to: "margaret@example.org", daysAgo: 3.5, mailbox: "sent" }); + reply({ from: ["Grace Hopper", "grace@example.org"], daysAgo: 3, mailbox: "inbox", html: true }); +} +// Invitation email +{ + const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:ada@example.org\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; + const e = addEmail({ from: ["Ada Lovelace", "ada@example.org"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true }); + const b = putBlob(ics, "text/calendar"); + (e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }]; + (e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }); + e.hasAttachment = true; +} + +export const identities: Obj[] = [ + { id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "
--
Demo User
ihasmail
", mayDelete: false }, + { id: "i2", name: "Demo (alias)", email: "alias@example.com", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true }, +]; +export const vacationBox: { current: Obj } = { current: { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null } }; +export const sieveScripts: Obj[] = []; +/* A calendar in the shared account, so "Shared with me" and a colleague's + events appearing in the grid can be exercised. Read-only, as a share is. */ +export const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }]; +export const sharedEvents: Obj[] = []; +export const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events); +export const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars); +export const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }]; +export function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; } +export const events: Obj[] = []; +{ + const now = new Date(); + const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; }; + const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`; + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); + events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:ada@example.org", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` }); + events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); + /* + * One event in a zone that is not the reader's, because every other fixture + * here uses the machine's own and so cannot tell a correct conversion from + * no conversion at all. Dragging this one is what proves a move keeps the + * time the event says it happens at. + */ + events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" }); + events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); + // Two in the shared account, so a colleague's calendar has something in it. + sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); + sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null }); +} +export const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }]; +export const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write }); +export const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }]; +/* A book in the shared account, so "Shared with me" and addressing a message + from somebody else's contacts can be exercised at all. Read-only, which is + what a share usually is. */ +export const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }]; +export const sharedCards: Obj[] = [ + { id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "katherine@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, + { id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "dorothy@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, +]; +/** + * One sort property, as Email/query defines them. `hasKeyword` sorts a + * boolean, and false comes before true -- which is what makes "unread first" + * an *ascending* sort on $seen. + */ +export function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number { + const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? ""); + switch (property) { + case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt)); + case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt)); + case "size": return Number(x.size ?? 0) - Number(y.size ?? 0); + case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? "")); + case "from": return addr(x.from).localeCompare(addr(y.from)); + case "to": return addr(x.to).localeCompare(addr(y.to)); + case "hasKeyword": { + const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0); + return has(x) - has(y); + } + default: return 0; + } +} + +/** A server that does not implement sorting on keywords, so the fallback can be developed against. */ +export const NO_KEYWORD_SORT = process.env.MOCK_NO_KEYWORD_SORT === "1"; + +/** The floor Stalwart puts under a requested EventSource ping interval. */ +export 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. + */ +export const NO_SCHEDULING_SEND = process.env.MOCK_NO_SCHEDULING_SEND === "1"; +export const SCHEDULING_FORBIDDEN = "This account is not allowed to send calendar scheduling messages."; + +export const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks); +/** One per contact, by index; a gap means that card has no birthday. */ +export const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [ + { year: 1815, month: 12, day: 10 }, + { month: 6, day: 9 }, // no year: the common case + { year: 1912, month: 6, day: 23 }, + null, + { year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year + { year: 1918, month: 8, day: 26 }, +]; + +export const cards: Obj[] = people.slice(0, 6).map((p, i) => { + const [given, surname] = p[0]!.split(" "); + return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined, + /* + * Birthdays on most but not all of them, and one with no year, because a + * card that records only a day and month is the common case rather than + * the exceptional one. + */ + anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined }; +}); +export const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); +export const fileNodes: Obj[] = [ + { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" }, + { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, + { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, +]; + +/* What the shared account holds. Its own nodes, so opening the share in Files + shows something different from the reader's own folders rather than the same + list under another name. */ +export const sharedFileNodes: Obj[] = [ + { id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, + { id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, +]; +/** The node list an account owns. */ +export const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes); + +export function fr() { + return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; +} + +export function recount() { + for (const m of mailboxes) { + const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]); + m.totalEmails = inBox.length; + m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length; + const threads = new Set(inBox.map((e) => e.threadId)); + m.totalThreads = threads.size; + m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size; + } +} +recount(); + diff --git a/server/src/mock/engine.ts b/server/src/mock/engine.ts new file mode 100644 index 0000000..108e787 --- /dev/null +++ b/server/src/mock/engine.ts @@ -0,0 +1,442 @@ +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): 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, 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:ada@example.org", 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 | 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(); + + /* 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(); + 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(); + 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 | 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), +}); + diff --git a/server/src/mock/events.ts b/server/src/mock/events.ts new file mode 100644 index 0000000..faafa69 --- /dev/null +++ b/server/src/mock/events.ts @@ -0,0 +1,24 @@ +import type { ServerResponse } from "node:http"; +import { ACCOUNT, state } from "./config.js"; + +/* + * The server-sent-events fan-out and the Email/changes ring buffer. + * + * Separate from index.ts because the JMAP handlers raise these events and + * index.ts imports the handlers -- leaving them in index.ts makes that a + * cycle. Separate from data.ts because a live HTTP response is not fixture + * data. + */ +export const sseClients = new Set(); +/** What changed and when, so `Email/changes` can answer honestly. */ +export const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = []; +export function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) { + emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] }); + // A window is plenty; the client refetches from scratch if it falls behind. + if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200); +} + +export function broadcast(types: string[]) { + const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`; + for (const c of sseClients) c.write(payload); +} diff --git a/server/src/mock/handlers.ts b/server/src/mock/handlers.ts new file mode 100644 index 0000000..6702a8e --- /dev/null +++ b/server/src/mock/handlers.ts @@ -0,0 +1,457 @@ +import { checkOtp } from "./auth.js"; +import { emailChanges, recordEmailChange, broadcast } from "./events.js"; +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 { ACCOUNT, MASKED, MAX_DELAYED_SEND, MOCK_LOCALE, NO_FUTURE_RELEASE, Obj, PUSH_TTL_MS, SHARED_ACCOUNT, account, nextState, state } from "./config.js"; +import { NO_KEYWORD_SORT, abRights, blobs, booksFor, calendarsFor, cards, compareBy, emails, eventsFor, fileNodes, fr, identities, mailboxes, mb, nodesFor, participantIdentities, principals, pushSubscriptions, putBlob, recount, rightsCal, seq, sharedCards, sieveScripts, vacationBox } from "./data.js"; +import { Handler, MethodError, applyPatch, calendarEventParse, calendarEventSet, directory, genericGet, genericSet, hideShareWithUnlessAsked, matchFilter, matchSubmissionFilter, pick, resolveEvent, setResp, submissionView, submissions } from "./engine.js"; + +export const handlers: Record = { + // 0.16 exposes the account locale here, under a permission ordinary users + // actually have (unlike x:Account below, which needs sysAccountGet). + "x:AccountSettings/get": (a) => { + const ids = (a.ids as string[] | null) ?? ["singleton"]; + const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null })); + return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") }; + }, + // Stalwart's directory registry: accounts, domains and roles, behind the + // same permissions as the real thing. The locale fallback reads x:Account + // too, and is refused here exactly when a real server would refuse it. + ...directory.handlers, + "Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never, + "Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; }, + "Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), + "Email/query": (a) => { + let list = emails.filter((e) => matchFilter(e, a.filter as Obj)); + /* + * Honor the sort rather than always answering newest-first. This used to + * ignore it entirely, which reproduced a server that silently returns a + * different order from the one asked for -- the one shape of wrongness a + * client cannot detect. + */ + const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }]; + if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) { + // A method-level failure, the way a real server refuses an optional sort: + // the whole call fails rather than the sort being quietly dropped. + throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported."); + } + list.sort((x, y) => { + for (const c of sort) { + const asc = c.isAscending !== false; + const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined); + if (cmp !== 0) return asc ? cmp : -cmp; + } + return 0; + }); + if (a.collapseThreads) { + const seen = new Set(); + list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; }); + } + const pos = Number(a.position ?? 0); + const limit = Number(a.limit ?? 50); + return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit }; + }, + "Email/get": (a) => genericGet(emails)(a), + /* + * Real changes, not an empty answer. + * + * This used to return three empty arrays whatever had happened, so the + * client's whole reconciliation path -- `Email/changes`, then deciding what + * to do with what came back -- never ran against the mock. A bug living in + * that path could not be reproduced here at all, which is how one reached + * production and survived being "fixed" once (#100). The log below is what + * the real server can answer from. + */ + "Email/changes": (a) => { + const since = Number(a.sinceState ?? 0); + const relevant = emailChanges.filter((c) => c.state > since); + const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))]; + return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") }; + }, + "Email/set": (a) => { + const r = genericSet(emails, "e", (o) => { + const bv = (o.bodyValues as Record) ?? {}; + const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); }; + const parts: Obj[] = []; + walk(o.bodyStructure as Obj, parts); + o.textBody = parts.filter((p) => p.type === "text/plain"); + o.htmlBody = parts.filter((p) => p.type === "text/html"); + o.attachments = []; + const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); }; + collect(o.bodyStructure as Obj); + o.hasAttachment = (o.attachments as Obj[]).length > 0; + o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`; + o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + o.size = 2000; + o.preview = (bv.text?.value ?? "").slice(0, 100); + o.messageId = [`${o.id}@mock`]; + o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822"); + })(a); + recount(); + nextState(); + recordEmailChange({ + created: Object.values((r.created ?? {}) as Record).map((x) => x.id), + updated: Object.keys((a.update as Obj) ?? {}), + destroyed: (r.destroyed as string[] | undefined) ?? [], + }); + /* A real server pushes a state change after a set, and the client acts on + it -- `Email/changes` runs and the store reconciles what came back. The + mock stayed silent, so that whole path never ran here and a bug living + in it could not be reproduced: marking a message read went round the + server and back on the live instance, and did nothing at all on the mock + (#100). Announced now, the way Stalwart does. */ + broadcast(["Email", "Mailbox", "Thread"]); + return r; + }, + "Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${seq.counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); }, + "Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; }, + // Stalwart 0.16 registry objects backing self-service credentials. + "x:AccountPassword/get": () => ({ + accountId: ACCOUNT, + state: String(state.n), + list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }], + notFound: [], + }), + "x:AccountPassword/set": (a) => { + const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined; + if (!patch) return setResp({ updated: {} }); + const current = patch.currentSecret as string | undefined; + const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined; + if (!current) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } }); + } + if (current !== account.password) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); + } + if (account.otpUrl && !code) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } }); + } + if (account.otpUrl && !checkOtp(code!)) { + return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); + } + const secret = patch.secret as string | undefined; + if (secret !== undefined && secret !== MASKED) { + if (secret.length < 8) { + return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } }); + } + account.password = secret; + } + if ("otpAuth/otpUrl" in patch) { + const url = patch["otpAuth/otpUrl"] as string | null; + if (url !== MASKED) account.otpUrl = url; + } + state.n++; + return setResp({ updated: { singleton: null } }); + }, + /* + * Push subscriptions. The JMAP half can be modeled; delivery cannot -- that + * runs through the browser vendor's real push service, so nothing local will + * ever make a notification appear. + * + * What is worth reproducing is the handshake, because it is the part that + * fails quietly: a subscription is created unverified and stays silent until + * the client echoes back a code the server pushed. A mock that marked one + * verified on creation would let a client ship without ever implementing + * that, and the symptom in production is "registered, and no notifications". + */ + "PushSubscription/get": (a) => { + const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string); + const list = pushSubscriptions.filter((s) => ids.includes(s.id as string)); + // `keys` is write-only in JMAP: the server never hands it back. + return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) }; + }, + "PushSubscription/set": (a) => { + const created: Obj = {}; + const notCreated: Obj = {}; + const updated: Obj = {}; + const notUpdated: Obj = {}; + const destroyed: string[] = []; + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { + const o = obj as Obj; + const keys = (o.keys ?? {}) as Obj; + // Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push + // API produces; padding it would be the client inventing a shape. + for (const k of ["p256dh", "auth"]) { + const v = String(keys[k] ?? ""); + if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; } + if (v.includes("=") || v.includes("+") || v.includes("/")) { + notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` }; + break; + } + } + if (notCreated[cid]) continue; + if (!String(o.url ?? "").startsWith("https://")) { + notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." }; + continue; + } + // A filter condition with a null value is not a filter -- the real server + // answers "Invalid filter" and refuses the whole subscription. ihasmail + // shipped `inMailbox: null` meaning "the inbox", which meant nothing at + // all here, and the mock accepted it happily. It does not any more. + const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => { + const f = ((cfg as Obj)?.filter ?? {}) as Obj; + return Object.values(f).some((v) => v === null || v === undefined); + }); + if (badFilter) { + notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." }; + continue; + } + // One per device: re-subscribing replaces rather than accumulates. + const deviceId = String(o.deviceClientId ?? ""); + const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId); + if (clash >= 0) pushSubscriptions.splice(clash, 1); + const id = `ps${randomUUID().slice(0, 6)}`; + /* + * A subscription expires, and this used to hand back `expires: null`. + * That is the one shape that makes the client's real problem invisible in + * development: JMAP puts a ceiling of seven days on a push subscription + * and expects the client to re-register before it lapses, so a client + * that never renews works perfectly against a mock that never expires + * anything and goes silent a week after being deployed. Seven days here, + * so "does this client renew?" is a question the mock can answer. + */ + const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString(); + pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` }); + created[cid] = { id, expires }; + state.n++; + } + for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { + const s = pushSubscriptions.find((x) => x.id === id); + if (!s) { notUpdated[id] = { type: "notFound" }; continue; } + const code = (patch as Obj).verificationCode; + if (code !== undefined) { + if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; } + s.verified = true; + } + updated[id] = null; + state.n++; + } + for (const id of (a.destroy as string[]) ?? []) { + const i = pushSubscriptions.findIndex((x) => x.id === id); + if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; } + } + return setResp({ created, notCreated, updated, notUpdated, destroyed }); + }, + "x:AppPassword/get": (a) => genericGet(account.appPasswords)(a), + "x:AppPassword/set": (a) => { + const created: Obj = {}; + const destroyed: string[] = []; + for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { + const id = `ap${randomUUID().slice(0, 6)}`; + // Real app passwords carry their credential id, so the server can spot + // one by its shape alone. Mirror that. + const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`; + const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret }; + account.appPasswords.push(row); + created[cid] = { id, secret, createdAt: row.createdAt }; + } + for (const id of (a.destroy as string[]) ?? []) { + const i = account.appPasswords.findIndex((x) => x.id === id); + if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); } + } + state.n++; + return setResp({ created, destroyed }); + }, + "Identity/get": genericGet(identities), + "Identity/set": (a) => { + // Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of + // UTF-8, not characters. Anything longer is refused by name. + for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) { + for (const [key, obj] of Object.entries(entries)) { + const over = ["htmlSignature", "textSignature"].find((prop) => { + const v = (obj as Obj)[prop]; + return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047; + }); + if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } }); + } + } + return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a); + }, + "EmailSubmission/get": (a) => { + const ids = a.ids as string[] | null | undefined; + const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions; + return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] }; + }, + "EmailSubmission/query": (a) => { + const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined)); + list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt))); + const pos = Number(a.position ?? 0); + const limit = Number(a.limit ?? 50); + return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit }; + }, + "EmailSubmission/set": (a) => { + const created: Obj = {}; + const notCreated: Obj = {}; + const updated: Obj = {}; + const notUpdated: Obj = {}; + for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) { + const sub = raw as Obj; + const emailId = sub.emailId as string; + const e = emails.find((x) => x.id === emailId); + if (!e) { + notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." }; + continue; + } + const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now()); + if (Number.isNaN(hold)) { + notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." }; + continue; + } + // Stalwart rejects MAIL FROM outright past its own limit. + if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) { + notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` }; + continue; + } + // With the MTA extension off, the hold is dropped in silence. + const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now(); + const rec: Obj = { + id: `s${randomUUID().slice(0, 6)}`, + identityId: sub.identityId ?? null, + emailId, + threadId: e.threadId ?? null, + envelope: sub.envelope ?? null, + sendAt: new Date(sendAt).toISOString(), + undoStatus: null, + deliveryStatus: null, + }; + submissions.push(rec); + created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) }; + const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined; + if (patch) applyPatch(e, patch); + } + for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) { + const patch = raw as Obj; + const sub = submissions.find((x) => x.id === id); + if (!sub) { notUpdated[id] = { type: "notFound" }; continue; } + if (patch.undoStatus !== "canceled") { + notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." }; + continue; + } + const status = undoStatusOf(sub, Date.now()); + if (status !== "pending") { + notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." }; + continue; + } + sub.undoStatus = "canceled"; + updated[id] = null; + } + recount(); + return setResp({ + created, + updated, + ...(Object.keys(notCreated).length ? { notCreated } : {}), + ...(Object.keys(notUpdated).length ? { notUpdated } : {}), + }); + }, + "VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacationBox.current], notFound: [] }), + "VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacationBox.current = { ...vacationBox.current, ...p }; return setResp({ updated: { singleton: null } }); }, + "Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }), + "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/validate": () => ({ accountId: ACCOUNT, error: null }), + "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), + /* + * With `expandRecurrences` every id that comes back is synthetic — a one-off + * included, which is what a live 0.16.19 does and what makes `baseEventId` + * useless as a test for a series. Without it (the `findByUid` path) the + * stored ids come back untouched, because callers hand those straight to a + * destroy and mean the whole event. + */ + "CalendarEvent/query": (a) => { + const list = eventsFor(a.accountId); + const filter = (a.filter as Obj) ?? {}; + const matching = list.filter((e) => !filter.uid || e.uid === filter.uid); + if (!a.expandRecurrences) { + return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length }; + } + 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 ids: string[] = []; + 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 }; + }, + "CalendarEvent/get": (a) => { + const list = eventsFor(a.accountId); + const ids = a.ids as string[] | null | undefined; + const properties = a.properties as string[] | null | undefined; + // With no ids every event comes back under its stored id, none synthetic. + if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] }; + const found: Obj[] = []; + const notFound: string[] = []; + for (const id of ids) { + const resolved = resolveEvent(list, id); + if (!resolved) { notFound.push(id); continue; } + found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties)); + } + return { accountId: ACCOUNT, state: String(state.n), list: found, notFound }; + }, + // 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 now does both. + "CalendarEvent/set": (a) => calendarEventSet(a), + "CalendarEvent/parse": (a) => calendarEventParse(a), + "ParticipantIdentity/get": genericGet(participantIdentities), + "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), + "Principal/get": genericGet(principals), + // One busy block a day across whatever range was asked for. It used to answer + // with a single block on the first day whatever the range, which was all an + // availability bar a day wide could show -- and left a bar covering several + // days looking as though everyone were free for all but the first of them. + "Principal/getAvailability": (a) => { + const from = new Date(String(a.utcStart)); + const to = new Date(String(a.utcEnd)); + const list: Obj[] = []; + for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) { + const date = day.toISOString().slice(0, 11); + list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null }); + } + return { accountId: ACCOUNT, list }; + }, + "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a), + "AddressBook/set": (a) => { + /* Stalwart refuses any update to a book shared read-only, `isSubscribed` + included -- "You are not allowed to modify this address book", confirmed + live on 0.16.19 (2026-08-27) from the account holding the share. A mock + that accepted it would have agreed that subscribing works, which is + exactly the belief that shipped. Calendars accept the same write; the + difference is the server's, not ours. */ + if (a.accountId === SHARED_ACCOUNT && a.update) { + const notUpdated: Obj = {}; + for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." }; + return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated }; + } + return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a); + }, + "ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; }, + // An empty `properties` list returns `id` alone, which `pick` already does. + // 0.16.22 made Stalwart agree; through 0.16.21 it returned every property. + "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), + "ContactCard/set": genericSet(cards, "cc"), + "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, + "FileNode/query": (a) => { + const f = (a.filter as Obj) ?? {}; + const fileNodes = nodesFor(a.accountId); + // `nodeType` is a filter 0.16.19 really applies -- checked live on + // 2026-08-27, where it returned the two directories out of seven nodes. The + // mock ignoring it was worse than not having it: the sidebar tree asks for + // directories and was handed files, which it then drew as folders. + const list = fileNodes.filter((n) => { + if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false; + if (f.nodeType && n.nodeType !== f.nodeType) return false; + return true; + }); + return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; + }, + "FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a), + "FileNode/set": (a) => { + return genericSet(nodesFor(a.accountId), "f", (o) => { + Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); + // Without nodeType, a node is a directory precisely when it carries no + // file properties. Keep it internally so query and get stay consistent. + if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory"; + })(a); + }, +}; + diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 5b89f97..168bb9d 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -4,1362 +4,21 @@ * server at it with STALWART_URL=http://127.0.0.1:8788 (user: demo / pass: demo). */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { randomUUID } from "node:crypto"; -import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js"; -import { eventGetView, expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js"; import { parseOtpauthUrl, verifyTotp } from "../totp.js"; -import { holdUntilOf, undoStatusOf } from "./futurerelease.js"; -import { createDirectory, mockRole } from "./directory.js"; -import { readFileSync } from "node:fs"; import { gzipSync } from "node:zlib"; - -const PERMISSION_SNAPSHOT = (JSON.parse(readFileSync(new URL("../../../web/src/locales/permissions/source.json", import.meta.url), "utf8")) as { permissions: Array<{ name: string; label: string }> }).permissions; - -const PORT = Number(process.env.MOCK_PORT ?? 8788); -/** - * Omit `urn:stalwart:jmap` from the session, so a sign-in can be tested - * against a server ihasmail does not support. This is only that: the rest of - * the mock still behaves like 0.16. Emulating 0.15 properly went with the - * support for it. - */ -const NO_REGISTRY = process.env.MOCK_NO_REGISTRY === "1"; -/** - * Stalwart advertises FUTURERELEASE in the session but only honors it when - * the MTA's own `futureRelease` setting is on -- and that setting defaults to - * off, in which case the hold is dropped without a word and the message goes - * out at once. Set MOCK_NO_FUTURE_RELEASE=1 to reproduce that trap. - */ -const NO_FUTURE_RELEASE = process.env.MOCK_NO_FUTURE_RELEASE === "1"; -/** What the session advertises, matching Stalwart's own 30 days. */ -const MAX_DELAYED_SEND = 86400 * 30; -const ACCOUNT = "a1"; -/** How long a push subscription lives before the server drops it. */ -const PUSH_TTL_MS = 7 * 24 * 60 * 60 * 1000; -/** An account somebody has shared with the demo user. See the session below. */ -const SHARED_ACCOUNT = "a2"; -const SHARED_CAPS: Obj = { - "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, - "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, - "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {}, -}; -const USER = process.env.MOCK_USER ?? "demo@example.com"; -/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ -const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; -/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */ -const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss"; -const PASS = process.env.MOCK_PASS ?? "demo"; -/** - * Credential state, mutable so the self-service flows can be exercised against - * the mock the way they run against a real 0.16 server: the password changes, - * 2FA starts demanding a code on every request, and app passwords keep working - * without one. - */ -export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] }; -const MASKED = "[********]"; - -type Obj = Record; -const state = { n: 1 }; -const nextState = () => String(state.n++); - -/* ---------- data ---------- */ -/* - * The names are Stalwart's own defaults, which follow the Exchange convention: - * "Deleted Items" and "Sent Items", not "Trash" and "Sent". The mock used the - * short forms, so anything built from a folder's name read differently here - * than in production -- "Empty Trash" against the mock, "Empty Deleted Items" - * against a real server -- and every screenshot in the README showed a folder - * list no user has. The role is what the client branches on; the name is only - * ever displayed, which is exactly why it has to look right. - */ -/** Push subscriptions, as a fresh account has none. */ -const pushSubscriptions: Obj[] = []; - -const mailboxes: Obj[] = [ - mb("inbox", "Inbox", "inbox"), - mb("drafts", "Drafts", "drafts"), - mb("sent", "Sent Items", "sent"), - mb("junk", "Junk Mail", "junk"), - mb("trash", "Deleted Items", "trash"), - mb("archive", "Archive", "archive"), - mb("work", "Work", null), - mb("work-inv", "Invoices", null, "work"), - mb("news", "Newsletters", null), -]; -function mb(id: string, name: string, role: string | null, parentId: string | null = null): Obj { - return { id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true } }; -} - -const blobs = new Map(); -function putBlob(data: Buffer | string, type: string): string { - const id = `b${randomUUID().slice(0, 8)}`; - blobs.set(id, { type, data: Buffer.isBuffer(data) ? data : Buffer.from(data) }); - return id; -} - -const people = [ - ["Ada Lovelace", "ada@example.org"], ["Grace Hopper", "grace@example.org"], ["Linus Torvalds", "linus@kernel.example"], - ["Margaret Hamilton", "margaret@nasa.example"], ["Alan Turing", "alan@bletchley.example"], ["GitHub", "noreply@github.example"], - ["Stalwart Labs", "hello@stalw.art"], ["Weekly Digest", "digest@newsletter.example"], ["Finance Team", "finance@example.org"], -]; -const subjects = [ - "Re: Q3 planning document", "Your invoice #4821 is ready", "Welcome to Stalwart!", "Lunch on Thursday?", "[PR] Fix push reconnect backoff", - "Weekly digest: 12 new articles", "Photos from the hike", "Deployment window this weekend", "Contract draft v3 attached", "Can you review my slides?", - "Reminder: dentist appointment", "Flight confirmation – BOS → SFO", "Team offsite agenda", "Re: Re: budget approval", "Security notice: new sign-in", -]; -const emails: Obj[] = []; -let counter = 1; -/** - * A real TNEF blob, built to the format description, so the winmail.dat - * decoder has something to open that is not a hand-made fixture in its own - * test file. Two files inside, one of them carrying a long name in the MAPI - * stream behind an 8.3 title -- which is the case the decoder exists for. - */ -function winmailDat(): Buffer { - const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]); - const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]); - const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; }; - const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]); - const asciiProp = (id: number, value: string) => { - const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]); - const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4); - return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]); - }; - const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]); - - const renddata = Buffer.alloc(14); - const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]); - const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1"); - const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1"); - - return Buffer.concat([ - u32(0x223e9f78), u16(0x1234), - attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion - attr(2, 0x00069002, renddata), - attr(2, 0x00018010, title("QUARTE~1.CSV")), - attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])), - attr(2, 0x0006800f, csv), - attr(2, 0x00069002, renddata), - attr(2, 0x00018010, title("notes.txt")), - attr(2, 0x0006800f, notes), - ]); -} - -/** - * A really signed message, served as the raw blob a client verifies against. - * - * The signature is over exact bytes, so this deliberately does not go through - * addEmail: that builds a message out of parts and would hand back a body it - * had assembled rather than the one that was signed. Here the blob *is* the - * fixture, byte for byte, and the JMAP metadata is arranged around it. - * - * `bodyStructure` says multipart/signed because that is what the client checks - * before deciding to download anything -- a mock that omitted it would leave - * the whole path unreachable while every stored byte was still correct. - */ -function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) { - const id = `e${counter++}`; - const raw = signedMessage(o.which); - const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z"); - const body = "The Analytical Engine has no pretensions whatever to originate anything."; - const textBlob = putBlob(body, "text/plain"); - const e: Obj = { - id, - blobId: putBlob(raw, "message/rfc822"), - threadId: `t${id}`, - mailboxIds: { [o.mailbox]: true }, - keywords: o.unread ? {} : { $seen: true }, - size: raw.length, - receivedAt: received, - sentAt: received, - messageId: [`${id}@mock`], - inReplyTo: null, - references: null, - from: [{ name: o.from[0], email: o.from[1] }], - to: [{ name: "Demo User", email: USER }], - cc: null, bcc: null, replyTo: null, sender: null, - subject: o.subject, - hasAttachment: false, - preview: body.slice(0, 120), - textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], - htmlBody: [], - attachments: [], - bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } }, - bodyStructure: { - partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null, - subParts: [ - { partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, - { partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null }, - ], - }, - }; - emails.push(e); - return e; -} - -/* - * A marketing template of the shape #290 was reported against. - * - * Nothing in it is unusual — an outer 600px wrapper on `bgcolor="#ffffff"`, a - * ` -
- - - -

Your order is on its way

-

Thanks for shopping with us. Your parcel left the warehouse this morning.

- -
- Track your parcel -
-

Order #4471 · placed 2 September

-
- You are receiving this because you bought something. Unsubscribe -
-
`; - -function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; styled?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) { - const id = `e${counter++}`; - const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z"); - const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`; - const html = `

Hi,

This is a sample HTML message about “${o.subject}”. It was generated by the ihasmail mock server.

  • Keyboard shortcuts (press ?)
  • Conversation view
  • Drag & drop to folders

logo

Cheers,
${o.from[0]}

On Monday, someone wrote:
This is the quoted part of an earlier message. It should be collapsed by default.
`; - const textBlob = putBlob(text, "text/plain"); - const htmlBlob = putBlob(o.styled ? STYLED_MARKETING_HTML : html, "text/html"); - const attachments: Obj[] = []; - if (o.attach) { - attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null }); - attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null }); - } - if (o.winmail) { - const dat = winmailDat(); - attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null }); - } - if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" }); - const e: Obj = { - id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"), - threadId: o.threadId ?? `t${id}`, mailboxIds: { [o.mailbox]: true }, - keywords: { ...(o.unread ? {} : { $seen: true }), ...(o.flagged ? { $flagged: true } : {}) }, - size: 4000 + Math.floor(Math.random() * 20000), receivedAt: received, sentAt: received, - messageId: [`${id}@mock`], inReplyTo: o.inReplyTo ? [o.inReplyTo] : null, references: o.inReplyTo ? [o.inReplyTo] : null, - from: [{ name: o.from[0], email: o.from[1] }], to: [{ name: "Demo User", email: o.to ?? USER }], cc: null, bcc: null, replyTo: null, sender: null, - subject: o.subject, hasAttachment: Boolean(o.attach), preview: text.slice(0, 120).replace(/\n/g, " "), - textBody: [{ partId: "1", blobId: textBlob, size: text.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }], - htmlBody: o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }] : [], - attachments, - bodyValues: { "1": { value: text, isEncodingProblem: false, isTruncated: false }, ...(o.html ? { "2": { value: o.styled ? STYLED_MARKETING_HTML : html, isEncodingProblem: false, isTruncated: false } } : {}) }, - bodyStructure: { partId: null, blobId: null, size: 0, type: "multipart/mixed", name: null, charset: null, disposition: null, cid: null, subParts: [{ partId: "1", blobId: textBlob, size: text.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null }, ...(o.html ? [{ partId: "2", blobId: htmlBlob, size: (o.styled ? STYLED_MARKETING_HTML : html).length, type: "text/html", name: null, charset: "utf-8", disposition: null, cid: null }] : []), ...attachments] }, - "header:List-Unsubscribe:asText": o.from[1].includes("newsletter") ? ", " : null, - "header:X-Priority:asText": o.subject.startsWith("Security") ? "1 (Highest)" : null, - // Stalwart's spam filter writes the SpamAssassin-shaped set at delivery, so - // delivered mail carries it and mail this account wrote does not. - "header:X-Spam-Status:asText": - o.mailbox === "junk" - ? "Yes, score=14.2 required=5.0 tests=[BAYES_99=3.5, URIBL_BLOCKED=2.7, HTML_IMAGE_ONLY=1.4, SUBJ_ALL_CAPS=1.2, FROM_FREEMAIL=0.4] autolearn=no" - : o.mailbox === "inbox" - ? "No, score=-1.8 required=5.0 tests=[BAYES_00=-1.9, DKIM_VALID=-0.7, SPF_PASS=-0.1, HTML_MESSAGE=0.9]" - : null, - }; - emails.push(e); - return e; -} -// Seed -for (let i = 0; i < 45; i++) { - const p = people[i % people.length]!; - const subj = subjects[i % subjects.length]!; - const e = addEmail({ from: [p[0]!, p[1]!], subject: subj, daysAgo: i * 0.7, mailbox: i % 9 === 8 ? "news" : i % 11 === 10 ? "work" : "inbox", unread: i % 3 === 0, flagged: i % 7 === 0, html: i % 2 === 0, attach: i % 5 === 0 }); - if (i % 4 === 0) { - // thread replies - addEmail({ from: ["Demo User", USER], to: p[1]!, subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.2, mailbox: "sent", threadId: e.threadId as string, inReplyTo: `${e.id}@mock`, html: true }); - addEmail({ from: [p[0]!, p[1]!], subject: `Re: ${subj}`, daysAgo: i * 0.7 - 0.4, mailbox: "inbox", threadId: e.threadId as string, unread: i % 8 === 0, inReplyTo: `${e.id}@mock`, html: i % 3 === 0 }); - } -} -addEmail({ from: ["Shop Updates", "orders@example.com"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true }); -addEmail({ from: ["Demo User", USER], to: "ada@example.org", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true }; - -/* - * Three signed messages, so every branch of the signature banner can be seen - * without staging a certificate authority. Read "A note" first: that pins Ada's - * certificate, after which the other two have something to disagree with. - */ -addSignedEmail({ which: "good", from: ["Ada Lovelace", "ada@example.com"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true }); -addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "ada@example.com"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true }); -addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "ada@example.com"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true }); -addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true }); -addEmail({ from: ["Outlook User", "sales@partner.example"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true }); -addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true }); -addEmail({ from: ["Finance Team", "finance@example.org"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true }); -// A thread whose unread message is not the last one: someone's server queued -// their reply for hours, so it landed after messages that answer it and sits in -// the middle of the conversation. Opening this thread at the newest message -// left that reply above the fold until the mark-read timer swept it (#87). -{ - const subj = "Compiler timings for the release"; - const t = addEmail({ from: ["Grace Hopper", "grace@example.org"], subject: subj, daysAgo: 6, mailbox: "inbox", html: true }); - const tid = t.threadId as string; - const reply = (o: { from: [string, string]; daysAgo: number; mailbox: string; to?: string; unread?: boolean; html?: boolean }) => - addEmail({ ...o, subject: `Re: ${subj}`, threadId: tid, inReplyTo: `${t.id}@mock` }); - reply({ from: ["Alan Turing", "alan@example.org"], daysAgo: 5.5, mailbox: "inbox", unread: true }); - // Long enough after the unread one that the thread scrolls: opening at the - // bottom put four messages between the reader and the mail they had not read. - reply({ from: ["Demo User", USER], to: "grace@example.org", daysAgo: 5, mailbox: "sent", html: true }); - reply({ from: ["Grace Hopper", "grace@example.org"], daysAgo: 4.5, mailbox: "inbox" }); - reply({ from: ["Margaret Hamilton", "margaret@example.org"], daysAgo: 4, mailbox: "inbox", html: true }); - reply({ from: ["Demo User", USER], to: "margaret@example.org", daysAgo: 3.5, mailbox: "sent" }); - reply({ from: ["Grace Hopper", "grace@example.org"], daysAgo: 3, mailbox: "inbox", html: true }); -} -// Invitation email -{ - const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//mock//EN\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:inv-1@mock\r\nDTSTAMP:20260820T100000Z\r\nDTSTART:20260825T140000Z\r\nDTEND:20260825T150000Z\r\nSUMMARY:Project kickoff\r\nORGANIZER;CN=Ada Lovelace:mailto:ada@example.org\r\nATTENDEE;CN=Demo User;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:${USER}\r\nLOCATION:Room 4B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; - const e = addEmail({ from: ["Ada Lovelace", "ada@example.org"], subject: "Invitation: Project kickoff", daysAgo: 0.3, mailbox: "inbox", unread: true }); - const b = putBlob(ics, "text/calendar"); - (e.bodyStructure as Obj).subParts = [...((e.bodyStructure as Obj).subParts as Obj[]), { partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }]; - (e.attachments as Obj[]).push({ partId: "9", blobId: b, size: ics.length, type: "text/calendar", name: "invite.ics", charset: "utf-8", disposition: "attachment", cid: null }); - e.hasAttachment = true; -} - -const identities: Obj[] = [ - { id: "i1", name: "Demo User", email: USER, replyTo: null, bcc: null, textSignature: "-- \nDemo User\nihasmail", htmlSignature: "
--
Demo User
ihasmail
", mayDelete: false }, - { id: "i2", name: "Demo (alias)", email: "alias@example.com", replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true }, -]; -let vacation: Obj = { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }; -const sieveScripts: Obj[] = []; -/* A calendar in the shared account, so "Shared with me" and a colleague's - events appearing in the grid can be exercised. Read-only, as a share is. */ -const sharedCalendars: Obj[] = [{ id: "c9", name: "Grace — Work", description: null, color: "#c084fc", sortOrder: 0, isSubscribed: false, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: {}, myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false } }]; -const sharedEvents: Obj[] = []; -const eventsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedEvents : events); -const calendarsFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedCalendars : calendars); -const calendars: Obj[] = [{ id: "c1", name: "Personal", description: null, color: "#0f766e", sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }, { id: "c2", name: "Work", description: null, color: "#2563eb", sortOrder: 1, isSubscribed: true, isVisible: true, isDefault: false, includeInAvailability: "all", defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: "UTC", shareWith: null, myRights: rightsCal() }]; -function rightsCal() { return { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true }; } -const events: Obj[] = []; -{ - const now = new Date(); - const d = (dayOff: number, h: number) => { const x = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOff, h, 0, 0); return x; }; - const local = (x: Date) => `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}T${String(x.getHours()).padStart(2, "0")}:00:00`; - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); - events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:ada@example.org", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` }); - events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); - /* - * One event in a zone that is not the reader's, because every other fixture - * here uses the machine's own and so cannot tell a correct conversion from - * no conversion at all. Dragging this one is what proves a move keeps the - * time the event says it happens at. - */ - events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" }); - events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); - // Two in the shared account, so a colleague's calendar has something in it. - sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); - sharedEvents.push({ id: "sv2", calendarIds: { c9: true }, "@type": "Event", uid: "sv2", title: "Grace: on leave", start: local(d(4, 0)).slice(0, 10) + "T00:00:00", duration: "P1D", showWithoutTime: true, timeZone: null }); -} -const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }]; -const abRights = (write = true) => ({ mayRead: true, mayWrite: write, mayShare: write, mayDelete: write }); -const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: {}, myRights: abRights() }]; -/* A book in the shared account, so "Shared with me" and addressing a message - from somebody else's contacts can be exercised at all. Read-only, which is - what a share usually is. */ -const sharedAddressBooks: Obj[] = [{ id: "ab9", name: "Team contacts", description: null, sortOrder: 0, isDefault: true, isSubscribed: false, shareWith: {}, myRights: abRights(false) }]; -const sharedCards: Obj[] = [ - { id: "sc1", addressBookIds: { ab9: true }, name: { full: "Katherine Johnson" }, emails: { e1: { address: "katherine@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, - { id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "dorothy@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, -]; -/** - * One sort property, as Email/query defines them. `hasKeyword` sorts a - * boolean, and false comes before true -- which is what makes "unread first" - * an *ascending* sort on $seen. - */ -function compareBy(x: Obj, y: Obj, property: string, keyword?: string): number { - const addr = (v: unknown) => String(((v as Obj[] | undefined)?.[0] as Obj | undefined)?.email ?? ""); - switch (property) { - case "receivedAt": return String(x.receivedAt).localeCompare(String(y.receivedAt)); - case "sentAt": return String(x.sentAt ?? x.receivedAt).localeCompare(String(y.sentAt ?? y.receivedAt)); - case "size": return Number(x.size ?? 0) - Number(y.size ?? 0); - case "subject": return String(x.subject ?? "").localeCompare(String(y.subject ?? "")); - case "from": return addr(x.from).localeCompare(addr(y.from)); - case "to": return addr(x.to).localeCompare(addr(y.to)); - case "hasKeyword": { - const has = (e: Obj) => (keyword && (e.keywords as Obj | undefined)?.[keyword] ? 1 : 0); - return has(x) - has(y); - } - default: return 0; - } -} - -/** 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"; - -/** 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); -/** One per contact, by index; a gap means that card has no birthday. */ -const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [ - { year: 1815, month: 12, day: 10 }, - { month: 6, day: 9 }, // no year: the common case - { year: 1912, month: 6, day: 23 }, - null, - { year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year - { year: 1918, month: 8, day: 26 }, -]; - -const cards: Obj[] = people.slice(0, 6).map((p, i) => { - const [given, surname] = p[0]!.split(" "); - return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined, - /* - * Birthdays on most but not all of them, and one with no year, because a - * card that records only a day and month is the common case rather than - * the exceptional one. - */ - anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined }; -}); -const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); -const fileNodes: Obj[] = [ - { id: "f1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Documents", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, role: "documents" }, - { id: "f2", parentId: "f1", nodeType: "file", blobId: putBlob("hello world", "text/plain"), size: 11, name: "notes.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, - { id: "f3", parentId: null, nodeType: "file", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 14, name: "report.pdf", type: "application/pdf", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, -]; - -/* What the shared account holds. Its own nodes, so opening the share in Files - shows something different from the reader's own folders rather than the same - list under another name. */ -const sharedFileNodes: Obj[] = [ - { id: "s1", parentId: null, nodeType: "directory", blobId: null, size: null, name: "Team plans", type: null, created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, - { id: "s2", parentId: "s1", nodeType: "file", blobId: putBlob("shared notes", "text/plain"), size: 12, name: "roadmap.txt", type: "text/plain", created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {} }, -]; -/** The node list an account owns. */ -const nodesFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedFileNodes : fileNodes); - -function fr() { - return { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true }; -} - -function recount() { - for (const m of mailboxes) { - const inBox = emails.filter((e) => (e.mailboxIds as Obj)[m.id as string]); - m.totalEmails = inBox.length; - m.unreadEmails = inBox.filter((e) => !(e.keywords as Obj).$seen).length; - const threads = new Set(inBox.map((e) => e.threadId)); - m.totalThreads = threads.size; - m.unreadThreads = new Set(inBox.filter((e) => !(e.keywords as Obj).$seen).map((e) => e.threadId)).size; - } -} -recount(); - -/* ---------- helpers ---------- */ -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; -} -function resolveRefs(args: Obj, responses: [string, Obj, string][], creations: Record): 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. - */ -function resolveCreationIds(value: unknown, creations: Record, 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; -} -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; -} -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; -} -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 ---------- */ -type Handler = (args: Obj) => Obj | [string, Obj][]; -/** A method-level failure, surfaced as ["error", {type, description}, id]. */ -class MethodError extends Error { - constructor( - public readonly type: string, - description?: string, - ) { - super(description ?? type); - } -} - -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. - */ -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(); - } -} - -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. - */ -function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } { - if (a.properties) return res; - return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) }; -} - -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. - */ -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. */ -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 } : {}) }; } -} - -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. - */ -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:ada@example.org", 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 }; -} - -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 | 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(); - - /* 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(); - 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(); - 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. - */ -function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) { - const overrides = (base.recurrenceOverrides as Record | 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. - */ -const submissions: Obj[] = []; - -function submissionView(sub: Obj): Obj { - return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) }; -} - -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. */ -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), -}); - -const handlers: Record = { - // 0.16 exposes the account locale here, under a permission ordinary users - // actually have (unlike x:Account below, which needs sysAccountGet). - "x:AccountSettings/get": (a) => { - const ids = (a.ids as string[] | null) ?? ["singleton"]; - const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null })); - return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") }; - }, - // Stalwart's directory registry: accounts, domains and roles, behind the - // same permissions as the real thing. The locale fallback reads x:Account - // too, and is refused here exactly when a real server would refuse it. - ...directory.handlers, - "Mailbox/get": (a) => hideShareWithUnlessAsked(a, genericGet(mailboxes)(a) as { list: Obj[] }) as never, - "Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; }, - "Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }), - "Email/query": (a) => { - let list = emails.filter((e) => matchFilter(e, a.filter as Obj)); - /* - * Honor the sort rather than always answering newest-first. This used to - * ignore it entirely, which reproduced a server that silently returns a - * different order from the one asked for -- the one shape of wrongness a - * client cannot detect. - */ - const sort = (a.sort as Obj[] | undefined) ?? [{ property: "receivedAt", isAscending: false }]; - if (NO_KEYWORD_SORT && sort.some((c) => String(c.property) === "hasKeyword")) { - // A method-level failure, the way a real server refuses an optional sort: - // the whole call fails rather than the sort being quietly dropped. - throw new MethodError("unsupportedSort", "Sorting on hasKeyword is not supported."); - } - list.sort((x, y) => { - for (const c of sort) { - const asc = c.isAscending !== false; - const cmp = compareBy(x, y, String(c.property), c.keyword as string | undefined); - if (cmp !== 0) return asc ? cmp : -cmp; - } - return 0; - }); - if (a.collapseThreads) { - const seen = new Set(); - list = list.filter((e) => { const t = e.threadId as string; if (seen.has(t)) return false; seen.add(t); return true; }); - } - const pos = Number(a.position ?? 0); - const limit = Number(a.limit ?? 50); - return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((e) => e.id), total: list.length, limit }; - }, - "Email/get": (a) => genericGet(emails)(a), - /* - * Real changes, not an empty answer. - * - * This used to return three empty arrays whatever had happened, so the - * client's whole reconciliation path -- `Email/changes`, then deciding what - * to do with what came back -- never ran against the mock. A bug living in - * that path could not be reproduced here at all, which is how one reached - * production and survived being "fixed" once (#100). The log below is what - * the real server can answer from. - */ - "Email/changes": (a) => { - const since = Number(a.sinceState ?? 0); - const relevant = emailChanges.filter((c) => c.state > since); - const pick = (k: "created" | "updated" | "destroyed") => [...new Set(relevant.flatMap((c) => c[k]))]; - return { accountId: ACCOUNT, oldState: String(a.sinceState ?? "1"), newState: String(state.n), hasMoreChanges: false, created: pick("created"), updated: pick("updated"), destroyed: pick("destroyed") }; - }, - "Email/set": (a) => { - const r = genericSet(emails, "e", (o) => { - const bv = (o.bodyValues as Record) ?? {}; - const walk = (p: Obj | undefined, acc: Obj[]) => { if (!p) return; if (p.partId && bv[p.partId as string]) acc.push({ ...p, blobId: putBlob(bv[p.partId as string]!.value, p.type as string), size: bv[p.partId as string]!.value.length }); (p.subParts as Obj[] | undefined)?.forEach((s) => walk(s, acc)); }; - const parts: Obj[] = []; - walk(o.bodyStructure as Obj, parts); - o.textBody = parts.filter((p) => p.type === "text/plain"); - o.htmlBody = parts.filter((p) => p.type === "text/html"); - o.attachments = []; - const collect = (p: Obj | undefined) => { if (!p) return; if (p.blobId && !p.partId && p.type !== "multipart/mixed") (o.attachments as Obj[]).push({ ...p, size: p.size ?? 0 }); (p.subParts as Obj[] | undefined)?.forEach(collect); }; - collect(o.bodyStructure as Obj); - o.hasAttachment = (o.attachments as Obj[]).length > 0; - o.threadId = o.inReplyTo ? (emails.find((e) => (e.messageId as string[] | null)?.[0] === (o.inReplyTo as string[])[0])?.threadId ?? `t${o.id}`) : `t${o.id}`; - o.receivedAt = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - o.size = 2000; - o.preview = (bv.text?.value ?? "").slice(0, 100); - o.messageId = [`${o.id}@mock`]; - o.blobId = putBlob(`Subject: ${o.subject}\r\n\r\n${bv.text?.value ?? ""}`, "message/rfc822"); - })(a); - recount(); - nextState(); - recordEmailChange({ - created: Object.values((r.created ?? {}) as Record).map((x) => x.id), - updated: Object.keys((a.update as Obj) ?? {}), - destroyed: (r.destroyed as string[] | undefined) ?? [], - }); - /* A real server pushes a state change after a set, and the client acts on - it -- `Email/changes` runs and the store reconciles what came back. The - mock stayed silent, so that whole path never ran here and a bug living - in it could not be reproduced: marking a message read went round the - server and back on the live instance, and did nothing at all on the mock - (#100). Announced now, the way Stalwart does. */ - broadcast(["Email", "Mailbox", "Thread"]); - return r; - }, - "Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); }, - "Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; }, - // Stalwart 0.16 registry objects backing self-service credentials. - "x:AccountPassword/get": () => ({ - accountId: ACCOUNT, - state: String(state.n), - list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }], - notFound: [], - }), - "x:AccountPassword/set": (a) => { - const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined; - if (!patch) return setResp({ updated: {} }); - const current = patch.currentSecret as string | undefined; - const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined; - if (!current) { - return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } }); - } - if (current !== account.password) { - return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); - } - if (account.otpUrl && !code) { - return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } }); - } - if (account.otpUrl && !checkOtp(code!)) { - return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } }); - } - const secret = patch.secret as string | undefined; - if (secret !== undefined && secret !== MASKED) { - if (secret.length < 8) { - return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } }); - } - account.password = secret; - } - if ("otpAuth/otpUrl" in patch) { - const url = patch["otpAuth/otpUrl"] as string | null; - if (url !== MASKED) account.otpUrl = url; - } - state.n++; - return setResp({ updated: { singleton: null } }); - }, - /* - * Push subscriptions. The JMAP half can be modeled; delivery cannot -- that - * runs through the browser vendor's real push service, so nothing local will - * ever make a notification appear. - * - * What is worth reproducing is the handshake, because it is the part that - * fails quietly: a subscription is created unverified and stays silent until - * the client echoes back a code the server pushed. A mock that marked one - * verified on creation would let a client ship without ever implementing - * that, and the symptom in production is "registered, and no notifications". - */ - "PushSubscription/get": (a) => { - const ids = (a.ids as string[] | null) ?? pushSubscriptions.map((s) => s.id as string); - const list = pushSubscriptions.filter((s) => ids.includes(s.id as string)); - // `keys` is write-only in JMAP: the server never hands it back. - return { accountId: ACCOUNT, state: String(state.n), list: list.map((s) => { const { keys: _drop, ...rest } = s; return rest; }), notFound: ids.filter((i) => !list.some((s) => s.id === i)) }; - }, - "PushSubscription/set": (a) => { - const created: Obj = {}; - const notCreated: Obj = {}; - const updated: Obj = {}; - const notUpdated: Obj = {}; - const destroyed: string[] = []; - for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { - const o = obj as Obj; - const keys = (o.keys ?? {}) as Obj; - // Stalwart 0.16 was fixed to accept the unpadded base64url the W3C Push - // API produces; padding it would be the client inventing a shape. - for (const k of ["p256dh", "auth"]) { - const v = String(keys[k] ?? ""); - if (!v) { notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `Missing ${k}.` }; break; } - if (v.includes("=") || v.includes("+") || v.includes("/")) { - notCreated[cid] = { type: "invalidProperties", properties: ["keys"], description: `${k} must be unpadded base64url.` }; - break; - } - } - if (notCreated[cid]) continue; - if (!String(o.url ?? "").startsWith("https://")) { - notCreated[cid] = { type: "invalidProperties", properties: ["url"], description: "Push endpoint must be https." }; - continue; - } - // A filter condition with a null value is not a filter -- the real server - // answers "Invalid filter" and refuses the whole subscription. ihasmail - // shipped `inMailbox: null` meaning "the inbox", which meant nothing at - // all here, and the mock accepted it happily. It does not any more. - const badFilter = Object.entries((o.emailPush ?? {}) as Obj).find(([, cfg]) => { - const f = ((cfg as Obj)?.filter ?? {}) as Obj; - return Object.values(f).some((v) => v === null || v === undefined); - }); - if (badFilter) { - notCreated[cid] = { type: "invalidArguments", properties: ["emailPush"], description: "Invalid filter." }; - continue; - } - // One per device: re-subscribing replaces rather than accumulates. - const deviceId = String(o.deviceClientId ?? ""); - const clash = pushSubscriptions.findIndex((s) => s.deviceClientId === deviceId); - if (clash >= 0) pushSubscriptions.splice(clash, 1); - const id = `ps${randomUUID().slice(0, 6)}`; - /* - * A subscription expires, and this used to hand back `expires: null`. - * That is the one shape that makes the client's real problem invisible in - * development: JMAP puts a ceiling of seven days on a push subscription - * and expects the client to re-register before it lapses, so a client - * that never renews works perfectly against a mock that never expires - * anything and goes silent a week after being deployed. Seven days here, - * so "does this client renew?" is a question the mock can answer. - */ - const expires = new Date(Date.now() + PUSH_TTL_MS).toISOString(); - pushSubscriptions.push({ id, deviceClientId: deviceId, url: o.url, types: o.types ?? null, emailPush: o.emailPush ?? null, expires, keys, verified: false, code: `v${randomUUID().slice(0, 8)}` }); - created[cid] = { id, expires }; - state.n++; - } - for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) { - const s = pushSubscriptions.find((x) => x.id === id); - if (!s) { notUpdated[id] = { type: "notFound" }; continue; } - const code = (patch as Obj).verificationCode; - if (code !== undefined) { - if (code !== s.code) { notUpdated[id] = { type: "invalidProperties", properties: ["verificationCode"], description: "Verification code does not match." }; continue; } - s.verified = true; - } - updated[id] = null; - state.n++; - } - for (const id of (a.destroy as string[]) ?? []) { - const i = pushSubscriptions.findIndex((x) => x.id === id); - if (i >= 0) { pushSubscriptions.splice(i, 1); destroyed.push(id); state.n++; } - } - return setResp({ created, notCreated, updated, notUpdated, destroyed }); - }, - "x:AppPassword/get": (a) => genericGet(account.appPasswords)(a), - "x:AppPassword/set": (a) => { - const created: Obj = {}; - const destroyed: string[] = []; - for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) { - const id = `ap${randomUUID().slice(0, 6)}`; - // Real app passwords carry their credential id, so the server can spot - // one by its shape alone. Mirror that. - const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`; - const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret }; - account.appPasswords.push(row); - created[cid] = { id, secret, createdAt: row.createdAt }; - } - for (const id of (a.destroy as string[]) ?? []) { - const i = account.appPasswords.findIndex((x) => x.id === id); - if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); } - } - state.n++; - return setResp({ created, destroyed }); - }, - "Identity/get": genericGet(identities), - "Identity/set": (a) => { - // Stalwart's cap is `value.len() < 2048` on a Rust string: 2047 bytes of - // UTF-8, not characters. Anything longer is refused by name. - for (const [where, entries] of [["notCreated", (a.create as Obj) ?? {}], ["notUpdated", (a.update as Obj) ?? {}]] as const) { - for (const [key, obj] of Object.entries(entries)) { - const over = ["htmlSignature", "textSignature"].find((prop) => { - const v = (obj as Obj)[prop]; - return typeof v === "string" && Buffer.byteLength(v, "utf8") > 2047; - }); - if (over) return setResp({ [where]: { [key]: { type: "invalidProperties", properties: [over], description: "Invalid property." } } }); - } - } - return genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o }))(a); - }, - "EmailSubmission/get": (a) => { - const ids = a.ids as string[] | null | undefined; - const found = ids ? ids.map((id) => submissions.find((x) => x.id === id)).filter(Boolean) as Obj[] : submissions; - return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(submissionView(x), a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !submissions.some((x) => x.id === id)) : [] }; - }, - "EmailSubmission/query": (a) => { - const list = submissions.filter((s) => matchSubmissionFilter(s, a.filter as Obj | undefined)); - list.sort((x, y) => String(x.sendAt).localeCompare(String(y.sendAt))); - const pos = Number(a.position ?? 0); - const limit = Number(a.limit ?? 50); - return { accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: false, position: pos, ids: list.slice(pos, pos + limit).map((s) => s.id), total: list.length, limit }; - }, - "EmailSubmission/set": (a) => { - const created: Obj = {}; - const notCreated: Obj = {}; - const updated: Obj = {}; - const notUpdated: Obj = {}; - for (const [cid, raw] of Object.entries((a.create as Obj) ?? {})) { - const sub = raw as Obj; - const emailId = sub.emailId as string; - const e = emails.find((x) => x.id === emailId); - if (!e) { - notCreated[cid] = { type: "invalidProperties", properties: ["emailId"], description: "Blob for email not found." }; - continue; - } - const hold = holdUntilOf(sub.envelope as Obj | undefined, Date.now()); - if (Number.isNaN(hold)) { - notCreated[cid] = { type: "invalidProperties", properties: ["envelope"], description: "Failed to parse mailFrom parameters." }; - continue; - } - // Stalwart rejects MAIL FROM outright past its own limit. - if (hold !== null && hold > Date.now() + MAX_DELAYED_SEND * 1000) { - notCreated[cid] = { type: "forbiddenMailFrom", description: `Server rejected MAIL-FROM: 501 5.5.4 Requested release time exceeds maximum of ${new Date(Date.now() + MAX_DELAYED_SEND * 1000).toISOString()}.` }; - continue; - } - // With the MTA extension off, the hold is dropped in silence. - const sendAt = hold !== null && !NO_FUTURE_RELEASE ? hold : Date.now(); - const rec: Obj = { - id: `s${randomUUID().slice(0, 6)}`, - identityId: sub.identityId ?? null, - emailId, - threadId: e.threadId ?? null, - envelope: sub.envelope ?? null, - sendAt: new Date(sendAt).toISOString(), - undoStatus: null, - deliveryStatus: null, - }; - submissions.push(rec); - created[cid] = { id: rec.id, sendAt: rec.sendAt, undoStatus: undoStatusOf(rec, Date.now()) }; - const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined; - if (patch) applyPatch(e, patch); - } - for (const [id, raw] of Object.entries((a.update as Obj) ?? {})) { - const patch = raw as Obj; - const sub = submissions.find((x) => x.id === id); - if (!sub) { notUpdated[id] = { type: "notFound" }; continue; } - if (patch.undoStatus !== "canceled") { - notUpdated[id] = { type: "invalidProperties", properties: ["undoStatus"], description: "Only cancellation is supported." }; - continue; - } - const status = undoStatusOf(sub, Date.now()); - if (status !== "pending") { - notUpdated[id] = { type: "cannotUnsend", description: status === "canceled" ? "The message was already canceled." : "The message has already been sent." }; - continue; - } - sub.undoStatus = "canceled"; - updated[id] = null; - } - recount(); - return setResp({ - created, - updated, - ...(Object.keys(notCreated).length ? { notCreated } : {}), - ...(Object.keys(notUpdated).length ? { notUpdated } : {}), - }); - }, - "VacationResponse/get": () => ({ accountId: ACCOUNT, state: "1", list: [vacation], notFound: [] }), - "VacationResponse/set": (a) => { const p = ((a.update as Obj) ?? {}).singleton as Obj | undefined; if (p) vacation = { ...vacation, ...p }; return setResp({ updated: { singleton: null } }); }, - "Quota/get": () => ({ accountId: ACCOUNT, state: "1", list: [{ id: "q1", resourceType: "octets", used: 734003200, hardLimit: 2147483648, scope: "account", name: "Storage", types: ["Email"] }], notFound: [] }), - "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/validate": () => ({ accountId: ACCOUNT, error: null }), - "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), - /* - * With `expandRecurrences` every id that comes back is synthetic — a one-off - * included, which is what a live 0.16.19 does and what makes `baseEventId` - * useless as a test for a series. Without it (the `findByUid` path) the - * stored ids come back untouched, because callers hand those straight to a - * destroy and mean the whole event. - */ - "CalendarEvent/query": (a) => { - const list = eventsFor(a.accountId); - const filter = (a.filter as Obj) ?? {}; - const matching = list.filter((e) => !filter.uid || e.uid === filter.uid); - if (!a.expandRecurrences) { - return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: matching.map((e) => e.id), total: matching.length }; - } - 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 ids: string[] = []; - 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 }; - }, - "CalendarEvent/get": (a) => { - const list = eventsFor(a.accountId); - const ids = a.ids as string[] | null | undefined; - const properties = a.properties as string[] | null | undefined; - // With no ids every event comes back under its stored id, none synthetic. - if (!ids) return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => eventGetView(x, false, properties)), notFound: [] }; - const found: Obj[] = []; - const notFound: string[] = []; - for (const id of ids) { - const resolved = resolveEvent(list, id); - if (!resolved) { notFound.push(id); continue; } - found.push(resolved.occ ? eventGetView(occurrenceView(resolved.base, resolved.occ), true, properties) : eventGetView(resolved.base, false, properties)); - } - return { accountId: ACCOUNT, state: String(state.n), list: found, notFound }; - }, - // 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 now does both. - "CalendarEvent/set": (a) => calendarEventSet(a), - "CalendarEvent/parse": (a) => calendarEventParse(a), - "ParticipantIdentity/get": genericGet(participantIdentities), - "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), - "Principal/get": genericGet(principals), - // One busy block a day across whatever range was asked for. It used to answer - // with a single block on the first day whatever the range, which was all an - // availability bar a day wide could show -- and left a bar covering several - // days looking as though everyone were free for all but the first of them. - "Principal/getAvailability": (a) => { - const from = new Date(String(a.utcStart)); - const to = new Date(String(a.utcEnd)); - const list: Obj[] = []; - for (let day = new Date(from); day < to && list.length < 31; day.setUTCDate(day.getUTCDate() + 1)) { - const date = day.toISOString().slice(0, 11); - list.push({ utcStart: `${date}13:00:00Z`, utcEnd: `${date}14:30:00Z`, busyStatus: "confirmed", event: null }); - } - return { accountId: ACCOUNT, list }; - }, - "AddressBook/get": (a) => genericGet(booksFor(a.accountId))(a), - "AddressBook/set": (a) => { - /* Stalwart refuses any update to a book shared read-only, `isSubscribed` - included -- "You are not allowed to modify this address book", confirmed - live on 0.16.19 (2026-08-27) from the account holding the share. A mock - that accepted it would have agreed that subscribing works, which is - exactly the belief that shipped. Calendars accept the same write; the - difference is the server's, not ours. */ - if (a.accountId === SHARED_ACCOUNT && a.update) { - const notUpdated: Obj = {}; - for (const id of Object.keys(a.update as Obj)) notUpdated[id] = { type: "forbidden", description: "You are not allowed to modify this address book." }; - return { accountId: a.accountId, oldState: String(state.n), newState: String(state.n), updated: null, notUpdated }; - } - return genericSet(booksFor(a.accountId), "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: {}, myRights: abRights(), ...o }))(a); - }, - "ContactCard/query": (a) => { const list = a.accountId === SHARED_ACCOUNT ? sharedCards : cards; return { accountId: a.accountId ?? ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((c) => c.id), total: list.length }; }, - // An empty `properties` list returns `id` alone, which `pick` already does. - // 0.16.22 made Stalwart agree; through 0.16.21 it returned every property. - "ContactCard/get": (a) => genericGet(a.accountId === SHARED_ACCOUNT ? sharedCards : cards)(a), - "ContactCard/set": genericSet(cards, "cc"), - "ContactCard/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const t = blobs.get(b)?.data.toString() ?? ""; const fn = /^FN:(.*)$/m.exec(t)?.[1]?.trim() ?? "Imported"; const em = /^EMAIL[^:]*:(.*)$/m.exec(t)?.[1]?.trim(); parsed[b] = [{ "@type": "Card", version: "1.0", uid: randomUUID(), kind: "individual", name: { full: fn }, emails: em ? { e1: { address: em } } : undefined }]; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, - "FileNode/query": (a) => { - const f = (a.filter as Obj) ?? {}; - const fileNodes = nodesFor(a.accountId); - // `nodeType` is a filter 0.16.19 really applies -- checked live on - // 2026-08-27, where it returned the two directories out of seven nodes. The - // mock ignoring it was worse than not having it: the sidebar tree asks for - // directories and was handed files, which it then drew as folders. - const list = fileNodes.filter((n) => { - if (f.isTopLevel ? n.parentId != null : f.parentId ? n.parentId !== f.parentId : false) return false; - if (f.nodeType && n.nodeType !== f.nodeType) return false; - return true; - }); - return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; - }, - "FileNode/get": (a) => genericGet(nodesFor(a.accountId))(a), - "FileNode/set": (a) => { - return genericSet(nodesFor(a.accountId), "f", (o) => { - Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), shareWith: {}, size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o }); - // Without nodeType, a node is a directory precisely when it carries no - // file properties. Keep it internally so query and get stay consistent. - if (!o.nodeType) o.nodeType = o.blobId || o.size != null || o.type ? "file" : "directory"; - })(a); - }, -}; +import { ACCOUNT, MAX_DELAYED_SEND, MOCK_EDITION, MOCK_LOCALE, NO_REGISTRY, Obj, PASS, PERMISSION_SNAPSHOT, PORT, SHARED_ACCOUNT, SHARED_CAPS, USER, account, nextState, state } from "./config.js"; +import { PING_FLOOR_SECONDS, addEmail, blobs, calendars, people, principals, putBlob, recount } from "./data.js"; +import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from "./engine.js"; +import { handlers } from "./handlers.js"; +export { account } from "./config.js"; +import { checkOtp } from "./auth.js"; +import { sseClients, broadcast } from "./events.js"; /* ---------- http ---------- */ function unauthorized(res: ServerResponse) { res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' }); res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); } -function checkOtp(code: string | undefined): boolean { - if (!account.otpUrl) return true; - const params = parseOtpauthUrl(account.otpUrl); - return Boolean(code && params && verifyTotp(params, code)); -} function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; @@ -1407,19 +66,6 @@ const session = () => ({ state: String(state.n), }); -const sseClients = new Set(); -/** What changed and when, so `Email/changes` can answer honestly. */ -const emailChanges: Array<{ state: number; created: string[]; updated: string[]; destroyed: string[] }> = []; -function recordEmailChange(change: { created?: string[]; updated?: string[]; destroyed?: string[] }) { - emailChanges.push({ state: state.n, created: change.created ?? [], updated: change.updated ?? [], destroyed: change.destroyed ?? [] }); - // A window is plenty; the client refetches from scratch if it falls behind. - if (emailChanges.length > 200) emailChanges.splice(0, emailChanges.length - 200); -} - -function broadcast(types: string[]) { - const payload = `event: state\ndata: ${JSON.stringify({ "@type": "StateChange", changed: { [ACCOUNT]: Object.fromEntries(types.map((t) => [t, String(state.n)])) } })}\n\n`; - for (const c of sseClients) c.write(payload); -} /** Exported so tests can drive the mock in-process and shut it down. */ export const server = createServer(async (req, res) => { @@ -1543,3 +189,4 @@ setInterval(() => { nextState(); broadcast(["Email", "Mailbox", "Thread"]); }, 120_000).unref(); +