ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { formatAddress, initials, isValidEmail, parseAddressList } from "../address";
describe("address parsing", () => {
it("parses mixed lists", () => {
const list = parseAddressList('Ann Example <[email protected]>, [email protected]; "Smith, John" <[email protected]>');
expect(list).toEqual([
{ name: "Ann Example", email: "[email protected]" },
{ name: null, email: "[email protected]" },
{ name: "Smith, John", email: "[email protected]" },
]);
});
it("formats with quoting when needed", () => {
expect(formatAddress({ name: "Smith, John", email: "[email protected]" })).toBe('"Smith, John" <[email protected]>');
expect(formatAddress({ name: null, email: "[email protected]" })).toBe("[email protected]");
});
it("validates and initials", () => {
expect(isValidEmail("[email protected]")).toBe(true);
expect(isValidEmail("nope")).toBe(false);
expect(initials({ name: "Grace Hopper", email: "" })).toBe("GH");
expect(initials({ name: null, email: "[email protected]" })).toBe("LK");
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { formatDuration, parseDuration, zonedToDate, dateToZonedLocal, monthGrid } from "../dates";
describe("dates", () => {
it("parses and formats ISO durations", () => {
expect(parseDuration("PT1H30M")).toBe(5400);
expect(parseDuration("P1DT2H")).toBe(93600);
expect(parseDuration("-PT15M")).toBe(-900);
expect(formatDuration(5400)).toBe("PT1H30M");
expect(formatDuration(-600)).toBe("-PT10M");
expect(formatDuration(86400)).toBe("P1D");
});
it("converts zoned local times to instants", () => {
const d = zonedToDate("2024-07-01T12:00:00", "America/New_York");
expect(d.toISOString()).toBe("2024-07-01T16:00:00.000Z");
expect(dateToZonedLocal(d, "Europe/Berlin")).toBe("2024-07-01T18:00:00");
});
it("builds a 42-day month grid starting on week start", () => {
const g = monthGrid(new Date(2024, 1, 15), 1);
expect(g).toHaveLength(42);
expect(g[0]!.getDay()).toBe(1);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "../html";
describe("sanitizeEmailHtml", () => {
it("removes scripts and event handlers", () => {
const r = sanitizeEmailHtml('<div onclick="x()">hi<script>alert(1)</script><iframe src="https://evil"></iframe></div>');
expect(r.html).not.toContain("script");
expect(r.html).not.toContain("onclick");
expect(r.html).not.toContain("iframe");
});
it("blocks remote images until allowed and maps cid", () => {
const src = '<img src="https://t.example/p.gif"><img src="cid:logo@x"><div style="background:url(https://t.example/b.png)">x</div>';
const blocked = sanitizeEmailHtml(src, { cidMap: { "logo@x": "/api/blob/a/b/logo.png" } });
expect(blocked.remoteCount).toBe(2);
expect(blocked.html).toContain('data-ihm-blocked="1"');
expect(blocked.html).toContain("/api/blob/a/b/logo.png");
expect(blocked.html).not.toMatch(/src="https:\/\/t\.example/);
expect(blocked.html).not.toContain("url(https://t.example");
const allowed = sanitizeEmailHtml(src, { allowRemote: true, proxyRemote: true });
expect(allowed.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fp.gif");
});
it("forces links to open in new tabs", () => {
const r = sanitizeEmailHtml('<a href="https://x.io">x</a>');
expect(r.html).toContain('target="_blank"');
expect(r.html).toContain("noopener");
});
it("strips javascript: urls", () => {
const r = sanitizeEmailHtml('<a href="javascript:alert(1)">x</a>');
expect(r.html).not.toContain("javascript:");
});
it("editor sanitizer keeps basic formatting", () => {
expect(sanitizeEditorHtml("<b>x</b><script>1</script>")).toBe("<b>x</b>");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { buildFilter, parseQuery } from "../search";
import type { Mailbox } from "@/jmap/types";
const mb = (id: string, name: string, role: Mailbox["role"] = null): Mailbox =>
({ id, name, role, parentId: null, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: {} as Mailbox["myRights"] });
describe("parseQuery", () => {
it("parses gmail-style operators", () => {
const p = parseQuery('from:ada subject:"q3 plan" has:attachment is:unread in:work before:2024-01-02 larger:2M hello world');
expect(p.from).toBe("ada");
expect(p.subject).toBe("q3 plan");
expect(p.hasAttachment).toBe(true);
expect(p.unread).toBe(true);
expect(p.in).toBe("work");
expect(p.before).toMatch(/^2024-01-0[12]T/);
expect(p.larger).toBe(2 * 1024 * 1024);
expect(p.text).toEqual(["hello", "world"]);
});
it("handles labels and negation", () => {
const p = parseQuery("label:work -label:done is:starred");
expect(p.label).toEqual(["work"]);
expect(p.notLabel).toEqual(["done"]);
expect(p.starred).toBe(true);
});
});
describe("buildFilter", () => {
const mailboxes = { inbox: mb("inbox", "Inbox", "inbox"), work: mb("work", "Work") };
it("builds a simple condition", () => {
const f = buildFilter(parseQuery("invoice"), mailboxes, "inbox");
expect(f).toEqual({ text: "invoice", inMailbox: "inbox" });
});
it("resolves in: to a mailbox by name and ANDs keyword conditions", () => {
const f = buildFilter(parseQuery("in:work is:starred label:foo"), mailboxes, "inbox");
expect(f).toEqual({ operator: "AND", conditions: [{ inMailbox: "work" }, { hasKeyword: "$flagged" }, { hasKeyword: "foo" }] });
});
it("maps is:unread to notKeyword $seen", () => {
const f = buildFilter(parseQuery("is:unread"), mailboxes, null);
expect(f).toEqual({ notKeyword: "$seen" });
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { newRule, rulesToSieve, sieveToRules, testToSieve, sieveString } from "../sieve";
describe("sieve codec", () => {
it("escapes strings", () => {
expect(sieveString('a "quoted" \\ value')).toBe('"a \\"quoted\\" \\\\ value"');
});
it("generates tests", () => {
expect(testToSieve({ type: "header", header: "subject", op: "contains", value: "hi" })).toBe('header :contains "subject" "hi"');
expect(testToSieve({ type: "header", header: "x-foo", op: "notexists", value: "" })).toBe('not exists "x-foo"');
expect(testToSieve({ type: "address", header: "from", part: "domain", op: "is", value: "example.com" })).toBe('address :domain :is "from" "example.com"');
expect(testToSieve({ type: "size", op: "over", value: 2048 })).toBe("size :over 2048");
});
it("round-trips rules through a script", () => {
const rules = [
newRule({ id: "r1", name: "Newsletters", tests: [{ type: "header", header: "list-id", op: "exists", value: "" }], actions: [{ type: "fileinto", mailbox: "Newsletters" }, { type: "markread" }, { type: "stop" }] }),
newRule({ id: "r2", name: "Big", enabled: false, join: "anyof", tests: [{ type: "size", op: "over", value: 5_000_000 }], actions: [{ type: "addflag", flag: "big" }] }),
];
const script = rulesToSieve(rules);
expect(script).toContain('require ["fileinto", "imap4flags"];');
expect(script).toContain('if exists "list-id"');
expect(script).toContain('fileinto "Newsletters";');
expect(script).toContain('addflag "\\\\Seen";');
expect(script).toContain("# (disabled) Big");
expect(sieveToRules(script)).toEqual(rules);
});
it("reports hand-written scripts as raw", () => {
expect(sieveToRules('require ["fileinto"];\nif true { keep; }')).toBeNull();
expect(sieveToRules("")).toEqual([]);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { evaluateRule, evaluateTest } from "../sieveApply";
import type { Email } from "@/jmap/types";
import type { SieveRule } from "../sieve";
const email = {
id: "e1", blobId: "b", threadId: "t", mailboxIds: { inbox: true }, keywords: {}, size: 5000, receivedAt: "2026-01-01T00:00:00Z",
from: [{ name: "Ada Lovelace", email: "[email protected]" }], to: [{ name: null, email: "[email protected]" }], subject: "Invoice #42 is ready", preview: "Please find attached",
"header:List-Id:asText": "<dev.lists.example.org>",
} as unknown as Email;
describe("sieve client-side evaluation", () => {
it("evaluates header/address/size/body tests", () => {
expect(evaluateTest(email, { type: "header", header: "from", op: "contains", value: "ada@" })).toBe(true);
expect(evaluateTest(email, { type: "header", header: "subject", op: "matches", value: "invoice*ready" })).toBe(true);
expect(evaluateTest(email, { type: "header", header: "subject", op: "regex", value: "^Invoice #\\d+" })).toBe(true);
expect(evaluateTest(email, { type: "header", header: "list-id", op: "exists", value: "" })).toBe(true);
expect(evaluateTest(email, { type: "header", header: "x-none", op: "notexists", value: "" })).toBe(true);
expect(evaluateTest(email, { type: "address", header: "from", part: "domain", op: "is", value: "example.org" })).toBe(true);
expect(evaluateTest(email, { type: "address", header: "from", part: "localpart", op: "is", value: "ada" })).toBe(true);
expect(evaluateTest(email, { type: "size", op: "over", value: 1000 })).toBe(true);
expect(evaluateTest(email, { type: "size", op: "under", value: 1000 })).toBe(false);
expect(evaluateTest(email, { type: "body", op: "contains", value: "attached" }, "Please find attached the file")).toBe(true);
});
it("combines with allof/anyof", () => {
const base: SieveRule = { id: "r", name: "r", enabled: true, join: "allof", tests: [{ type: "header", header: "from", op: "contains", value: "ada" }, { type: "header", header: "subject", op: "contains", value: "nope" }], actions: [] };
expect(evaluateRule(email, base)).toBe(false);
expect(evaluateRule(email, { ...base, join: "anyof" })).toBe(true);
expect(evaluateRule(email, { ...base, tests: [{ type: "true" }] })).toBe(true);
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildMarkerSignature, compactHtml, markerOf, SIGNATURE_LIMIT } from "../signatureHtml";
describe("signature compaction", () => {
it("strips office cruft and non-essential styles but keeps colours and links", () => {
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Ellis</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
const out = compactHtml(src);
expect(out).not.toContain("mso-");
expect(out).not.toContain("class=");
expect(out).not.toContain("<xml");
expect(out).not.toContain("o:p");
expect(out).toContain("color:#1F4E79");
expect(out).toContain("<b>John Ellis</b>");
expect(out).toContain('href="https://linuxexpert.org"');
expect(out).toContain('width="100"');
expect(out.length).toBeLessThan(src.length / 2);
});
it("builds marker signatures within the limit", () => {
const big = `<div>${"<b>x</b>".repeat(1000)}</div>`;
const m = buildMarkerSignature("blob123", big);
expect(m.htmlSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT);
expect(m.textSignature.length).toBeLessThanOrEqual(SIGNATURE_LIMIT);
expect(markerOf(m.htmlSignature)).toEqual({ blobId: "blob123", type: "text/html" });
expect(markerOf("<div>plain</div>")).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { htmlToText, quoteText, replySubject, textToHtml } from "../text";
describe("text helpers", () => {
it("linkifies and escapes", () => {
const html = textToHtml("see <https://x.io/a?b=1> now");
expect(html).toContain("&lt;");
expect(html).toContain('<a href="https://x.io/a?b=1"');
});
it("colors quote levels", () => {
expect(textToHtml("> hi\n>> there")).toContain('class="q1"');
expect(textToHtml("> hi\n>> there")).toContain('class="q2"');
});
it("converts html to text", () => {
const t = htmlToText("<p>Hello <b>world</b></p><ul><li>one</li><li>two</li></ul><blockquote>q</blockquote><a href='https://a.b'>link</a>");
expect(t).toContain("Hello world");
expect(t).toContain("- one");
expect(t).toContain("> q");
expect(t).toContain("link <https://a.b>");
});
it("quotes and subjects", () => {
expect(quoteText("a\n> b")).toBe("> a\n>> b");
expect(replySubject("Re: Hi", "Re")).toBe("Re: Hi");
expect(replySubject("Fwd: Hi", "Re")).toBe("Re: Hi");
expect(replySubject("Hi", "Fwd")).toBe("Fwd: Hi");
});
});