/** * A tiny in-memory JMAP server that mimics the subset of Stalwart that ihasmail * uses. For local development and demos only: `npm run mock` then point the * 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"; const PORT = Number(process.env.MOCK_PORT ?? 8788); const ACCOUNT = "a1"; 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"; const PASS = process.env.MOCK_PASS ?? "demo"; type Obj = Record; const state = { n: 1 }; const nextState = () => String(state.n++); /* ---------- data ---------- */ const mailboxes: Obj[] = [ mb("inbox", "Inbox", "inbox"), mb("drafts", "Drafts", "drafts"), mb("sent", "Sent", "sent"), mb("junk", "Junk Mail", "junk"), mb("trash", "Trash", "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; function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: 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.

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(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.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: 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: 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: 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, }; 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: ["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 }; addEmail({ from: ["Spammy", "win@lottery.example"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: 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 }); // 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[] = []; 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", recurrenceRules: [{ "@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", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { attendee: true }, participationStatus: "needs-action", expectReply: true } }, replyTo: { imip: `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 }); 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" }); } const participantIdentities: Obj[] = [{ id: "pi1", name: "Demo User", calendarAddress: `mailto:${USER}`, sendTo: { imip: `mailto:${USER}` }, isDefault: true }]; const addressBooks: Obj[] = [{ id: "ab1", name: "Personal", description: null, sortOrder: 0, isDefault: true, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true } }]; 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 }; }); 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(), 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() }, { 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() }, ]; 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][]): 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] = v; } return out; } 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][]; const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra }); 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)) : [] }; }; } 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 }; onCreate?.(o); 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 } : {}) }); }; } const handlers: Record = { // Stalwart's directory extension - the client reads the account locale from here. "x:Account/get": (a) => { const ids = (a.ids as string[] | null) ?? [ACCOUNT]; const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null })); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) }; }, "Mailbox/get": genericGet(mailboxes), "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)); list.sort((x, y) => String(y.receivedAt).localeCompare(String(x.receivedAt))); 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), "Email/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], 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(); 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)) }; }, "Identity/get": genericGet(identities), "Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })), "EmailSubmission/set": (a) => { const created: Obj = {}; for (const [cid, sub] of Object.entries((a.create as Obj) ?? {})) { const emailId = (sub as Obj).emailId as string; const e = emails.find((x) => x.id === emailId); if (!e) continue; created[cid] = { id: `s${randomUUID().slice(0, 6)}`, sendAt: new Date().toISOString(), undoStatus: "final" }; const patch = ((a.onSuccessUpdateEmail as Obj) ?? {})[`#${cid}`] as Obj | undefined; if (patch) applyPatch(e, patch); } recount(); return setResp({ created }); }, "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": genericGet(calendars), "Calendar/set": genericSet(calendars, "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 })), "CalendarEvent/query": (a) => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: events.filter((e) => !(a.filter as Obj)?.uid || e.uid === (a.filter as Obj).uid).map((e) => e.id), total: events.length }), "CalendarEvent/get": genericGet(events), "CalendarEvent/set": genericSet(events, "ev", (o) => Object.assign(o, { uid: o.uid ?? randomUUID() })), "CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); 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`); parsed[b] = { "@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: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", email: "ada@example.org", sendTo: { imip: "mailto:ada@example.org" }, roles: { owner: true } }, me: { name: "Demo User", email: USER, sendTo: { imip: `mailto:${USER}` }, roles: { attendee: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; }, "ParticipantIdentity/get": genericGet(participantIdentities), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "Principal/get": genericGet(principals), "Principal/getAvailability": (a) => ({ accountId: ACCOUNT, list: [{ utcStart: String(a.utcStart).slice(0, 11) + "13:00:00Z", utcEnd: String(a.utcStart).slice(0, 11) + "14:30:00Z", busyStatus: "confirmed", event: null }] }), "AddressBook/get": genericGet(addressBooks), "AddressBook/set": genericSet(addressBooks, "ab", (o) => Object.assign(o, { description: null, sortOrder: 0, isDefault: false, isSubscribed: true, shareWith: null, myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true }, ...o })), "ContactCard/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: cards.map((c) => c.id), total: cards.length }), "ContactCard/get": genericGet(cards), "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 list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true)); return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length }; }, "FileNode/get": genericGet(fileNodes), "FileNode/set": genericSet(fileNodes, "f", (o) => Object.assign(o, { created: new Date().toISOString(), modified: new Date().toISOString(), myRights: fr(), size: o.blobId ? (blobs.get(o.blobId as string)?.data.length ?? 0) : null, type: o.type ?? null, blobId: o.blobId ?? null, ...o })), }; /* ---------- 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 checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; if (!h.startsWith("Basic ")) return false; const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); return u === USER && p === PASS; } function readBody(req: IncomingMessage): Promise { return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); }); } const session = () => ({ capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} }, accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "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": {} } } }, primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), "urn:stalwart:jmap": ACCOUNT }, username: USER, apiUrl: `http://127.0.0.1:${PORT}/jmap/`, downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`, uploadUrl: `http://127.0.0.1:${PORT}/jmap/upload/{accountId}/`, eventSourceUrl: `http://127.0.0.1:${PORT}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`, state: String(state.n), }); const sseClients = new Set(); 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); } createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`); if (!checkAuth(req)) return unauthorized(res); if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify(session())); } if (url.pathname === "/jmap/" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] }; const responses: [string, Obj, string][] = []; const touched = new Set(); for (const [name, rawArgs, id] of body.methodCalls) { const h = handlers[name]; if (!h) { responses.push(["error", { type: "unknownMethod" }, id]); continue; } try { const args = resolveRefs(rawArgs, responses); const r = h(args); responses.push([name, r as Obj, id]); if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!); } catch (err) { responses.push(["error", { type: "serverFail", description: String(err) }, id]); } } if (touched.size) { nextState(); setTimeout(() => broadcast([...touched, ...(touched.has("Email") ? ["Mailbox", "Thread"] : [])]), 50); } res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ methodResponses: responses, sessionState: "1" })); } if (url.pathname.startsWith("/jmap/upload/") && req.method === "POST") { const data = await readBody(req); const type = req.headers["content-type"] ?? "application/octet-stream"; const blobId = putBlob(data, type); res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ accountId: ACCOUNT, blobId, type, size: data.length })); } if (url.pathname.startsWith("/jmap/download/")) { const [, , , , blobId] = url.pathname.split("/"); const b = blobs.get(blobId ?? ""); if (!b) { res.writeHead(404); return res.end(); } res.writeHead(200, { "content-type": url.searchParams.get("accept") ?? b.type, "content-length": b.data.length }); return res.end(b.data); } if (url.pathname.startsWith("/jmap/eventsource")) { res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); res.write(`event: ping\ndata: {}\n\n`); sseClients.add(res); const t = setInterval(() => res.write(`event: ping\ndata: {}\n\n`), 25000); req.on("close", () => { clearInterval(t); sseClients.delete(res); }); // Simulate a new message every 90s return; } res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); }).listen(PORT, "127.0.0.1", () => { console.log(`[mock-stalwart] listening on http://127.0.0.1:${PORT} (login: ${USER} / ${PASS})`); console.log(`[mock-stalwart] run the app with: STALWART_URL=http://127.0.0.1:${PORT} npm run dev`); }); // Periodically inject a new inbox email to demo push setInterval(() => { const p = people[Math.floor(Math.random() * people.length)]!; addEmail({ from: [p[0]!, p[1]!], subject: `Live update ${new Date().toLocaleTimeString()}`, daysAgo: 0, mailbox: "inbox", unread: true, html: true }); recount(); nextState(); broadcast(["Email", "Mailbox", "Thread"]); }, 120_000).unref();