Both branches turn on where Stalwart advertises a capability, so they meet in the same two files. The mock keeps `urn:stalwart:jmap` out of the session-level capabilities and hands it out per-account, as a real server does, while the submission capability it grew for scheduled send lives per-account beside it; the client keeps both accessors, one asking whether a capability is advertised anywhere and one reading the object itself.
804 lines
54 KiB
TypeScript
804 lines
54 KiB
TypeScript
/**
|
||
* 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";
|
||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||
|
||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||
/**
|
||
* Which Stalwart generation to impersonate. "0.16" (the default) has the
|
||
* registry — the `x:` methods, `nodeType` on FileNode, the finer-grained
|
||
* rights. "0.15" is the older shape, and differs in ways that mostly do not
|
||
* announce themselves: its FileNode/query cannot see directories at all, it
|
||
* refuses a `using` naming a capability it does not know, and self-service
|
||
* credentials live behind a REST endpoint instead.
|
||
*/
|
||
const LEGACY = process.env.MOCK_STALWART === "0.15";
|
||
/**
|
||
* Stalwart advertises FUTURERELEASE in the session but only honours 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";
|
||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||
/** 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";
|
||
/**
|
||
* 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<string, unknown>;
|
||
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<string, { type: string; data: Buffer }>();
|
||
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", "[email protected]"], ["Grace Hopper", "[email protected]"], ["Linus Torvalds", "[email protected]"],
|
||
["Margaret Hamilton", "[email protected]"], ["Alan Turing", "[email protected]"], ["GitHub", "[email protected]"],
|
||
["Stalwart Labs", "[email protected]"], ["Weekly Digest", "[email protected]"], ["Finance Team", "[email protected]"],
|
||
];
|
||
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 = `<html><body style="font-family:Arial"><p>Hi,</p><p>This is a <b>sample HTML message</b> about “${o.subject}”. It was generated by the ihasmail mock server.</p><ul><li>Keyboard shortcuts (press ?)</li><li>Conversation view</li><li><a href="https://stalw.art">Drag & drop</a> to folders</li></ul><p><img src="https://example.com/tracker.gif" width="1" height="1" alt=""> <img src="cid:logo@mock" width="120" alt="logo"></p><p>Cheers,<br>${o.from[0]}</p><div class="gmail_quote">On Monday, someone wrote:<blockquote>This is the quoted part of an earlier message. It should be collapsed by default.</blockquote></div></body></html>`;
|
||
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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : 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: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||
addEmail({ from: ["Finance Team", "[email protected]"], 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:[email protected]\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", "[email protected]"], 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: "<div>-- <br><b>Demo User</b><br>ihasmail</div>", mayDelete: false },
|
||
{ id: "i2", name: "Demo (alias)", email: "[email protected]", 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: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, 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() {
|
||
// 0.16 split what used to be a single mayWrite into four.
|
||
return LEGACY
|
||
? { mayRead: true, mayWrite: true, mayShare: true }
|
||
: { 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<string, string>): Obj {
|
||
const out: Obj = {};
|
||
for (const [k, v] of Object.entries(args)) {
|
||
if (k.startsWith("#")) {
|
||
const r = v as { resultOf: string; name: string; path: string };
|
||
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
||
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
||
} else out[k] = resolveCreationIds(v, creations, k);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
||
* go, pointing at something created earlier in the same request. Sending a
|
||
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
||
* without this the mock quietly declines to create any submission at all.
|
||
*
|
||
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
||
* the method that receives them resolves them itself.
|
||
*/
|
||
function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
||
if (key === "onSuccessUpdateEmail") return value;
|
||
if (typeof value === "string") {
|
||
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
||
}
|
||
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
||
if (value && typeof value === "object") {
|
||
const out: Obj = {};
|
||
for (const [k, v] of Object.entries(value as Obj)) {
|
||
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
||
out[nk] = resolveCreationIds(v, creations, k);
|
||
}
|
||
return out;
|
||
}
|
||
return value;
|
||
}
|
||
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 });
|
||
|
||
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 } : {}) });
|
||
};
|
||
}
|
||
|
||
/* ---------- 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;
|
||
}
|
||
|
||
const handlers: Record<string, Handler> = {
|
||
// 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 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<string>();
|
||
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<string, { value: string }>) ?? {};
|
||
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)) }; },
|
||
// 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 } });
|
||
},
|
||
"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 cancelled." : "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": 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: "[email protected]", sendTo: { imip: "mailto:[email protected]" }, 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) ?? {};
|
||
if (LEGACY) {
|
||
// Sorting is refused outright, and isTopLevel / nodeType are not filters
|
||
// this generation knows.
|
||
if (a.sort) throw new MethodError("unsupportedSort", "Sorting is not supported on FileNode");
|
||
if ("isTopLevel" in f || "nodeType" in f) throw new MethodError("unsupportedFilter", "Unsupported filter");
|
||
}
|
||
let list = fileNodes.filter((n) => (f.isTopLevel ? n.parentId == null : f.parentId ? n.parentId === f.parentId : true));
|
||
// The pre-0.16 query masks its results to non-containers, so a directory
|
||
// never comes back — with nothing to say it was left out.
|
||
if (LEGACY) list = list.filter((n) => n.nodeType !== "directory");
|
||
return { accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: list.map((n) => n.id), total: list.length };
|
||
},
|
||
"FileNode/get": (a) => {
|
||
const res = genericGet(fileNodes)(a);
|
||
// nodeType does not exist before 0.16; the shape is all the client gets.
|
||
if (LEGACY) res.list = (res.list as Obj[]).map((n) => { const { nodeType: _drop, ...rest } = n; return rest; });
|
||
return res;
|
||
},
|
||
"FileNode/set": (a) => {
|
||
if (LEGACY) {
|
||
for (const obj of [...Object.values((a.create as Obj) ?? {}), ...Object.values((a.update as Obj) ?? {})]) {
|
||
if (obj && typeof obj === "object" && "nodeType" in (obj as Obj)) {
|
||
return setResp({ notCreated: Object.fromEntries(Object.keys((a.create as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])), notUpdated: Object.fromEntries(Object.keys((a.update as Obj) ?? {}).map((k) => [k, { type: "invalidProperties", properties: ["nodeType"], description: "Invalid property." }])) });
|
||
}
|
||
}
|
||
}
|
||
return 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 });
|
||
// 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);
|
||
},
|
||
};
|
||
|
||
/* ---------- 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 ?? "";
|
||
if (!h.startsWith("Basic ")) return false;
|
||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||
const sep = raw.indexOf(":");
|
||
if (sep < 0) return false;
|
||
const u = raw.slice(0, sep);
|
||
const p = raw.slice(sep + 1);
|
||
if (u !== USER) return false;
|
||
// App passwords are recognised by shape and skip the second factor, which is
|
||
// exactly what lets a webmail session survive 2FA being switched on.
|
||
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
||
if (!account.otpUrl) return p === account.password;
|
||
const at = p.lastIndexOf("$");
|
||
if (at < 0) return false;
|
||
return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1));
|
||
}
|
||
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||
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: MAX_OBJECTS, maxObjectsInSet: MAX_OBJECTS, 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": {} },
|
||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": { maxDelayedSend: MAX_DELAYED_SEND, submissionExtensions: { FUTURERELEASE: [], SIZE: [], DSN: [], DELIVERYBY: [], "MT-PRIORITY": ["MIXER"], REQUIRETLS: [] } }, "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": {}, ...(LEGACY ? {} : { "urn:stalwart:jmap": {} }) } } },
|
||
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), ...(LEGACY ? {} : { "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<ServerResponse>();
|
||
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) => {
|
||
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()));
|
||
}
|
||
// Before 0.16, self-service credentials are a REST endpoint rather than
|
||
// registry objects: GET reports the state, POST takes a list of actions.
|
||
if (LEGACY && url.pathname === "/api/account/auth") {
|
||
if (req.method === "GET") {
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
return res.end(JSON.stringify({ data: { otpEnabled: Boolean(account.otpUrl), appPasswords: account.appPasswords.map((a) => a.description) } }));
|
||
}
|
||
if (req.method === "POST") {
|
||
const actions = JSON.parse((await readBody(req)).toString()) as { type: string; password?: string; url?: string | null; name?: string }[];
|
||
// Password and OTP changes are only accepted over Basic auth.
|
||
if (actions.some((a) => ["setPassword", "enableOtpAuth", "disableOtpAuth"].includes(a.type)) && !(req.headers.authorization ?? "").startsWith("Basic ")) {
|
||
res.writeHead(400, { "content-type": "application/json" });
|
||
return res.end(JSON.stringify({ error: "unauthorized", details: "Password changes only allowed using Basic auth" }));
|
||
}
|
||
for (const a of actions) {
|
||
if (a.type === "setPassword") account.password = a.password ?? account.password;
|
||
else if (a.type === "enableOtpAuth") account.otpUrl = a.url ?? null;
|
||
else if (a.type === "disableOtpAuth") account.otpUrl = null;
|
||
else if (a.type === "addAppPassword") account.appPasswords.push({ id: `ap${randomUUID().slice(0, 6)}`, description: a.name ?? "App password", secret: a.password ?? "", createdAt: new Date().toISOString(), expiresAt: null });
|
||
else if (a.type === "removeAppPassword") {
|
||
const i = account.appPasswords.findIndex((p) => p.description === a.name);
|
||
if (i >= 0) account.appPasswords.splice(i, 1);
|
||
}
|
||
}
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
return res.end(JSON.stringify({ data: null }));
|
||
}
|
||
}
|
||
|
||
// 0.16's account info endpoint; the only place a server reports its edition.
|
||
if (!LEGACY && url.pathname === "/api/account" && req.method === "GET") {
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||
}
|
||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][]; using?: string[] };
|
||
// A capability the server cannot parse fails the whole request, not the one
|
||
// call that wanted it - which is why an over-eager `using` is so damaging.
|
||
// Stalwart decides this by parsing the urn, not by looking it up in the
|
||
// session, so a capability it hands out per-account is still usable here:
|
||
// `urn:stalwart:jmap` never appears in the session-level capabilities and
|
||
// the registry calls that name it work all the same.
|
||
const known = new Set([...Object.keys(session().capabilities), ...Object.keys(session().accounts[ACCOUNT]?.accountCapabilities ?? {})]);
|
||
const unknown = (body.using ?? []).find((u) => !known.has(u));
|
||
if (unknown) {
|
||
res.writeHead(400, { "content-type": "application/json" });
|
||
return res.end(JSON.stringify({ type: "urn:ietf:params:jmap:error:unknownCapability", status: 400, detail: `Unknown capability: ${JSON.stringify(unknown)}` }));
|
||
}
|
||
const responses: [string, Obj, string][] = [];
|
||
const touched = new Set<string>();
|
||
const creations: Record<string, string> = {};
|
||
for (const [name, rawArgs, id] of body.methodCalls) {
|
||
const h = handlers[name];
|
||
// The registry, and every x: method with it, arrived in 0.16.
|
||
if (!h || (LEGACY && name.startsWith("x:"))) { responses.push(["error", { type: "unknownMethod" }, id]); continue; }
|
||
try {
|
||
const args = resolveRefs(rawArgs, responses, creations);
|
||
enforceLimits(name, args);
|
||
const r = h(args);
|
||
responses.push([name, r as Obj, id]);
|
||
for (const [cid, obj] of Object.entries(((r as Obj).created as Obj) ?? {})) {
|
||
const newId = (obj as Obj)?.id;
|
||
if (typeof newId === "string") creations[cid] = newId;
|
||
}
|
||
if (name.endsWith("/set") || name.endsWith("/import")) touched.add(name.split("/")[0]!);
|
||
} catch (err) {
|
||
if (err instanceof MethodError) responses.push(["error", { type: err.type, description: err.message }, id]);
|
||
else 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] impersonating Stalwart ${LEGACY ? "0.15 (pre-registry)" : "0.16+"}`);
|
||
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();
|