Merge pull request #374 from Coffey-Labs/refactor/lib-clusters
Group six more lib clusters, and split the mock server
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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 ?? "[email protected]";
|
||||
/** 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<string, unknown>;
|
||||
export const state = { n: 1 };
|
||||
export const nextState = () => String(state.n++);
|
||||
|
||||
@@ -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<string, { type: string; data: Buffer }>();
|
||||
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", "[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]"],
|
||||
];
|
||||
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
|
||||
* `<style>` block, a colored call to action, a gray footer — and that is the
|
||||
* point. Every one of those is enough to make `htmlDeclaresColors` true, so a
|
||||
* mock without one could not show what "apply the theme to messages too" does
|
||||
* to the mail people actually receive: nothing at all.
|
||||
*/
|
||||
export const STYLED_MARKETING_HTML = `<html><head><style>
|
||||
a { color:#1155CC; text-decoration:underline }
|
||||
.h { font-size:20px; color:#111111 }
|
||||
</style></head><body style="margin:0;background-color:#f4f4f4">
|
||||
<table width="100%" bgcolor="#f4f4f4" cellpadding="0" cellspacing="0"><tr><td align="center">
|
||||
<table width="600" bgcolor="#ffffff" cellpadding="0" cellspacing="0" style="background-color:#ffffff">
|
||||
<tr><td style="padding:24px"><p class="h">Your order is on its way</p>
|
||||
<p style="color:#333333">Thanks for shopping with us. Your parcel left the warehouse this morning.</p>
|
||||
<table cellpadding="0" cellspacing="0"><tr>
|
||||
<td bgcolor="#1155CC" style="border-radius:4px;padding:12px 20px">
|
||||
<a href="https://example.com/track" style="color:#FFFFFF;text-decoration:none">Track your parcel</a>
|
||||
</td></tr></table>
|
||||
<p style="color:#666666;font-size:12px">Order #4471 · placed 2 September</p>
|
||||
</td></tr>
|
||||
<tr><td bgcolor="#222222" style="padding:16px;color:#dddddd;font-size:12px">
|
||||
You are receiving this because you bought something. <a href="https://example.com/x" style="color:#88bbff">Unsubscribe</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table></body></html>`;
|
||||
|
||||
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 = `<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(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") ? "<mailto:[email protected]?subject=unsubscribe>, <https://newsletter.example/unsub>" : 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", "[email protected]"], subject: "Your order is on its way", daysAgo: 0.3, mailbox: "inbox", html: true, styled: true });
|
||||
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 };
|
||||
|
||||
/*
|
||||
* 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", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
|
||||
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: 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 });
|
||||
// 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", "[email protected]"], 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", "[email protected]"], 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: "[email protected]", daysAgo: 5, mailbox: "sent", html: true });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], daysAgo: 4.5, mailbox: "inbox" });
|
||||
reply({ from: ["Margaret Hamilton", "[email protected]"], daysAgo: 4, mailbox: "inbox", html: true });
|
||||
reply({ from: ["Demo User", USER], to: "[email protected]", daysAgo: 3.5, mailbox: "sent" });
|
||||
reply({ from: ["Grace Hopper", "[email protected]"], 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:[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;
|
||||
}
|
||||
|
||||
export 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 },
|
||||
];
|
||||
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:[email protected]", 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: "[email protected]", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() },
|
||||
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", 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();
|
||||
|
||||
@@ -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<string, string>): Obj {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (k.startsWith("#")) {
|
||||
const r = v as { resultOf: string; name: string; path: string };
|
||||
const resp = responses.find((x) => x[2] === r.resultOf && x[0] === r.name);
|
||||
out[k.slice(1)] = resp ? jsonPointer(resp[1], r.path) : [];
|
||||
} else out[k] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creation references (RFC 8620 5.3): a `#creationId` anywhere a real id would
|
||||
* go, pointing at something created earlier in the same request. Sending a
|
||||
* message uses one -- `EmailSubmission/set` names the email as `#m` -- so
|
||||
* without this the mock quietly declines to create any submission at all.
|
||||
*
|
||||
* `onSuccessUpdateEmail` is left alone: its keys are creation ids by design and
|
||||
* the method that receives them resolves them itself.
|
||||
*/
|
||||
export function resolveCreationIds(value: unknown, creations: Record<string, string>, key?: string): unknown {
|
||||
if (key === "onSuccessUpdateEmail") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.startsWith("#") && creations[value.slice(1)] ? creations[value.slice(1)]! : value;
|
||||
}
|
||||
if (Array.isArray(value)) return value.map((v) => resolveCreationIds(v, creations));
|
||||
if (value && typeof value === "object") {
|
||||
const out: Obj = {};
|
||||
for (const [k, v] of Object.entries(value as Obj)) {
|
||||
const nk = k.startsWith("#") && creations[k.slice(1)] ? creations[k.slice(1)]! : k;
|
||||
out[nk] = resolveCreationIds(v, creations, k);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export function jsonPointer(obj: unknown, path: string): unknown {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let cur: unknown = obj;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]!;
|
||||
if (p === "*") {
|
||||
const rest = parts.slice(i + 1).join("/");
|
||||
const arr = (cur as unknown[]).flatMap((x) => { const v = jsonPointer(x, "/" + rest); return Array.isArray(v) ? v : [v]; });
|
||||
return arr;
|
||||
}
|
||||
cur = (cur as Obj)?.[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
export function matchFilter(e: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.operator) {
|
||||
const conds = (f.conditions as Obj[]).map((c) => matchFilter(e, c));
|
||||
return f.operator === "AND" ? conds.every(Boolean) : f.operator === "OR" ? conds.some(Boolean) : !conds.some(Boolean);
|
||||
}
|
||||
const kw = e.keywords as Obj;
|
||||
if (f.inMailbox && !(e.mailboxIds as Obj)[f.inMailbox as string]) return false;
|
||||
if (f.hasKeyword && !kw[f.hasKeyword as string]) return false;
|
||||
if (f.notKeyword && kw[f.notKeyword as string]) return false;
|
||||
if (f.hasAttachment !== undefined && Boolean(e.hasAttachment) !== f.hasAttachment) return false;
|
||||
const hay = `${e.subject} ${JSON.stringify(e.from)} ${JSON.stringify(e.to)} ${e.preview}`.toLowerCase();
|
||||
for (const k of ["text", "subject", "from", "to", "body"]) if (f[k] && !hay.includes(String(f[k]).toLowerCase())) return false;
|
||||
if (f.before && String(e.receivedAt) >= String(f.before)) return false;
|
||||
if (f.after && String(e.receivedAt) < String(f.after)) return false;
|
||||
if (f.minSize && Number(e.size) < Number(f.minSize)) return false;
|
||||
if (f.maxSize && Number(e.size) > Number(f.maxSize)) return false;
|
||||
return true;
|
||||
}
|
||||
export function applyPatch(obj: Obj, patch: Obj) {
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k.includes("/")) {
|
||||
const [root, ...rest] = k.split("/");
|
||||
const key = rest.join("/");
|
||||
const target = (obj[root!] as Obj) ?? {};
|
||||
if (v === null) delete target[key];
|
||||
else target[key] = v;
|
||||
obj[root!] = target;
|
||||
} else obj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- method handlers ---------- */
|
||||
export type Handler = (args: Obj) => Obj | [string, Obj][];
|
||||
/** A method-level failure, surfaced as ["error", {type, description}, id]. */
|
||||
export class MethodError extends Error {
|
||||
constructor(
|
||||
public readonly type: string,
|
||||
description?: string,
|
||||
) {
|
||||
super(description ?? type);
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_OBJECTS = 500;
|
||||
|
||||
/**
|
||||
* Stalwart refuses a whole method call that carries more objects than it will
|
||||
* process at once - it does not quietly handle the first 500. Enforce the same
|
||||
* ceiling the session advertises, so an unbatched client fails here too.
|
||||
*/
|
||||
export function enforceLimits(name: string, args: Obj): void {
|
||||
const tooLarge = () => {
|
||||
throw new MethodError("requestTooLarge", "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call.");
|
||||
};
|
||||
if (name.endsWith("/get")) {
|
||||
const ids = args.ids as unknown[] | null | undefined;
|
||||
if (Array.isArray(ids) && ids.length > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
if (name.endsWith("/set")) {
|
||||
const n =
|
||||
Object.keys((args.create as Obj) ?? {}).length +
|
||||
Object.keys((args.update as Obj) ?? {}).length +
|
||||
((args.destroy as unknown[] | undefined)?.length ?? 0);
|
||||
if (n > MAX_OBJECTS) tooLarge();
|
||||
}
|
||||
}
|
||||
|
||||
export const setResp = (extra: Obj = {}): Obj => ({ accountId: ACCOUNT, oldState: "1", newState: nextState(), created: {}, updated: {}, destroyed: [], ...extra });
|
||||
|
||||
/*
|
||||
* `Mailbox/get` does not return `shareWith` unless a client asks for it by
|
||||
* name: a `/get` with no `properties` comes back without the field at all.
|
||||
* Confirmed on 0.16.19 (2026-08-27) against a mailbox that really was shared.
|
||||
* The mock handing it over unasked meant a client that never asked still saw
|
||||
* every share, and the one place that did not -- the real server -- showed
|
||||
* nothing shared at all.
|
||||
*
|
||||
* Calendars and address books used to behave the same way and no longer do.
|
||||
* 0.16.21 fixed `Calendar/get` and `AddressBook/get` to return every property
|
||||
* when `properties` is omitted or null, `shareWith` included. **Confirmed live
|
||||
* on 0.16.21 (2026-09-06):** both come back with the full set, while
|
||||
* `Mailbox/get` on the same server still omits it — so this stays, and it
|
||||
* stays applied to mailboxes alone.
|
||||
*/
|
||||
export function hideShareWithUnlessAsked(a: Obj, res: { list: Obj[] }): { list: Obj[] } {
|
||||
if (a.properties) return res;
|
||||
return { ...res, list: res.list.map(({ shareWith: _drop, ...rest }) => rest) };
|
||||
}
|
||||
|
||||
export function genericGet(list: Obj[]) {
|
||||
return (a: Obj) => {
|
||||
const ids = a.ids as string[] | null | undefined;
|
||||
const found = ids ? ids.map((id) => list.find((x) => x.id === id)).filter(Boolean) as Obj[] : list;
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: found.map((x) => pick(x, a.properties as string[] | null)), notFound: ids ? ids.filter((id) => !list.some((x) => x.id === id)) : [] };
|
||||
};
|
||||
}
|
||||
/**
|
||||
* An id, as either a stored event or one occurrence of one.
|
||||
*
|
||||
* A synthetic id whose base is gone, or whose date the rule no longer
|
||||
* generates (excluded, or past a `count`), resolves to nothing — `notFound`,
|
||||
* the way the server answers for an occurrence that is not there any more.
|
||||
*/
|
||||
export function resolveEvent(list: Obj[], id: string): { base: Obj; occ?: Occurrence } | null {
|
||||
const direct = list.find((x) => x.id === id);
|
||||
if (direct) return { base: direct };
|
||||
const parsed = parseSyntheticId(id);
|
||||
if (!parsed) return null;
|
||||
const base = list.find((x) => x.id === parsed.baseId);
|
||||
if (!base) return null;
|
||||
const occ = occurrenceAt(base, parsed.recurrenceId);
|
||||
return occ ? { base, occ } : null;
|
||||
}
|
||||
|
||||
/** Thrown from an onCreate hook to refuse a create the way a real server would. */
|
||||
export class SetError extends Error {
|
||||
constructor(readonly type: string, readonly description: string, readonly properties?: string[]) { super(description); }
|
||||
toJSON(): Obj { return { type: this.type, description: this.description, ...(this.properties ? { properties: this.properties } : {}) }; }
|
||||
}
|
||||
|
||||
export function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
return (a: Obj) => {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `${prefix}${randomUUID().slice(0, 6)}`;
|
||||
const o = { ...(obj as Obj), id };
|
||||
try {
|
||||
onCreate?.(o);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetError)) throw err;
|
||||
notCreated[cid] = err.toJSON();
|
||||
continue;
|
||||
}
|
||||
list.push(o);
|
||||
created[cid] = { id };
|
||||
}
|
||||
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
|
||||
const o = list.find((x) => x.id === id);
|
||||
if (o) { applyPatch(o, patch as Obj); updated[id] = null; }
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = list.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { list.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
return setResp({ created, updated, destroyed, ...(Object.keys(notCreated).length ? { notCreated } : {}) });
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- calendar events ---------- */
|
||||
|
||||
/**
|
||||
* `CalendarEvent/set`, including the synthetic-id handling 0.16.20 added.
|
||||
*
|
||||
* An update or destroy aimed at an occurrence does not touch the series: it
|
||||
* writes a `recurrenceOverrides` entry keyed by that date, exactly as Stalwart
|
||||
* does — `{ excluded: true }` for a destroy, the patch merged in for an update.
|
||||
*
|
||||
* The refusals are the point of reproducing this at all:
|
||||
*
|
||||
* - a base event and one of its instances in the same request is refused, both
|
||||
* ids at once, because the server cannot apply them in a defined order;
|
||||
* - the same id twice is "Duplicate event id.";
|
||||
* - the ten event-level properties are refused with `invalidProperties`;
|
||||
* - and the twelve inherited ones are dropped in silence, with the response
|
||||
* still saying the update succeeded. A mock that applied them would let a
|
||||
* client that sends them look correct everywhere except a real server.
|
||||
*/
|
||||
/**
|
||||
* Enough of an iCalendar reader to stand in for Stalwart's.
|
||||
*
|
||||
* It reads per VEVENT rather than across the whole file, because a file is the
|
||||
* case an emailed invitation never was: an export carries a year of them, and a
|
||||
* regex over the whole text would find the first DTSTART and call that the
|
||||
* answer. One event still comes back as a bare object, the shape this returned
|
||||
* when an invitation was all it had to handle.
|
||||
*
|
||||
* The synthetic organizer and attendee only go on events that arrived with a
|
||||
* METHOD. Those are scheduling messages, which is what the invitation fixtures
|
||||
* are; a plain export is not addressed to anyone, and inventing participants
|
||||
* for it would make imported events look like invitations nobody sent.
|
||||
*/
|
||||
export function calendarEventParse(a: Obj) {
|
||||
const parsed: Obj = {};
|
||||
const notParsable: string[] = [];
|
||||
for (const b of a.blobIds as string[]) {
|
||||
const blob = blobs.get(b);
|
||||
if (!blob) { notParsable.push(b); continue; }
|
||||
const text = blob.data.toString();
|
||||
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
|
||||
const method = field(text, "METHOD");
|
||||
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
|
||||
const events = bodies.map((body) => {
|
||||
const g = (k: string) => field(body, k);
|
||||
const ds = g("DTSTART") ?? "20260101T000000Z";
|
||||
const de = g("DTEND") ?? ds;
|
||||
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
|
||||
const start = new Date(`${toLocal(ds)}Z`);
|
||||
const end = new Date(`${toLocal(de)}Z`);
|
||||
return {
|
||||
"@type": "Event",
|
||||
uid: g("UID"),
|
||||
title: g("SUMMARY"),
|
||||
start: toLocal(ds),
|
||||
timeZone: "Etc/UTC",
|
||||
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
|
||||
method,
|
||||
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
|
||||
participants: method
|
||||
? {
|
||||
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
|
||||
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
if (!events.length) { notParsable.push(b); continue; }
|
||||
parsed[b] = events.length === 1 ? events[0] : events;
|
||||
}
|
||||
return { accountId: ACCOUNT, parsed, notParsable };
|
||||
}
|
||||
|
||||
export function calendarEventSet(a: Obj) {
|
||||
const created: Obj = {};
|
||||
const updated: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
const notCreated: Obj = {};
|
||||
const notUpdated: Obj = {};
|
||||
const notDestroyed: Obj = {};
|
||||
|
||||
/*
|
||||
* An account that may not send invitations refuses the whole request the
|
||||
* moment it asks for them, and refuses it per object rather than as a method
|
||||
* error. Confirmed live on 0.16.21 for all three of create, update and
|
||||
* destroy; the same requests with the flag absent or false went through.
|
||||
* The flag alone decides it — the server does not first check whether the
|
||||
* event has anyone to notify.
|
||||
*/
|
||||
if (NO_SCHEDULING_SEND && a.sendSchedulingMessages === true) {
|
||||
const denied = () => new SetError("forbidden", SCHEDULING_FORBIDDEN).toJSON();
|
||||
for (const cid of Object.keys((a.create as Obj) ?? {})) notCreated[cid] = denied();
|
||||
for (const id of Object.keys((a.update as Obj) ?? {})) notUpdated[id] = denied();
|
||||
for (const id of ((a.destroy as string[]) ?? [])) notDestroyed[id] = denied();
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const o: Obj = { ...(obj as Obj), id: `ev${randomUUID().slice(0, 6)}` };
|
||||
// Stalwart 0.16 rejects the RFC 8984 array outright and silently discards
|
||||
// participants addressed the RFC 8984 way. The mock did neither, which is
|
||||
// how #26 and #30 reached a live server unnoticed — so it does both.
|
||||
if (o.recurrenceRules) { notCreated[cid] = new SetError("invalidProperties", "Invalid property.", ["recurrenceRules"]).toJSON(); continue; }
|
||||
const parts = o.participants as Record<string, Obj> | undefined;
|
||||
if (parts && Object.values(parts).some((p) => !p.calendarAddress)) delete o.participants;
|
||||
if (o.replyTo && !o.organizerCalendarAddress) delete o.replyTo;
|
||||
o.uid = o.uid ?? randomUUID();
|
||||
events.push(o);
|
||||
created[cid] = { id: o.id };
|
||||
}
|
||||
|
||||
const updates = Object.entries((a.update as Obj) ?? {});
|
||||
const destroys = ((a.destroy as string[]) ?? []).slice();
|
||||
const seen = new Set<string>();
|
||||
|
||||
/* A base and one of its instances cannot be settled in the same request. */
|
||||
const baseOf = (id: string): string | null => {
|
||||
const r = resolveEvent(events, id);
|
||||
return r ? (r.base.id as string) : null;
|
||||
};
|
||||
const touched = new Map<string, { base: string[]; instance: string[] }>();
|
||||
for (const id of [...updates.map(([id]) => id), ...destroys]) {
|
||||
const b = baseOf(id);
|
||||
if (!b) continue;
|
||||
const entry = touched.get(b) ?? { base: [], instance: [] };
|
||||
(parseSyntheticId(id) ? entry.instance : entry.base).push(id);
|
||||
touched.set(b, entry);
|
||||
}
|
||||
const conflicted = new Set<string>();
|
||||
for (const [, e] of touched) {
|
||||
if (e.base.length && e.instance.length) for (const id of [...e.base, ...e.instance]) conflicted.add(id);
|
||||
}
|
||||
const conflict = () => new SetError("invalidProperties", "A base event and its instances cannot be modified in the same request.", ["id"]).toJSON();
|
||||
|
||||
for (const [id, patch] of updates) {
|
||||
if (conflicted.has(id)) { notUpdated[id] = conflict(); continue; }
|
||||
if (seen.has(id)) { notUpdated[id] = new SetError("invalidProperties", "Duplicate event id.", ["id"]).toJSON(); continue; }
|
||||
seen.add(id);
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notUpdated[id] = { type: "notFound" }; continue; }
|
||||
if (!resolved.occ) { applyPatch(resolved.base, patch as Obj); updated[id] = null; continue; }
|
||||
const { rejected, applied } = splitOccurrencePatch(patch as Obj);
|
||||
if (rejected) { notUpdated[id] = new SetError("invalidProperties", "This property cannot be modified on a single occurrence.", [rejected]).toJSON(); continue; }
|
||||
writeOverride(resolved.base, resolved.occ, applied);
|
||||
updated[id] = null;
|
||||
}
|
||||
|
||||
for (const id of destroys) {
|
||||
if (conflicted.has(id)) { notDestroyed[id] = conflict(); continue; }
|
||||
const resolved = resolveEvent(events, id);
|
||||
if (!resolved) { notDestroyed[id] = { type: "notFound" }; continue; }
|
||||
if (resolved.occ) {
|
||||
// One date off a series, which is an override rather than a deletion.
|
||||
writeOverride(resolved.base, resolved.occ, { excluded: true }, true);
|
||||
destroyed.push(id);
|
||||
continue;
|
||||
}
|
||||
const i = events.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { events.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
|
||||
return setResp({
|
||||
created, updated, destroyed,
|
||||
...(Object.keys(notCreated).length ? { notCreated } : {}),
|
||||
...(Object.keys(notUpdated).length ? { notUpdated } : {}),
|
||||
...(Object.keys(notDestroyed).length ? { notDestroyed } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into the override for one date.
|
||||
*
|
||||
* Stalwart fills `start` and `duration` in when the patch leaves them out, so
|
||||
* an override always carries its own timing; the mock does the same, or a
|
||||
* client could depend on inheriting them and be right only here.
|
||||
*/
|
||||
export function writeOverride(base: Obj, occ: Occurrence, patch: Obj, replace = false) {
|
||||
const overrides = (base.recurrenceOverrides as Record<string, Obj> | undefined) ?? {};
|
||||
const existing = replace ? {} : (overrides[occ.recurrenceId] ?? {});
|
||||
const next: Obj = { ...existing };
|
||||
if (!replace) {
|
||||
if (!("start" in next)) next.start = occ.start;
|
||||
if (!("duration" in next) && base.duration) next.duration = base.duration;
|
||||
}
|
||||
applyPatch(next, patch);
|
||||
overrides[occ.recurrenceId] = next;
|
||||
base.recurrenceOverrides = overrides;
|
||||
}
|
||||
|
||||
/* ---------- submissions ---------- */
|
||||
/**
|
||||
* Held messages, the way Stalwart models them: `sendAt` is derived from the
|
||||
* envelope's FUTURERELEASE parameter rather than set by the client, and
|
||||
* `undoStatus` reports whether the message is still in the queue.
|
||||
*/
|
||||
export const submissions: Obj[] = [];
|
||||
|
||||
export function submissionView(sub: Obj): Obj {
|
||||
return { ...sub, undoStatus: undoStatusOf(sub, Date.now()) };
|
||||
}
|
||||
|
||||
export function matchSubmissionFilter(sub: Obj, f: Obj | undefined): boolean {
|
||||
if (!f) return true;
|
||||
if (f.undoStatus && undoStatusOf(sub, Date.now()) !== f.undoStatus) return false;
|
||||
if (Array.isArray(f.emailIds) && !(f.emailIds as string[]).includes(sub.emailId as string)) return false;
|
||||
if (Array.isArray(f.identityIds) && !(f.identityIds as string[]).includes(sub.identityId as string)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Who the demo user is, for administration. See mock/directory.ts. */
|
||||
export const directory = createDirectory({
|
||||
accountId: ACCOUNT,
|
||||
user: USER,
|
||||
locale: MOCK_LOCALE,
|
||||
role: mockRole(process.env.MOCK_ROLE),
|
||||
metricsOff: process.env.MOCK_METRICS === "off",
|
||||
fail: (type, description) => new MethodError(type, description),
|
||||
});
|
||||
|
||||
@@ -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<ServerResponse>();
|
||||
/** 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);
|
||||
}
|
||||
@@ -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<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 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<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),
|
||||
/*
|
||||
* 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<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();
|
||||
nextState();
|
||||
recordEmailChange({
|
||||
created: Object.values((r.created ?? {}) as Record<string, { id: string }>).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);
|
||||
},
|
||||
};
|
||||
|
||||
+8
-1361
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -16,12 +16,12 @@ import { LoginPage } from "@/views/Login";
|
||||
import { AppShell } from "@/views/AppShell";
|
||||
import { MailView } from "@/views/mail/MailView";
|
||||
import { ComposerDock } from "@/views/compose/ComposerDock";
|
||||
import { setUnreadBadge } from "@/lib/notify";
|
||||
import { publishWorkerFacts } from "@/lib/swFacts";
|
||||
import { setUnreadBadge } from "@/lib/notify/notify";
|
||||
import { publishWorkerFacts } from "@/lib/sw/swFacts";
|
||||
import { PAINTED_FROM_CACHE, useSettings, syncedPart } from "@/store/settings";
|
||||
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsAlreadyLoadedFor, settingsSyncAvailable } from "@/lib/settingsSync";
|
||||
import { loadSettingsPolicy } from "@/lib/settingsPolicy";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
|
||||
import { listenForVerification, renewWebPush } from "@/lib/notify/webpushEnable";
|
||||
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
|
||||
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
@@ -260,7 +260,7 @@ function AuthedApp() {
|
||||
});
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
useEffect(() => {
|
||||
void import("@/lib/notify").then((m) => {
|
||||
void import("@/lib/notify/notify").then((m) => {
|
||||
m.setBaseTitle(appName);
|
||||
setUnreadBadge(inboxUnread);
|
||||
});
|
||||
@@ -284,7 +284,7 @@ function AuthedApp() {
|
||||
// Request notification permission lazily when enabled
|
||||
const notif = useSettings((s) => s.settings.desktopNotifications);
|
||||
useEffect(() => {
|
||||
if (notif) void import("@/lib/notify").then((m) => m.requestNotificationPermission());
|
||||
if (notif) void import("@/lib/notify/notify").then((m) => m.requestNotificationPermission());
|
||||
}, [notif]);
|
||||
|
||||
// Nothing worth painting until the account's settings are in force; see the
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* the joining is Intl's rather than a hardcoded " and ".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeRule as describeSieve } from "../sieve";
|
||||
import { describeRule as describeSieve } from "../sieve/sieve";
|
||||
import { describeRule as describeRecurrence, weekdayOptions } from "../calendar/recurrence";
|
||||
import { setUiLanguageForFormatting } from "../datetime";
|
||||
import { setCatalog } from "../i18n";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasHtmlAlternative } from "../html";
|
||||
import { hasHtmlAlternative } from "../text/html";
|
||||
|
||||
/*
|
||||
* The rule: `htmlBody` is derived, so its presence proves nothing. Only the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { isTextEntry, keyboard } from "@/lib/keyboard";
|
||||
import { isTextEntry, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Shortcuts after a click on a checkbox (#260).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comboOf, keyboard } from "@/lib/keyboard";
|
||||
import { comboOf, keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* A "keydown" that carries no key. Chrome's password autofill dispatches one
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
|
||||
/*
|
||||
* Two-key sequences against the single keys they start with.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||
|
||||
/**
|
||||
* The handoff, from the tab's side. The worker's half cannot be exercised here
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/touch";
|
||||
import { navSwipeThreshold, swipeNavDirection, swipeThreshold, lockAxis } from "@/lib/input/touch";
|
||||
|
||||
describe("navSwipeThreshold", () => {
|
||||
it("asks for more travel than a row swipe does, at every width", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCalendar, type EventDraft } from "@/store/calendar";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { uniqueAddresses } from "../address";
|
||||
import { toLocalDateOnly } from "../dates";
|
||||
import { htmlToText } from "../text";
|
||||
import { htmlToText } from "../text/text";
|
||||
|
||||
/**
|
||||
* How much of a message body is copied into an event description.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* so a node has one shape and there is nothing left to detect.
|
||||
*/
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { descendantIds } from "./folderMove";
|
||||
import { descendantIds } from "./mailbox/folderMove";
|
||||
|
||||
/** Properties to request for a node. */
|
||||
export function fileNodeProps(): string[] {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowClick, type RowClick } from "@/lib/listSelection";
|
||||
import { rowClick, type RowClick } from "@/lib/input/listSelection";
|
||||
|
||||
const IDS = ["a", "b", "c", "d", "e"];
|
||||
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/archiveDate";
|
||||
import { archiveSegments, archivePath, groupByArchivePath } from "@/lib/mailbox/archiveDate";
|
||||
|
||||
/**
|
||||
* The dates below are written as local-time strings on purpose. The segments
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { canEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
||||
import type { MailboxRole } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/labelTree";
|
||||
import { labelTree, visibleLabels, descendantKeywords } from "@/lib/mailbox/labelTree";
|
||||
import type { Label } from "@/store/settings";
|
||||
|
||||
const L = (keyword: string, over: Partial<Label> = {}): Label => ({ keyword, name: keyword, color: "#000", ...over });
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
|
||||
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||
import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
unsubscribeThisDevice,
|
||||
webPushAvailable,
|
||||
type JmapPushSubscription,
|
||||
} from "@/lib/webpush";
|
||||
} from "@/lib/notify/webpush";
|
||||
import { setDeviceTrusted } from "@/lib/storage";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withBase } from "./basePath";
|
||||
import { withBase } from "../basePath";
|
||||
|
||||
let baseTitle = "ihasmail";
|
||||
let faviconCanvas: HTMLCanvasElement | null = null;
|
||||
@@ -6,8 +6,8 @@
|
||||
* permission prompt, none of which exists under a test runner.
|
||||
*/
|
||||
import { CAP } from "@/jmap/client";
|
||||
import { withBase } from "./basePath";
|
||||
import { SW_CACHE_NAME } from "./swCache";
|
||||
import { withBase } from "../basePath";
|
||||
import { SW_CACHE_NAME } from "../sw/swCache";
|
||||
import { isDeviceTrusted } from "@/lib/storage";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useMail } from "@/store/mail";
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
unsubscribeThisDevice,
|
||||
verifySubscription,
|
||||
webPushAvailable,
|
||||
} from "@/lib/webpush";
|
||||
} from "@/lib/notify/webpush";
|
||||
|
||||
let listening = false;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* has to find the payload lying somewhere.
|
||||
*/
|
||||
import { withBase } from "./basePath";
|
||||
import { SW_CACHE_NAME } from "./swCache";
|
||||
import { SW_CACHE_NAME } from "./sw/swCache";
|
||||
|
||||
export interface SharedContent {
|
||||
title: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Sieve below each comment is what the server actually runs.
|
||||
*/
|
||||
|
||||
import { formatList } from "./datetime";
|
||||
import { formatList } from "../datetime";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
|
||||
@@ -7,7 +7,7 @@ import { client, chunk } from "@/jmap/client";
|
||||
import type { Email, GetResponse, Id, QueryResponse } from "@/jmap/types";
|
||||
import { LIST_PROPS, useMail } from "@/store/mail";
|
||||
import type { SieveRule, SieveTest } from "./sieve";
|
||||
import { domainOf } from "./address";
|
||||
import { domainOf } from "../address";
|
||||
|
||||
function headerValues(e: Email, header: string): string[] {
|
||||
const h = header.toLowerCase();
|
||||
@@ -4,7 +4,7 @@
|
||||
* - marker signatures: when still too big, the full HTML lives in Files and the
|
||||
* identity only stores `<!--ihasmail:sig=<blobId>-->` + a plain-text fallback.
|
||||
*/
|
||||
import { escapeHtml, htmlToText } from "./text";
|
||||
import { escapeHtml, htmlToText } from "./text/text";
|
||||
|
||||
/**
|
||||
* Stalwart accepts a signature of `value.len() < 2048` — and that is Rust's
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/staleBuild";
|
||||
import { reloadIfServerRebuilt, makeConnectionWatcher, startBuildWatch } from "@/lib/sw/staleBuild";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
|
||||
function healthReplies(body: unknown, ok = true) {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
|
||||
import { SW_CACHE_NAME } from "@/lib/swCache";
|
||||
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/sw/swFacts";
|
||||
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
|
||||
import { setCatalog } from "@/lib/i18n";
|
||||
import { catalog as de } from "@/locales/de";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APP_VERSION } from "./version";
|
||||
import { withBase } from "./basePath";
|
||||
import { APP_VERSION } from "../version";
|
||||
import { withBase } from "../basePath";
|
||||
import { push, type PushState } from "@/jmap/push";
|
||||
|
||||
/**
|
||||
@@ -16,9 +16,9 @@
|
||||
* was installed, which is the same condition background notifications already
|
||||
* carry — a push subscription has to be renewed from a tab too.
|
||||
*/
|
||||
import { withBase } from "./basePath";
|
||||
import { withBase } from "../basePath";
|
||||
import { SW_CACHE_NAME } from "./swCache";
|
||||
import { t } from "./i18n";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const FACTS_KEY = "/ihasmail-worker-facts";
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* so a template follows the same date order and clock the rest of the app was
|
||||
* told to use.
|
||||
*/
|
||||
import { escapeHtml } from "./text";
|
||||
import { escapeHtml } from "./text/text";
|
||||
import { formatDate, formatClock } from "./datetime";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emlFilename, sanitizeFilename } from "@/lib/emlName";
|
||||
import { emlFilename, sanitizeFilename } from "@/lib/text/emlName";
|
||||
|
||||
describe("emlFilename", () => {
|
||||
it("keeps an ordinary subject, with spaces as underscores", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
|
||||
|
||||
describe("isMarkdown", () => {
|
||||
it("takes the type when there is one", () => {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./styles/app.css";
|
||||
import { App } from "./App";
|
||||
import { startBuildWatch } from "@/lib/staleBuild";
|
||||
import { startBuildWatch } from "@/lib/sw/staleBuild";
|
||||
import { BASE_PATH, withBase } from "@/lib/basePath";
|
||||
|
||||
startBuildWatch();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { newRule, rulesToSieve } from "@/lib/sieve";
|
||||
import { newRule, rulesToSieve } from "@/lib/sieve/sieve";
|
||||
import type { SieveScript } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,8 @@ import { client, setErrorMessage } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
|
||||
import { formatFullDate, uid } from "@/lib/format";
|
||||
import { formatAddress, parseMailto, sameAddress, uniqueAddresses } from "@/lib/address";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
|
||||
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text/text";
|
||||
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
|
||||
import { ensureScheduledMailbox, useScheduled } from "./scheduled";
|
||||
@@ -12,7 +12,7 @@ import { formatScheduleTime, holdUntil } from "@/lib/schedule";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { BASE_PATH } from "@/lib/basePath";
|
||||
import { settings } from "./settings";
|
||||
import { emlFilename } from "@/lib/emlName";
|
||||
import { emlFilename } from "@/lib/text/emlName";
|
||||
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
|
||||
import { shareBody, type SharedContent } from "@/lib/shareTarget";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
|
||||
import { foldersNeeded, type PlannedUpload } from "@/lib/dropUpload";
|
||||
import { foldersNeeded, type PlannedUpload } from "@/lib/input/dropUpload";
|
||||
import { isAppFolder } from "@/lib/appFolder";
|
||||
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "./session";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import type { FolderRef } from "@/lib/sieveFolders";
|
||||
import { groupByArchivePath, archivePath } from "@/lib/archiveDate";
|
||||
import type { FolderRef } from "@/lib/sieve/sieveFolders";
|
||||
import { groupByArchivePath, archivePath } from "@/lib/mailbox/archiveDate";
|
||||
import { isOptionalSort, withoutOptionalSorts } from "@/lib/listSort";
|
||||
import { JmapMethodError, chunk, client, setErrorMessage } from "@/jmap/client";
|
||||
import type {
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
import { toast } from "@/ui/toast";
|
||||
import { settings, useSettings } from "../settings";
|
||||
import { useSession } from "../session";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props";
|
||||
@@ -1148,7 +1148,7 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
|
||||
const emails = await get().getEmails(created);
|
||||
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
|
||||
if (!fresh.length) return;
|
||||
const { showNotification, playNewMailSound } = await import("@/lib/notify");
|
||||
const { showNotification, playNewMailSound } = await import("@/lib/notify/notify");
|
||||
if (s.notificationSound) playNewMailSound();
|
||||
if (s.desktopNotifications) {
|
||||
for (const e of fresh.slice(0, 3)) {
|
||||
@@ -1246,7 +1246,7 @@ async function followFolders(before: FolderRef[]): Promise<void> {
|
||||
else gone.push(ref);
|
||||
}
|
||||
|
||||
const { retargetRules, detachFolders } = await import("@/lib/sieveFolders");
|
||||
const { retargetRules, detachFolders } = await import("@/lib/sieve/sieveFolders");
|
||||
const retargeted = retargetRules(rules, moves);
|
||||
const detached = detachFolders(retargeted.rules, gone);
|
||||
if (!retargeted.changed && !detached.edited.length && !detached.removed.length) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import type { ArchiveGranularity } from "@/lib/mailbox/archiveDate";
|
||||
import type {
|
||||
Comparator,
|
||||
Email,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { push, type PushState } from "@/jmap/push";
|
||||
import { accountForCapability, ownAccountForCapability } from "@/lib/accountRouting";
|
||||
import { setServerLocale } from "@/lib/datetime";
|
||||
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
|
||||
import { reloadIfServerRebuilt } from "@/lib/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/webpush";
|
||||
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
|
||||
import { unsubscribeThisDevice } from "@/lib/notify/webpush";
|
||||
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
|
||||
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SortLevel, SortPreset } from "@/lib/listSort";
|
||||
import { pendingSettingsKeys, queueSettingsPush } from "@/lib/settingsSync";
|
||||
import { policyChanges, policyDefaults, policyEnforced, type PolicyChange } from "@/lib/settingsPolicy";
|
||||
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
import type { SwipeAction } from "@/lib/swipe";
|
||||
import type { SwipeAction } from "@/lib/input/swipe";
|
||||
import { resolveUiLanguage } from "@/lib/languages";
|
||||
import { loadLanguage } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
|
||||
import { rulesToSieve, scriptDamage, sieveToRules, type SieveRule } from "@/lib/sieve";
|
||||
import { rulesToSieve, scriptDamage, sieveToRules, type SieveRule } from "@/lib/sieve/sieve";
|
||||
import { useSession } from "./session";
|
||||
|
||||
export const IHASMAIL_SCRIPT = "ihasmail";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Code2, Download, Eye, Pencil, Printer, Save, Share2, X } from "lucide-r
|
||||
import { confirmDialog, Dialog } from "./dialog";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/text/markdown";
|
||||
import { canShareFiles, shareFile } from "@/lib/share";
|
||||
import { t, tc } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import { Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { DateField } from "@/ui/datefield";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type TenantRoles,
|
||||
} from "@/lib/admin/adminTenants";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { proxiedImageUrl } from "@/lib/html";
|
||||
import { proxiedImageUrl } from "@/lib/text/html";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { can, type RoleDef } from "@/lib/admin/adminAccess";
|
||||
import { describeDirectoryError, listRoles } from "@/lib/admin/adminDirectory";
|
||||
import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/lib/admin/adminTenants";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { proxiedImageUrl } from "@/lib/html";
|
||||
import { proxiedImageUrl } from "@/lib/text/html";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { useSession } from "@/store/session";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
|
||||
@@ -4,11 +4,11 @@ import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-rea
|
||||
import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays} from "@/lib/dates";
|
||||
import { useSwipeNav } from "@/lib/touch";
|
||||
import { useSwipeNav } from "@/lib/input/touch";
|
||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||
import { Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { EventPopover } from "./EventPopover";
|
||||
import { EventEditor, type EditorInit } from "./EventEditor";
|
||||
import type { Anchor } from "@/ui/popover";
|
||||
|
||||
@@ -9,14 +9,14 @@ import { RichEditor, type RichEditorHandle } from "./RichEditor";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { formatSize, formatRelative } from "@/lib/format";
|
||||
import { htmlToText, textToHtml } from "@/lib/text";
|
||||
import { htmlToText, textToHtml } from "@/lib/text/text";
|
||||
import { isValidEmail, uniqueAddresses } from "@/lib/address";
|
||||
import { crossesRecipientThreshold, externalRecipients, internalDomains } from "@/lib/warnings";
|
||||
import { attachmentIcon } from "../mail/MessageView";
|
||||
import { FilePicker } from "./FilePicker";
|
||||
import { RecipientPicker, type Field } from "./RecipientPicker";
|
||||
import { useFiles } from "@/store/files";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { useIsMobile } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ScheduleDialog, ScheduleMenuItems } from "./SchedulePicker";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ClipboardEvent, type ReactNode } from "react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Code, Eraser, Image as ImageIcon, Indent, Italic, Link as LinkIcon, List, ListOrdered, Outdent, Quote, Redo, Smile, Strikethrough, Underline, Undo, Palette, Highlighter, Type } from "lucide-react";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { Popover, useMenu } from "@/ui/popover";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFiles } from "@/store/files";
|
||||
import { useSession } from "@/store/session";
|
||||
import type { FileNode, Id } from "@/jmap/types";
|
||||
import { canDropFileNodes, NODE_MIME, readDraggedIds, isShared } from "@/lib/filenode";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { FileNode, Id } from "@/jmap/types";
|
||||
import { formatSize, formatListDate } from "@/lib/format";
|
||||
import { canDropFileNodes, isShared, NODE_MIME, readDraggedIds } from "@/lib/filenode";
|
||||
import { previewKind } from "@/lib/preview";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply";
|
||||
import { upsertRule, type SieveRule } from "@/lib/sieve";
|
||||
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieve/sieveApply";
|
||||
import { upsertRule, type SieveRule } from "@/lib/sieve/sieve";
|
||||
import { RuleDialog } from "../settings/RuleDialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSettings } from "@/store/settings";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { useIsNarrow } from "@/ui/misc";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
import { MessageList } from "./MessageList";
|
||||
@@ -17,10 +17,10 @@ import { LabelPicker } from "./LabelPicker";
|
||||
import type { Id } from "@/jmap/types";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||
import { isUnknownMailbox } from "@/lib/mailbox/mailboxRoute";
|
||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
||||
import { plural, t as translate, tNode } from "@/lib/i18n";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||
|
||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMail } from "@/store/mail";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
|
||||
|
||||
/**
|
||||
* @param need which right a folder has to grant to be worth offering.
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "re
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { labelTree, visibleLabels } from "@/lib/labelTree";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
||||
import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree";
|
||||
import { isScheduledMailbox } from "@/store/scheduled";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import type { Id, Mailbox } from "@/jmap/types";
|
||||
@@ -14,10 +14,10 @@ import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { MailboxPicker } from "./MailboxPicker";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/folderMove";
|
||||
import { haptic, useTouchRow } from "@/lib/touch";
|
||||
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
|
||||
import { haptic, useTouchRow } from "@/lib/input/touch";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
inbox: <Inbox size={20} />,
|
||||
|
||||
@@ -6,20 +6,20 @@ import { useMail, type ListState } from "@/store/mail";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import type { Email, Id } from "@/jmap/types";
|
||||
import { formatListDate } from "@/lib/format";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/archiveDate";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
|
||||
import { groupByArchivePath, archivePath, type ArchiveGranularity } from "@/lib/mailbox/archiveDate";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
|
||||
import { rowIsOpen } from "@/lib/openMessage";
|
||||
import { displayName, shortName } from "@/lib/address";
|
||||
import { Avatar, Empty, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { rowClick } from "@/lib/listSelection";
|
||||
import { rowClick } from "@/lib/input/listSelection";
|
||||
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
|
||||
import { useCompose } from "@/store/compose";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { startAppointment } from "@/lib/calendar/appointment";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
|
||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
|
||||
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/input/touch";
|
||||
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/input/swipe";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ import { startAppointment } from "@/lib/calendar/appointment";
|
||||
import { client } from "@/jmap/client";
|
||||
import { SignatureBanner } from "./SignatureBanner";
|
||||
import { useSignature } from "@/lib/smime/useSignature";
|
||||
import { emlFilename } from "@/lib/emlName";
|
||||
import { emlFilename } from "@/lib/text/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
import { displayName, domainOf, formatAddress } from "@/lib/address";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/html";
|
||||
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, hasHtmlAlternative, htmlDeclaresColors, markKeptSurfaces, sanitizeEmailHtml } from "@/lib/text/html";
|
||||
import { openableInTab, previewKind } from "@/lib/preview";
|
||||
import { FilePreviewDialog } from "@/ui/filepreview";
|
||||
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text";
|
||||
import { findQuoteStart, htmlToText, textToHtml } from "@/lib/text/text";
|
||||
import { canShare, canShareFiles, shareFile, shareText } from "@/lib/share";
|
||||
import { Avatar } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Spinner, useIsNarrow, useIsTouch } from "@/ui/misc";
|
||||
import { client } from "@/jmap/client";
|
||||
import { LabelPicker } from "./LabelPicker";
|
||||
import { threadScrollTarget } from "@/lib/threadScroll";
|
||||
import { useEdgeBack } from "@/lib/touch";
|
||||
import { useEdgeBack } from "@/lib/input/touch";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
/** How long the opening scroll keeps its place while bodies and images land. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { PALETTES, effectiveMode, type Mode, type PaletteId } from "@/lib/palette";
|
||||
import { Switch, useIsTouch } from "@/ui/misc";
|
||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
|
||||
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/input/swipe";
|
||||
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
|
||||
import { t as translate, tNode } from "@/lib/i18n";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Code, GripVertical, Plus, Trash2, Wand2, Play, AlertTriangle, Power } from "lucide-react";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { describeRule, newRule, reorderRules, rulesToSieve, upsertRule, type SieveRule } from "@/lib/sieve";
|
||||
import { describeRule, newRule, reorderRules, rulesToSieve, upsertRule, type SieveRule } from "@/lib/sieve/sieve";
|
||||
import { RuleDialog } from "./RuleDialog";
|
||||
import { saveAndApply } from "../mail/FilterFromMessage";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatSize } from "@/lib/format";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import type { Mailbox, MailboxRole } from "@/jmap/types";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
|
||||
|
||||
/*
|
||||
* Roles a folder can be given here.
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Dialog, confirmDialog } from "@/ui/dialog";
|
||||
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { parseAddressList, formatAddressList } from "@/lib/address";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { sanitizeEditorHtml } from "@/lib/html";
|
||||
import { htmlToText } from "@/lib/text/text";
|
||||
import { sanitizeEditorHtml } from "@/lib/text/html";
|
||||
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
||||
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings, type LabelVisibility } from "@/store/settings";
|
||||
import { labelTree, descendantKeywords } from "@/lib/labelTree";
|
||||
import { labelTree, descendantKeywords } from "@/lib/mailbox/labelTree";
|
||||
import { useMemo } from "react";
|
||||
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
|
||||
import { promptDialog } from "@/ui/dialog";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify";
|
||||
import { requestNotificationPermission, showNotification, playNewMailSound } from "@/lib/notify/notify";
|
||||
import { useSession } from "@/store/session";
|
||||
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/webpushEnable";
|
||||
import { supportsEmailPush, webPushAvailable } from "@/lib/webpush";
|
||||
import { disableWebPush, enableWebPush, webPushActive } from "@/lib/notify/webpushEnable";
|
||||
import { supportsEmailPush, webPushAvailable } from "@/lib/notify/webpush";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve";
|
||||
import { HEADER_CHOICES, HEADER_OPS, type SieveAction, type SieveRule, type SieveTest } from "@/lib/sieve/sieve";
|
||||
import { Dialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { keyboard } from "@/lib/keyboard";
|
||||
import { keyboard } from "@/lib/input/keyboard";
|
||||
import { Kbd } from "@/ui/misc";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { useSettings, type Template } from "@/store/settings";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { RichEditor } from "../compose/RichEditor";
|
||||
import { htmlToText } from "@/lib/text";
|
||||
import { htmlToText } from "@/lib/text/text";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { PLACEHOLDER_NAMES } from "@/lib/templatePlaceholders";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FiltersSettings } from "../FiltersSettings";
|
||||
import { ConfirmHost } from "@/ui/dialog";
|
||||
import { useSieve } from "@/store/sieve";
|
||||
import { hasUnsavedChanges } from "@/lib/unsavedChanges";
|
||||
import { newRule, rulesToSieve } from "@/lib/sieve";
|
||||
import { newRule, rulesToSieve } from "@/lib/sieve/sieve";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user