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");
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { EmailAddress } from "@/jmap/types";
const EMAIL_RE = /^[^\s@<>"',;]+@[^\s@<>"',;]+\.[^\s@<>"',;]+$/;
export function isValidEmail(s: string): boolean {
return EMAIL_RE.test(s.trim());
}
/**
* Parse a free-form recipient string ("Ann <[email protected]>, [email protected]; \"C, D\" <c@z>")
* into a list of EmailAddress. Lenient by design.
*/
export function parseAddressList(input: string): EmailAddress[] {
const out: EmailAddress[] = [];
let buf = "";
let inQuote = false;
let inAngle = false;
const flush = () => {
const a = parseOne(buf);
if (a) out.push(a);
buf = "";
};
for (const ch of input) {
if (ch === '"' && !inAngle) inQuote = !inQuote;
if (ch === "<" && !inQuote) inAngle = true;
if (ch === ">" && !inQuote) inAngle = false;
if ((ch === "," || ch === ";" || ch === "\n") && !inQuote && !inAngle) {
flush();
continue;
}
buf += ch;
}
flush();
return out;
}
export function parseOne(raw: string): EmailAddress | null {
const s = raw.trim();
if (!s) return null;
const m = /^(.*?)\s*<([^<>]+)>\s*$/.exec(s);
if (m) {
let name = m[1]!.trim();
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, "$1");
return { name: name || null, email: m[2]!.trim() };
}
return { name: null, email: s.replace(/^<|>$/g, "") };
}
export function formatAddress(a: EmailAddress | null | undefined): string {
if (!a) return "";
if (!a.name) return a.email;
const needsQuote = /[,;<>"()\\]/.test(a.name);
const name = needsQuote ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : a.name;
return `${name} <${a.email}>`;
}
export function formatAddressList(list: EmailAddress[] | null | undefined): string {
return (list ?? []).map(formatAddress).join(", ");
}
export function displayName(a: EmailAddress | null | undefined, fallback = "(unknown)"): string {
if (!a) return fallback;
if (a.name?.trim()) return a.name.trim();
return a.email || fallback;
}
export function shortName(a: EmailAddress | null | undefined): string {
const n = displayName(a, "");
if (!n) return "";
if (n.includes("@")) return n.split("@")[0]!;
return n.split(/\s+/)[0]!;
}
export function initials(a: EmailAddress | { name?: string | null; email?: string } | string | null | undefined): string {
const name = typeof a === "string" ? a : a?.name || a?.email || "";
const parts = name
.replace(/[<>"]/g, "")
.split(/[\s._@-]+/)
.filter(Boolean);
if (!parts.length) return "?";
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
const PALETTE = [
"#0f766e", "#b45309", "#7c3aed", "#be185d", "#1d4ed8", "#047857",
"#c2410c", "#4338ca", "#a21caf", "#0e7490", "#b91c1c", "#15803d",
];
export function avatarColor(seed: string | null | undefined): string {
const s = (seed ?? "").toLowerCase();
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length]!;
}
export function sameAddress(a: string | null | undefined, b: string | null | undefined): boolean {
return (a ?? "").trim().toLowerCase() === (b ?? "").trim().toLowerCase();
}
export function uniqueAddresses(list: EmailAddress[]): EmailAddress[] {
const seen = new Set<string>();
const out: EmailAddress[] = [];
for (const a of list) {
const k = a.email.trim().toLowerCase();
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(a);
}
return out;
}
export function domainOf(email: string): string {
const i = email.lastIndexOf("@");
return i >= 0 ? email.slice(i + 1).toLowerCase() : "";
}
+149
View File
@@ -0,0 +1,149 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
/** Best display name for a card. */
export function contactDisplayName(c: ContactCard): string {
const n = c.name;
if (n?.full?.trim()) return n.full.trim();
const comps = n?.components ?? [];
const ordered = comps.filter((x) => ["given", "given2", "surname", "surname2"].includes(x.kind));
if (ordered.length) {
// Prefer given + surname order regardless of isOrdered for display.
const given = comps.filter((x) => x.kind === "given" || x.kind === "given2").map((x) => x.value).join(" ");
const sur = comps.filter((x) => x.kind === "surname" || x.kind === "surname2").map((x) => x.value).join(" ");
const s = `${given} ${sur}`.trim();
if (s) return s;
}
if (c.kind === "group" || c.kind === "org") {
const org = Object.values(c.organizations ?? {})[0]?.name;
if (org) return org;
}
const nick = Object.values(c.nicknames ?? {})[0]?.name;
if (nick) return nick;
const org = Object.values(c.organizations ?? {})[0]?.name;
if (org) return org;
const email = primaryEmail(c);
if (email) return email;
return "(no name)";
}
export function nameParts(c: ContactCard): { given: string; surname: string; prefix: string; suffix: string; middle: string } {
const comps = c.name?.components ?? [];
const pick = (k: string) => comps.filter((x) => x.kind === k).map((x) => x.value).join(" ");
return { given: pick("given"), middle: pick("given2"), surname: pick("surname"), prefix: pick("title"), suffix: pick("credential") || pick("generation") };
}
export function buildName(parts: { given?: string; middle?: string; surname?: string; prefix?: string; suffix?: string }): JSContactName | undefined {
const components: JSContactName["components"] = [];
if (parts.prefix?.trim()) components.push({ "@type": "NameComponent", kind: "title", value: parts.prefix.trim() });
if (parts.given?.trim()) components.push({ "@type": "NameComponent", kind: "given", value: parts.given.trim() });
if (parts.middle?.trim()) components.push({ "@type": "NameComponent", kind: "given2", value: parts.middle.trim() });
if (parts.surname?.trim()) components.push({ "@type": "NameComponent", kind: "surname", value: parts.surname.trim() });
if (parts.suffix?.trim()) components.push({ "@type": "NameComponent", kind: "credential", value: parts.suffix.trim() });
if (!components.length) return undefined;
const full = [parts.prefix, parts.given, parts.middle, parts.surname, parts.suffix].map((s) => s?.trim()).filter(Boolean).join(" ");
return { "@type": "Name", components, isOrdered: true, full };
}
export function primaryEmail(c: ContactCard): string | null {
const emails = Object.values(c.emails ?? {});
if (!emails.length) return null;
const sorted = [...emails].sort((a, b) => (a.pref ?? 100) - (b.pref ?? 100));
return sorted[0]!.address;
}
export function contactEmails(c: ContactCard): EmailAddress[] {
const name = contactDisplayName(c);
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
}
export function contactPhoto(c: ContactCard, accountId: string): string | null {
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null;
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
return null;
}
export function sortKey(c: ContactCard, by: "surname" | "given" = "given"): string {
const p = nameParts(c);
const k = by === "surname" ? `${p.surname} ${p.given}` : `${p.given} ${p.surname}`;
return (k.trim() || contactDisplayName(c)).toLowerCase();
}
export function formatAddressLines(a: { components?: Array<{ kind: string; value: string }>; full?: string }): string[] {
if (a.full) return a.full.split(/\n/);
const get = (k: string) =>
(a.components ?? [])
.filter((c) => c.kind === k)
.map((c) => c.value)
.join(" ");
const lines: string[] = [];
const street = [get("number"), get("name"), get("apartment"), get("building"), get("floor"), get("room")].filter(Boolean).join(" ");
const pobox = get("postOfficeBox");
if (pobox) lines.push(pobox);
if (street) lines.push(street);
const city = [get("locality"), get("region")].filter(Boolean).join(", ");
const cityLine = [city, get("postcode")].filter(Boolean).join(" ");
if (cityLine) lines.push(cityLine);
if (get("country")) lines.push(get("country"));
return lines;
}
/** Generate a vCard 4.0 for export. */
export function toVCard(c: ContactCard): string {
const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
const lines = ["BEGIN:VCARD", "VERSION:4.0"];
lines.push(`UID:${c.uid}`);
if (c.kind && c.kind !== "individual") lines.push(`KIND:${c.kind}`);
lines.push(`FN:${esc(contactDisplayName(c))}`);
const p = nameParts(c);
if (p.given || p.surname) lines.push(`N:${esc(p.surname)};${esc(p.given)};${esc(p.middle)};${esc(p.prefix)};${esc(p.suffix)}`);
for (const n of Object.values(c.nicknames ?? {})) lines.push(`NICKNAME:${esc(n.name)}`);
for (const e of Object.values(c.emails ?? {})) {
const types = Object.keys(e.contexts ?? {}).join(",");
lines.push(`EMAIL${types ? `;TYPE=${types}` : ""}${e.pref ? `;PREF=${e.pref}` : ""}:${e.address}`);
}
for (const ph of Object.values(c.phones ?? {})) {
const types = [...Object.keys(ph.contexts ?? {}), ...Object.keys(ph.features ?? {})].join(",");
lines.push(`TEL${types ? `;TYPE=${types}` : ""}${ph.pref ? `;PREF=${ph.pref}` : ""}:${ph.number}`);
}
for (const a of Object.values(c.addresses ?? {})) {
const get = (k: string) =>
(a.components ?? [])
.filter((x) => x.kind === k)
.map((x) => x.value)
.join(" ");
const street = [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ");
const types = Object.keys(a.contexts ?? {}).join(",");
lines.push(`ADR${types ? `;TYPE=${types}` : ""}:${esc(get("postOfficeBox"))};;${esc(street)};${esc(get("locality"))};${esc(get("region"))};${esc(get("postcode"))};${esc(get("country"))}`);
}
for (const o of Object.values(c.organizations ?? {})) lines.push(`ORG:${esc(o.name ?? "")}${(o.units ?? []).map((u) => `;${esc(u.name)}`).join("")}`);
for (const t of Object.values(c.titles ?? {})) lines.push(`${t.kind === "role" ? "ROLE" : "TITLE"}:${esc(t.name)}`);
for (const an of Object.values(c.anniversaries ?? {})) {
const d = an.date;
const v = d.utc ? d.utc.slice(0, 10).replace(/-/g, "") : `${d.year ?? "--"}${String(d.month ?? 0).padStart(2, "0")}${String(d.day ?? 0).padStart(2, "0")}`;
if (an.kind === "birth") lines.push(`BDAY:${v}`);
else if (an.kind === "wedding") lines.push(`ANNIVERSARY:${v}`);
}
for (const n of Object.values(c.notes ?? {})) lines.push(`NOTE:${esc(n.note)}`);
for (const l of Object.values(c.links ?? {})) lines.push(`URL:${l.uri}`);
for (const s of Object.values(c.onlineServices ?? {})) if (s.uri) lines.push(`IMPP:${s.uri}`);
if (c.members) for (const m of Object.keys(c.members)) lines.push(`MEMBER:${m}`);
lines.push("END:VCARD");
return lines.map(fold).join("\r\n") + "\r\n";
}
function fold(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
out.push((i ? " " : "") + line.slice(i, i + 74));
i += 74;
}
return out.join("\r\n");
}
export function newKey(prefix = "k"): string {
return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
}
+250
View File
@@ -0,0 +1,250 @@
export const DAY_MS = 86_400_000;
export function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
export function endOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
export function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export function addMonths(d: Date, n: number): Date {
const x = new Date(d);
const day = x.getDate();
x.setDate(1);
x.setMonth(x.getMonth() + n);
const dim = daysInMonth(x.getFullYear(), x.getMonth());
x.setDate(Math.min(day, dim));
return x;
}
export function addMinutes(d: Date, n: number): Date {
return new Date(d.getTime() + n * 60_000);
}
export function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
/** weekStart: 0 = Sunday, 1 = Monday */
export function startOfWeek(d: Date, weekStart = 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - weekStart + 7) % 7;
return addDays(x, -diff);
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function isToday(d: Date): boolean {
return isSameDay(d, new Date());
}
/** 6x7 grid of dates covering the month view. */
export function monthGrid(anchor: Date, weekStart = 1): Date[] {
const first = startOfWeek(startOfMonth(anchor), weekStart);
const out: Date[] = [];
for (let i = 0; i < 42; i++) out.push(addDays(first, i));
return out;
}
export function weekDays(anchor: Date, weekStart = 1, count = 7): Date[] {
const first = startOfWeek(anchor, weekStart);
const out: Date[] = [];
for (let i = 0; i < count; i++) out.push(addDays(first, i));
return out;
}
function pad(n: number, w = 2): string {
return String(n).padStart(w, "0");
}
/** Format a Date's wall-clock (browser local) as JSCalendar LocalDateTime. */
export function toLocalDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function toLocalDateOnly(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Date → "YYYY-MM-DDTHH:MM:SSZ" (JMAP UTCDate, no millis). */
export function toUTCDate(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function parseLocalDateTime(s: string): { y: number; mo: number; d: number; h: number; mi: number; se: number } | null {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(s);
if (!m) return null;
return { y: +m[1]!, mo: +m[2]! - 1, d: +m[3]!, h: +(m[4] ?? 0), mi: +(m[5] ?? 0), se: +(m[6] ?? 0) };
}
const dtfCache = new Map<string, Intl.DateTimeFormat>();
function dtf(tz: string): Intl.DateTimeFormat | null {
let f = dtfCache.get(tz);
if (f) return f;
try {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
dtfCache.set(tz, f);
return f;
} catch {
return null;
}
}
/** Offset (ms) of timezone `tz` at instant `date`. */
export function tzOffsetMs(date: Date, tz: string): number {
const f = dtf(tz);
if (!f) return -date.getTimezoneOffset() * 60_000;
const parts = f.formatToParts(date);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
const asUTC = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
return asUTC - Math.floor(date.getTime() / 1000) * 1000;
}
/** Interpret a JSCalendar LocalDateTime in timezone `tz` (or browser local if null) as an instant. */
export function zonedToDate(local: string, tz: string | null | undefined): Date {
const p = parseLocalDateTime(local);
if (!p) return new Date(NaN);
if (!tz) {
return new Date(p.y, p.mo, p.d, p.h, p.mi, p.se);
}
const asUTC = Date.UTC(p.y, p.mo, p.d, p.h, p.mi, p.se);
// Two-pass offset resolution handles DST edges reasonably.
let off = tzOffsetMs(new Date(asUTC), tz);
off = tzOffsetMs(new Date(asUTC - off), tz);
return new Date(asUTC - off);
}
/** Format an instant as LocalDateTime in timezone `tz` (browser local if null). */
export function dateToZonedLocal(d: Date, tz: string | null | undefined): string {
if (!tz) return toLocalDateTime(d);
const f = dtf(tz);
if (!f) return toLocalDateTime(d);
const parts = f.formatToParts(d);
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "00";
return `${get("year")}-${get("month")}-${get("day")}T${String(Number(get("hour")) % 24).padStart(2, "0")}:${get("minute")}:${get("second")}`;
}
/** Parse ISO 8601 duration (e.g. "P1DT2H30M") into seconds. */
export function parseDuration(dur: string | null | undefined): number {
if (!dur) return 0;
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(dur);
if (!m) return 0;
const sign = m[1] === "-" ? -1 : 1;
const w = Number(m[2] ?? 0), d = Number(m[3] ?? 0), h = Number(m[4] ?? 0), mi = Number(m[5] ?? 0), s = Number(m[6] ?? 0);
return sign * (w * 7 * 86400 + d * 86400 + h * 3600 + mi * 60 + s);
}
export function formatDuration(seconds: number): string {
const neg = seconds < 0;
let s = Math.abs(Math.round(seconds));
const d = Math.floor(s / 86400);
s -= d * 86400;
const h = Math.floor(s / 3600);
s -= h * 3600;
const m = Math.floor(s / 60);
s -= m * 60;
let out = "P";
if (d) out += `${d}D`;
if (h || m || s) {
out += "T";
if (h) out += `${h}H`;
if (m) out += `${m}M`;
if (s) out += `${s}S`;
}
if (out === "P") out = "PT0S";
return (neg ? "-" : "") + out;
}
export function humanDuration(seconds: number): string {
const abs = Math.abs(seconds);
if (abs === 0) return "at time of event";
const parts: string[] = [];
const d = Math.floor(abs / 86400);
const h = Math.floor((abs % 86400) / 3600);
const m = Math.floor((abs % 3600) / 60);
if (d) parts.push(`${d} day${d === 1 ? "" : "s"}`);
if (h) parts.push(`${h} hour${h === 1 ? "" : "s"}`);
if (m) parts.push(`${m} minute${m === 1 ? "" : "s"}`);
return parts.join(" ") || `${abs} seconds`;
}
export const browserTimeZone = (() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
return "UTC";
}
})();
export function listTimeZones(): string[] {
try {
const sv = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
if (sv) return sv("timeZone");
} catch {
/* ignore */
}
return ["UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Asia/Tokyo", "Asia/Kolkata", "Australia/Sydney"];
}
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
if (allDay) {
const lastDay = new Date(end.getTime() - 1);
if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isSameDay(start, end)) {
return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} ${t(end)}`;
}
return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
}
/** For <input type="datetime-local"> */
export function toInputDateTime(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function fromInputDateTime(s: string): Date {
const p = parseLocalDateTime(s);
if (!p) return new Date(NaN);
return new Date(p.y, p.mo, p.d, p.h, p.mi, 0);
}
export function roundToNext(d: Date, minutes: number): Date {
const x = new Date(d);
x.setSeconds(0, 0);
const m = x.getMinutes();
const r = Math.ceil(m / minutes) * minutes;
x.setMinutes(r);
return x;
}
+108
View File
@@ -0,0 +1,108 @@
const rtf = typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
export function formatSize(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes)) return "";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
/** Gmail-style compact date for list views. */
export function formatListDate(iso: string | null | undefined, now = new Date()): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
if (isSameDay(d, now)) return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" */
export function formatFullDate(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
if (!iso) return "";
const d = new Date(iso);
const diff = (d.getTime() - now.getTime()) / 1000;
const abs = Math.abs(diff);
if (!rtf) return formatListDate(iso, now);
if (abs < 60) return rtf.format(Math.round(diff), "second");
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
if (abs < 86400 * 7) return rtf.format(Math.round(diff / 86400), "day");
return formatListDate(iso, now);
}
export function formatDateShort(d: Date): string {
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
}
export function formatTime(d: Date): string {
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
}
export function formatMonthYear(d: Date): string {
return d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
export function plural(n: number, one: string, many = `${one}s`): string {
return `${n} ${n === 1 ? one : many}`;
}
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
export function truncate(s: string, n: number): string {
return s.length > n ? `${s.slice(0, n - 1)}` : s;
}
export function uid(prefix = "u"): string {
return `${prefix}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
export function debounce<T extends (...args: never[]) => void>(fn: T, ms: number): T & { cancel(): void } {
let t: number | null = null;
const wrapped = ((...args: Parameters<T>) => {
if (t) window.clearTimeout(t);
t = window.setTimeout(() => {
t = null;
fn(...args);
}, ms);
}) as T & { cancel(): void };
wrapped.cancel = () => {
if (t) window.clearTimeout(t);
t = null;
};
return wrapped;
}
export function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
+169
View File
@@ -0,0 +1,169 @@
import DOMPurify from "dompurify";
export interface SanitizeOptions {
/** Map of Content-ID (without angle brackets) → URL for inline images. */
cidMap?: Record<string, string>;
/** Whether remote content (http/https images, css urls) may load. */
allowRemote?: boolean;
/** Route remote images through the privacy proxy. */
proxyRemote?: boolean;
}
export interface SanitizeResult {
html: string;
remoteCount: number;
bodyStyle: string;
}
const REMOTE_URL_RE = /^(https?:)?\/\//i;
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
let hooked = false;
function ensureHooks() {
if (hooked) return;
hooked = true;
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
if (data.tagName === "style" && node.textContent) {
// Remove @import and remote url() references; they're handled later in processRemote().
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
}
});
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer nofollow");
}
// Forms are forbidden but be safe about formaction-like attributes on anything.
for (const attr of ["formaction", "action", "ping", "xlink:href"]) {
if (node.hasAttribute(attr)) node.removeAttribute(attr);
}
});
}
export function proxiedImageUrl(url: string): string {
return `/api/image?url=${encodeURIComponent(url)}`;
}
export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): SanitizeResult {
ensureHooks();
let bodyStyle = "";
const bodyMatch = /<body([^>]*)>/i.exec(input);
if (bodyMatch) {
const attrs = bodyMatch[1]!;
const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1];
const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1];
if (bg) bodyStyle += `background-color:${bg.trim()};`;
if (style) bodyStyle += style;
}
const clean = DOMPurify.sanitize(input, {
WHOLE_DOCUMENT: false,
RETURN_DOM: true,
FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"],
FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
USE_PROFILES: { html: true },
ADD_TAGS: ["style", "center", "font", "marquee"],
ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"],
}) as unknown as HTMLElement;
let remoteCount = 0;
const cidMap = opts.cidMap ?? {};
const allow = Boolean(opts.allowRemote);
const proxy = Boolean(opts.proxyRemote);
const remote = (url: string): string => {
remoteCount++;
if (!allow) return "";
return proxy ? proxiedImageUrl(url) : url;
};
const rewriteUrl = (raw: string): { url: string; keep: boolean } => {
const url = raw.trim();
if (/^cid:/i.test(url)) {
const cid = url.slice(4).replace(/^<|>$/g, "");
const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()];
return mapped ? { url: mapped, keep: true } : { url: "", keep: false };
}
if (/^data:image\//i.test(url)) return { url, keep: true };
if (REMOTE_URL_RE.test(url)) {
const abs = url.startsWith("//") ? `https:${url}` : url;
const u = remote(abs);
return { url: u, keep: Boolean(u) };
}
// Relative or unknown scheme -> drop.
return { url: "", keep: false };
};
// Image-bearing attributes
const els = clean.querySelectorAll<HTMLElement>("[src],[background],[poster],[srcset]");
els.forEach((el) => {
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
for (const attr of ["src", "background", "poster"]) {
const v = el.getAttribute(attr);
if (v == null) continue;
const r = rewriteUrl(v);
if (r.keep) el.setAttribute(attr, r.url);
else {
el.removeAttribute(attr);
if (attr === "src" && el.tagName === "IMG") {
el.setAttribute("data-ihm-blocked", "1");
if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim());
}
}
}
});
// CSS url() in style attributes and <style> blocks
const rewriteCss = (css: string): string =>
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
const r = rewriteUrl(u);
return r.keep ? `url(${q}${r.url}${q})` : "none";
});
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const s = el.getAttribute("style");
if (s && /url\(/i.test(s)) el.setAttribute("style", rewriteCss(s));
});
clean.querySelectorAll("style").forEach((st) => {
if (st.textContent && /url\(|@import/i.test(st.textContent)) {
st.textContent = rewriteCss(st.textContent.replace(/@import[^;]+;?/gi, ""));
}
});
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
return { html: clean.innerHTML, remoteCount, bodyStyle };
}
/** Minimal sanitizer for signatures / composer HTML (no remote blocking, keeps images). */
export function sanitizeEditorHtml(input: string): string {
ensureHooks();
return DOMPurify.sanitize(input, {
USE_PROFILES: { html: true },
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "meta", "link", "base", "svg", "math"],
FORBID_ATTR: ["srcdoc", "formaction", "ping", "onerror", "onload"],
ADD_ATTR: ["target", "bgcolor", "align", "valign", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size"],
}) as string;
}
/** Base CSS injected into the shadow root that hosts HTML email. */
export const EMAIL_BASE_CSS = `
:host { display:block; color-scheme: light; }
.ihm-email-root { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color:#1f2937; background:#fff; padding:16px; border-radius:8px; overflow-wrap:anywhere; word-break:normal; contain: content; }
.ihm-email-root img { max-width:100%; height:auto; }
.ihm-email-root img[data-ihm-blocked] { display:inline-block; min-width:16px; min-height:16px; background:#f1f5f9 repeating-linear-gradient(45deg,#e2e8f0 0 6px,#f1f5f9 6px 12px); border:1px dashed #cbd5e1; }
.ihm-email-root table { max-width:100%; }
.ihm-email-root pre { white-space:pre-wrap; }
.ihm-email-root blockquote { margin:0 0 0 .8ex; border-left:2px solid #cbd5e1; padding-left:1ex; color:#475569; }
.ihm-email-root a { color:#0f766e; }
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
`;
export const TEXT_EMAIL_CSS = `
:host { display:block; }
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
.ihm-text-root a { color: var(--link, #0f766e); }
.ihm-text-root .q1 { color: var(--q1,#2563eb); } .ihm-text-root .q2 { color: var(--q2,#16a34a); } .ihm-text-root .q3 { color: var(--q3,#9333ea); }
`;
+138
View File
@@ -0,0 +1,138 @@
/**
* Gmail-style keyboard shortcut manager with two-key sequences ("g i").
* Handlers are registered in scopes; the most recently pushed scope wins.
*/
export type KeyHandler = (e: KeyboardEvent) => void | boolean;
interface Binding {
keys: string; // e.g. "j", "shift+i", "g i", "mod+enter"
handler: KeyHandler;
description: string;
group: string;
allowInInput?: boolean;
}
interface Scope {
name: string;
bindings: Binding[];
}
class Keyboard {
private scopes: Scope[] = [];
private pendingPrefix: string | null = null;
private prefixTimer: number | null = null;
enabled = true;
constructor() {
if (typeof window !== "undefined") window.addEventListener("keydown", this.onKeyDown, true);
}
pushScope(name: string, bindings: Binding[]): () => void {
const scope = { name, bindings };
this.scopes.push(scope);
return () => {
this.scopes = this.scopes.filter((s) => s !== scope);
};
}
/** All bindings with descriptions, for the help overlay. */
list(): Array<{ group: string; keys: string; description: string }> {
const seen = new Set<string>();
const out: Array<{ group: string; keys: string; description: string }> = [];
for (const s of [...this.scopes].reverse()) {
for (const b of s.bindings) {
if (!b.description || seen.has(b.keys)) continue;
seen.add(b.keys);
out.push({ group: b.group, keys: b.keys, description: b.description });
}
}
return out;
}
private onKeyDown = (e: KeyboardEvent) => {
if (!this.enabled) return;
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
if (document.querySelector(".dialog-backdrop, .popover")) return;
const target = e.target as HTMLElement | null;
const inInput =
!!target &&
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable);
const combo = comboOf(e);
if (!combo) return;
// Try sequence completion first.
const candidates: Binding[] = [];
for (let i = this.scopes.length - 1; i >= 0; i--) {
for (const b of this.scopes[i]!.bindings) candidates.push(b);
}
if (this.pendingPrefix) {
const seq = `${this.pendingPrefix} ${combo}`;
const b = candidates.find((x) => x.keys === seq && (!inInput || x.allowInInput));
this.clearPrefix();
if (b) {
const r = b.handler(e);
if (r !== false) {
e.preventDefault();
e.stopPropagation();
}
return;
}
}
// Is this combo the first half of any sequence?
if (!inInput && candidates.some((x) => x.keys.startsWith(`${combo} `))) {
this.pendingPrefix = combo;
this.prefixTimer = window.setTimeout(() => this.clearPrefix(), 1200);
e.preventDefault();
return;
}
const b = candidates.find((x) => x.keys === combo && (!inInput || x.allowInInput));
if (b) {
const r = b.handler(e);
if (r !== false) {
e.preventDefault();
e.stopPropagation();
}
}
};
private clearPrefix() {
this.pendingPrefix = null;
if (this.prefixTimer) window.clearTimeout(this.prefixTimer);
this.prefixTimer = null;
}
}
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
export function comboOf(e: KeyboardEvent): string | null {
const key = e.key;
if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") return null;
const parts: string[] = [];
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod) parts.push("mod");
if (e.altKey) parts.push("alt");
if (e.shiftKey && key.length > 1) parts.push("shift");
let k = key;
if (k === " ") k = "space";
else if (k === "Escape") k = "esc";
else if (k.length === 1) {
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
} else k = k.toLowerCase();
parts.push(k);
return parts.join("+");
}
export function formatKeys(keys: string): string {
return keys
.split(" ")
.map((k) =>
k
.split("+")
.map((p) => (p === "mod" ? (isMac ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "alt" ? (isMac ? "⌥" : "Alt") : p === "enter" ? "↵" : p === "esc" ? "Esc" : p === "space" ? "Space" : p === "arrowup" ? "↑" : p === "arrowdown" ? "↓" : p === "arrowleft" ? "←" : p === "arrowright" ? "→" : p.length === 1 ? p : p[0]!.toUpperCase() + p.slice(1)))
.join(isMac ? "" : "+"),
)
.join(" then ");
}
export const keyboard = new Keyboard();
+95
View File
@@ -0,0 +1,95 @@
let baseTitle = "ihasmail";
let faviconCanvas: HTMLCanvasElement | null = null;
let baseFavicon: HTMLImageElement | null = null;
export function setBaseTitle(t: string) {
baseTitle = t;
}
/** Update document title and favicon badge with unread count. */
export function setUnreadBadge(count: number): void {
document.title = count > 0 ? `(${count > 999 ? "999+" : count}) ${baseTitle}` : baseTitle;
try {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"][type="image/png"]');
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
baseFavicon.src = "/img/favicon-64.png";
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
link.href = "/img/favicon-64.png";
return;
}
faviconCanvas ??= document.createElement("canvas");
const c = faviconCanvas;
c.width = 64;
c.height = 64;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, 64, 64);
ctx.drawImage(baseFavicon, 0, 0, 64, 64);
ctx.fillStyle = "#dc2626";
ctx.beginPath();
ctx.arc(46, 18, 16, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = "bold 22px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(count > 99 ? "99" : String(count), 46, 19);
link.href = c.toDataURL("image/png");
} catch {
/* ignore */
}
}
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!("Notification" in window)) return "denied";
if (Notification.permission !== "default") return Notification.permission;
try {
return await Notification.requestPermission();
} catch {
return "denied";
}
}
export function showNotification(title: string, opts: NotificationOptions & { onClick?: () => void } = {}): void {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
try {
const n = new Notification(title, { icon: "/img/icon-192.png", badge: "/img/favicon-64.png", ...opts });
n.onclick = () => {
window.focus();
opts.onClick?.();
n.close();
};
setTimeout(() => n.close(), 8000);
} catch {
/* ignore */
}
}
let audioCtx: AudioContext | null = null;
/** Short, soft "ding" using WebAudio (no asset needed). */
export function playNewMailSound(): void {
try {
audioCtx ??= new AudioContext();
const ctx = audioCtx;
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = "sine";
o.frequency.setValueAtTime(880, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.08);
g.gain.setValueAtTime(0.0001, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.15, ctx.currentTime + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.4);
o.connect(g).connect(ctx.destination);
o.start();
o.stop(ctx.currentTime + 0.45);
} catch {
/* ignore */
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
export const WEEKDAYS: Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> = [
{ key: "mo", label: "Monday", short: "M" },
{ key: "tu", label: "Tuesday", short: "T" },
{ key: "we", label: "Wednesday", short: "W" },
{ key: "th", label: "Thursday", short: "T" },
{ key: "fr", label: "Friday", short: "F" },
{ key: "sa", label: "Saturday", short: "S" },
{ key: "su", label: "Sunday", short: "S" },
];
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
export function presetFor(rule: JSCalendarRecurrenceRule | undefined): RecurrencePreset {
if (!rule) return "none";
const simple = !rule.count && !rule.until && (rule.interval ?? 1) === 1;
if (rule.frequency === "daily" && simple && !rule.byDay) return "daily";
if (rule.frequency === "weekly" && simple) {
if (!rule.byDay) return "weekly";
const days = rule.byDay.map((d) => d.day).sort().join(",");
if (days === ["mo", "tu", "we", "th", "fr"].sort().join(",")) return "weekdays";
if (rule.byDay.length === 1) return "weekly";
}
if (rule.frequency === "monthly" && simple && !rule.byDay && (!rule.byMonthDay || rule.byMonthDay.length === 1)) return "monthly";
if (rule.frequency === "yearly" && simple && !rule.byDay && !rule.byMonth) return "yearly";
return "custom";
}
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
const dow = WEEKDAYS[(start.getDay() + 6) % 7]!.key;
switch (preset) {
case "daily":
return { "@type": "RecurrenceRule", frequency: "daily" };
case "weekly":
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: dow }] };
case "weekdays":
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: ["mo", "tu", "we", "th", "fr"].map((d) => ({ "@type": "NDay" as const, day: d as JSCalendarNDay["day"] })) };
case "monthly":
return { "@type": "RecurrenceRule", frequency: "monthly", byMonthDay: [start.getDate()] };
case "yearly":
return { "@type": "RecurrenceRule", frequency: "yearly" };
default:
return undefined;
}
}
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
if (!rule) return "Does not repeat";
const n = rule.interval ?? 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = n === 1 ? "Daily" : `Every ${n} days`;
break;
case "weekly": {
base = n === 1 ? "Weekly" : `Every ${n} weeks`;
if (rule.byDay?.length) {
const names = rule.byDay.map((d) => WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day);
const set = rule.byDay.map((d) => d.day).sort().join(",");
if (set === ["mo", "tu", "we", "th", "fr"].sort().join(",") && n === 1) base = "Every weekday";
else base += ` on ${names.join(", ")}`;
}
break;
}
case "monthly": {
base = n === 1 ? "Monthly" : `Every ${n} months`;
if (rule.byMonthDay?.length) base += ` on day ${rule.byMonthDay.join(", ")}`;
else if (rule.byDay?.length) {
const d = rule.byDay[0]!;
const ord = d.nthOfPeriod ? ordinal(d.nthOfPeriod) + " " : "";
base += ` on the ${ord}${WEEKDAYS.find((w) => w.key === d.day)?.label ?? d.day}`;
}
break;
}
case "yearly":
base = n === 1 ? "Yearly" : `Every ${n} years`;
break;
default:
base = `Every ${n} ${rule.frequency}`;
}
if (rule.count) base += `, ${rule.count} times`;
if (rule.until) base += `, until ${rule.until.slice(0, 10)}`;
return base;
}
function ordinal(n: number): string {
if (n === -1) return "last";
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!);
}
+193
View File
@@ -0,0 +1,193 @@
import type { EmailFilter, EmailFilterCondition, Mailbox } from "@/jmap/types";
export interface ParsedQuery {
text: string[];
from?: string;
to?: string;
cc?: string;
subject?: string;
body?: string;
hasAttachment?: boolean;
unread?: boolean;
read?: boolean;
starred?: boolean;
in?: string;
label?: string[];
before?: string;
after?: string;
larger?: number;
smaller?: number;
notLabel?: string[];
}
const SIZE_RE = /^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/i;
function parseSize(s: string): number | undefined {
const m = SIZE_RE.exec(s.trim());
if (!m) return undefined;
const n = Number(m[1]);
const unit = (m[2] ?? "").toLowerCase();
const mult = unit.startsWith("k") ? 1024 : unit.startsWith("m") ? 1024 ** 2 : unit.startsWith("g") ? 1024 ** 3 : 1;
return Math.round(n * mult);
}
function parseDate(s: string, endOfDay = false): string | undefined {
const t = s.trim();
let d: Date | null = null;
if (/^\d{4}-\d{2}-\d{2}$/.test(t) || /^\d{4}\/\d{2}\/\d{2}$/.test(t)) {
const [y, m, dd] = t.split(/[-/]/).map(Number) as [number, number, number];
d = new Date(y, m - 1, dd);
} else if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(t)) {
const [m, dd, y] = t.split("/").map(Number) as [number, number, number];
d = new Date(y, m - 1, dd);
} else {
const rel = /^(\d+)([dwmy])$/.exec(t);
if (rel) {
d = new Date();
const n = Number(rel[1]);
if (rel[2] === "d") d.setDate(d.getDate() - n);
if (rel[2] === "w") d.setDate(d.getDate() - n * 7);
if (rel[2] === "m") d.setMonth(d.getMonth() - n);
if (rel[2] === "y") d.setFullYear(d.getFullYear() - n);
} else {
const p = new Date(t);
if (!Number.isNaN(p.getTime())) d = p;
}
}
if (!d || Number.isNaN(d.getTime())) return undefined;
if (endOfDay) d.setHours(23, 59, 59, 999);
else d.setHours(0, 0, 0, 0);
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
/** Tokenize respecting quotes. */
function tokenize(q: string): string[] {
const out: string[] = [];
const re = /(\S+?:"[^"]*"|"[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(q))) out.push(m[1]!);
return out;
}
export function parseQuery(q: string): ParsedQuery {
const p: ParsedQuery = { text: [] };
for (const tok of tokenize(q)) {
const idx = tok.indexOf(":");
const key = idx > 0 ? tok.slice(0, idx).toLowerCase() : "";
let val = idx > 0 ? tok.slice(idx + 1) : tok;
if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
const neg = key.startsWith("-");
const k = neg ? key.slice(1) : key;
switch (k) {
case "from":
p.from = val;
break;
case "to":
p.to = val;
break;
case "cc":
p.cc = val;
break;
case "subject":
p.subject = val;
break;
case "body":
p.body = val;
break;
case "has":
if (val === "attachment") p.hasAttachment = true;
if (val === "star" || val === "flag") p.starred = true;
break;
case "is":
if (val === "unread") p.unread = true;
if (val === "read") p.read = true;
if (val === "starred" || val === "flagged") p.starred = true;
break;
case "in":
case "folder":
p.in = val;
break;
case "label":
case "keyword":
if (neg) (p.notLabel ??= []).push(val);
else (p.label ??= []).push(val);
break;
case "before":
p.before = parseDate(val);
break;
case "after":
case "since":
p.after = parseDate(val);
break;
case "newer":
case "newer_than":
p.after = parseDate(val);
break;
case "older":
case "older_than":
p.before = parseDate(val);
break;
case "larger":
case "size":
p.larger = parseSize(val);
break;
case "smaller":
p.smaller = parseSize(val);
break;
default:
p.text.push(val);
}
}
return p;
}
export function buildFilter(p: ParsedQuery, mailboxes: Record<string, Mailbox>, currentMailbox?: string | null): EmailFilter {
const conds: EmailFilterCondition[] = [];
const c: EmailFilterCondition = {};
if (p.text.length) c.text = p.text.join(" ");
if (p.from) c.from = p.from;
if (p.to) c.to = p.to;
if (p.cc) c.cc = p.cc;
if (p.subject) c.subject = p.subject;
if (p.body) c.body = p.body;
if (p.hasAttachment) c.hasAttachment = true;
if (p.unread) c.notKeyword = "$seen";
if (p.read) c.hasKeyword = "$seen";
if (p.before) c.before = p.before;
if (p.after) c.after = p.after;
if (p.larger != null) c.minSize = p.larger;
if (p.smaller != null) c.maxSize = p.smaller;
if (p.in) {
const mb = resolveMailbox(p.in, mailboxes);
if (mb) c.inMailbox = mb.id;
} else if (currentMailbox) {
c.inMailbox = currentMailbox;
}
conds.push(c);
if (p.starred) conds.push({ hasKeyword: "$flagged" });
for (const l of p.label ?? []) conds.push({ hasKeyword: l.startsWith("$") ? l : l });
for (const l of p.notLabel ?? []) conds.push({ notKeyword: l });
if (conds.length === 1) return conds[0]!;
return { operator: "AND", conditions: conds };
}
export function resolveMailbox(name: string, mailboxes: Record<string, Mailbox>): Mailbox | undefined {
const n = name.toLowerCase();
const list = Object.values(mailboxes);
const byRole = list.find((m) => m.role === n || (n === "spam" && m.role === "junk") || (n === "starred" && m.role === "flagged") || (n === "anywhere" && m.role === "all"));
if (byRole) return byRole;
if (n === "anywhere" || n === "all") return undefined;
return list.find((m) => m.name.toLowerCase() === n) ?? list.find((m) => m.name.toLowerCase().includes(n));
}
export function describeFilter(p: ParsedQuery): string {
const parts: string[] = [];
if (p.text.length) parts.push(`"${p.text.join(" ")}"`);
if (p.from) parts.push(`from ${p.from}`);
if (p.to) parts.push(`to ${p.to}`);
if (p.subject) parts.push(`subject ${p.subject}`);
if (p.hasAttachment) parts.push("has attachment");
if (p.unread) parts.push("unread");
if (p.starred) parts.push("starred");
if (p.in) parts.push(`in ${p.in}`);
return parts.join(", ") || "all mail";
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Visual filter rules <-> Sieve script codec.
*
* Rules are persisted inside the Sieve script itself as JSON comments
* (`# rule:{...}`) so the UI can round-trip them losslessly; the generated
* Sieve below each comment is what the server actually runs.
*/
export type HeaderOp = "contains" | "notcontains" | "is" | "notis" | "matches" | "notmatches" | "regex" | "notregex" | "exists" | "notexists";
export type SieveTest =
| { type: "header"; header: string; op: HeaderOp; value: string }
| { type: "address"; header: string; part: "all" | "localpart" | "domain"; op: HeaderOp; value: string }
| { type: "size"; op: "over" | "under"; value: number }
| { type: "body"; op: "contains" | "notcontains"; value: string }
| { type: "true" };
export type SieveAction =
| { type: "fileinto"; mailbox: string; mailboxId?: string; copy?: boolean }
| { type: "redirect"; address: string; copy?: boolean }
| { type: "discard" }
| { type: "keep" }
| { type: "reject"; reason: string }
| { type: "addflag"; flag: string }
| { type: "setflag"; flag: string }
| { type: "removeflag"; flag: string }
| { type: "markread" }
| { type: "flag" }
| { type: "stop" };
export interface SieveRule {
id: string;
name: string;
enabled: boolean;
join: "allof" | "anyof";
tests: SieveTest[];
actions: SieveAction[];
}
export const HEADER_CHOICES = [
{ value: "from", label: "From" },
{ value: "to", label: "To" },
{ value: "cc", label: "Cc" },
{ value: "subject", label: "Subject" },
{ value: "list-id", label: "List-Id" },
{ value: "reply-to", label: "Reply-To" },
{ value: "x-spam-status", label: "X-Spam-Status" },
{ value: "__custom__", label: "Other header…" },
];
export const HEADER_OPS: Array<{ value: HeaderOp; label: string }> = [
{ value: "contains", label: "contains" },
{ value: "notcontains", label: "does not contain" },
{ value: "is", label: "is" },
{ value: "notis", label: "is not" },
{ value: "matches", label: "matches (wildcards * ?)" },
{ value: "notmatches", label: "does not match" },
{ value: "regex", label: "matches regex" },
{ value: "notregex", label: "does not match regex" },
{ value: "exists", label: "exists" },
{ value: "notexists", label: "does not exist" },
];
export function sieveString(s: string): string {
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, " ")}"`;
}
function opToSieve(op: HeaderOp): { neg: boolean; match: string } {
const neg = op.startsWith("not");
const base = neg ? op.slice(3) : op;
return { neg, match: base === "regex" ? ":regex" : base === "matches" ? ":matches" : base === "is" ? ":is" : base === "exists" ? "exists" : ":contains" };
}
export function testToSieve(t: SieveTest): string {
switch (t.type) {
case "true":
return "true";
case "header": {
const { neg, match } = opToSieve(t.op);
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `header ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
return neg ? `not ${inner}` : inner;
}
case "address": {
const { neg, match } = opToSieve(t.op);
const part = t.part === "all" ? ":all" : t.part === "localpart" ? ":localpart" : ":domain";
const inner = match === "exists" ? `exists ${sieveString(t.header)}` : `address ${part} ${match} ${sieveString(t.header)} ${sieveString(t.value)}`;
return neg ? `not ${inner}` : inner;
}
case "size":
return `size :${t.op} ${Math.max(0, Math.round(t.value))}`;
case "body": {
const inner = `body :text :contains ${sieveString(t.value)}`;
return t.op === "notcontains" ? `not ${inner}` : inner;
}
}
}
export function actionToSieve(a: SieveAction): string[] {
switch (a.type) {
case "fileinto":
return [`fileinto${a.copy ? " :copy" : ""} ${sieveString(a.mailbox)};`];
case "redirect":
return [`redirect${a.copy ? " :copy" : ""} ${sieveString(a.address)};`];
case "discard":
return ["discard;"];
case "keep":
return ["keep;"];
case "reject":
return [`reject ${sieveString(a.reason || "Message rejected")};`];
case "addflag":
return [`addflag ${sieveString(a.flag)};`];
case "setflag":
return [`setflag ${sieveString(a.flag)};`];
case "removeflag":
return [`removeflag ${sieveString(a.flag)};`];
case "markread":
return ['addflag "\\\\Seen";'];
case "flag":
return ['addflag "\\\\Flagged";'];
case "stop":
return ["stop;"];
}
}
export function requiredExtensions(rules: SieveRule[]): string[] {
const req = new Set<string>();
for (const r of rules) {
for (const t of r.tests) {
if (t.type === "body") req.add("body");
if ((t.type === "header" || t.type === "address") && (t.op === "regex" || t.op === "notregex")) req.add("regex");
if (t.type === "address") req.add("envelope");
}
for (const a of r.actions) {
if (a.type === "fileinto") {
req.add("fileinto");
if (a.copy) req.add("copy");
}
if (a.type === "redirect" && a.copy) req.add("copy");
if (a.type === "reject") req.add("reject");
if (["addflag", "setflag", "removeflag", "markread", "flag"].includes(a.type)) req.add("imap4flags");
}
}
req.delete("envelope");
return [...req].sort();
}
export const SCRIPT_HEADER = "# ihasmail filters v1 - edit with care; rules are stored in the `# rule:` comments";
export function rulesToSieve(rules: SieveRule[]): string {
const ext = requiredExtensions(rules);
const lines: string[] = [SCRIPT_HEADER];
if (ext.length) lines.push(`require [${ext.map(sieveString).join(", ")}];`);
lines.push("");
for (const r of rules) {
lines.push(`# rule:${JSON.stringify(r)}`);
if (!r.enabled) {
lines.push(`# (disabled) ${r.name}`);
lines.push("");
continue;
}
const tests = r.tests.filter((t) => t.type !== "true");
let cond: string;
if (!tests.length) cond = "true";
else if (tests.length === 1) cond = testToSieve(tests[0]!);
else cond = `${r.join} (${tests.map(testToSieve).join(", ")})`;
const body = r.actions.flatMap(actionToSieve).map((l) => ` ${l}`);
if (!body.length) body.push(" keep;");
lines.push(`if ${cond}`);
lines.push("{");
lines.push(...body);
lines.push("}");
lines.push("");
}
return lines.join("\n");
}
/** Returns rules if the script was generated by ihasmail, else null (raw script). */
export function sieveToRules(script: string): SieveRule[] | null {
if (!script.includes("# rule:")) return script.trim() === "" || script.includes(SCRIPT_HEADER) ? [] : null;
const out: SieveRule[] = [];
for (const line of script.split(/\r?\n/)) {
if (!line.startsWith("# rule:")) continue;
try {
const r = JSON.parse(line.slice(7)) as SieveRule;
if (r && typeof r === "object" && Array.isArray(r.tests) && Array.isArray(r.actions)) out.push(r);
} catch {
/* skip */
}
}
return out;
}
export function newRule(partial: Partial<SieveRule> = {}): SieveRule {
return {
id: `r${Math.random().toString(36).slice(2, 9)}`,
name: "New filter",
enabled: true,
join: "allof",
tests: [{ type: "header", header: "from", op: "contains", value: "" }],
actions: [{ type: "fileinto", mailbox: "INBOX" }],
...partial,
};
}
export function describeRule(r: SieveRule): string {
const tests = r.tests
.map((t) => {
switch (t.type) {
case "header":
return `${t.header} ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "address":
return `${t.header} address ${HEADER_OPS.find((o) => o.value === t.op)?.label ?? t.op} "${t.value}"`;
case "size":
return `size ${t.op} ${Math.round(t.value / 1024)} KB`;
case "body":
return `body ${t.op === "contains" ? "contains" : "does not contain"} "${t.value}"`;
case "true":
return "always";
}
})
.join(r.join === "allof" ? " and " : " or ");
const actions = r.actions
.map((a) => {
switch (a.type) {
case "fileinto":
return `move to ${a.mailbox}`;
case "redirect":
return `forward to ${a.address}`;
case "discard":
return "delete";
case "keep":
return "keep";
case "reject":
return "reject";
case "markread":
return "mark read";
case "flag":
return "star";
case "addflag":
case "setflag":
return `add ${a.flag}`;
case "removeflag":
return `remove ${a.flag}`;
case "stop":
return "stop";
}
})
.join(", ");
return `${tests || "always"}${actions}`;
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Client-side evaluation of a visual Sieve rule against existing messages, so a
* newly created filter can be applied retroactively to a folder (the server only
* runs Sieve on delivery).
*/
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";
function headerValues(e: Email, header: string): string[] {
const h = header.toLowerCase();
const addr = (list?: { name: string | null; email: string }[] | null) => (list ?? []).map((a) => (a.name ? `${a.name} <${a.email}>` : a.email));
switch (h) {
case "from":
return addr(e.from);
case "to":
return addr(e.to);
case "cc":
return addr(e.cc);
case "bcc":
return addr(e.bcc);
case "reply-to":
return addr(e.replyTo);
case "sender":
return addr(e.sender);
case "subject":
return e.subject ? [e.subject] : [];
case "message-id":
return e.messageId ?? [];
default: {
const rec = e as unknown as Record<string, unknown>;
const key = Object.keys(rec).find((k) => k.toLowerCase().startsWith(`header:${h}:`));
const v = key ? rec[key] : undefined;
return typeof v === "string" ? [v] : Array.isArray(v) ? (v as string[]) : [];
}
}
}
function addressValues(e: Email, header: string, part: "all" | "localpart" | "domain"): string[] {
const h = header.toLowerCase();
const list = h === "from" ? e.from : h === "to" ? e.to : h === "cc" ? e.cc : h === "bcc" ? e.bcc : h === "reply-to" ? e.replyTo : h === "sender" ? e.sender : null;
return (list ?? []).map((a) => (part === "domain" ? domainOf(a.email) : part === "localpart" ? a.email.split("@")[0] ?? "" : a.email));
}
function wildcardToRegex(pattern: string): RegExp {
const esc = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
return new RegExp(`^${esc}$`, "i");
}
function matchOp(values: string[], op: string, value: string): boolean {
const neg = op.startsWith("not");
const base = neg ? op.slice(3) : op;
const v = value.toLowerCase();
let r: boolean;
switch (base) {
case "exists":
r = values.length > 0;
break;
case "is":
r = values.some((x) => x.toLowerCase() === v);
break;
case "matches":
r = values.some((x) => wildcardToRegex(value).test(x));
break;
case "regex": {
let re: RegExp | null = null;
try {
re = new RegExp(value, "i");
} catch {
re = null;
}
r = re ? values.some((x) => re!.test(x)) : false;
break;
}
default:
r = values.some((x) => x.toLowerCase().includes(v));
}
return neg ? !r : r;
}
export function evaluateTest(e: Email, t: SieveTest, bodyText?: string): boolean {
switch (t.type) {
case "true":
return true;
case "header":
return matchOp(headerValues(e, t.header), t.op, t.value);
case "address":
return matchOp(addressValues(e, t.header, t.part), t.op, t.value);
case "size":
return t.op === "over" ? e.size > t.value : e.size < t.value;
case "body": {
const has = (bodyText ?? e.preview ?? "").toLowerCase().includes(t.value.toLowerCase());
return t.op === "contains" ? has : !has;
}
}
}
export function evaluateRule(e: Email, rule: SieveRule, bodyText?: string): boolean {
const tests = rule.tests.filter((t) => t.type !== "true");
if (!tests.length) return true;
return rule.join === "anyof" ? tests.some((t) => evaluateTest(e, t, bodyText)) : tests.every((t) => evaluateTest(e, t, bodyText));
}
export interface ApplyResult {
scanned: number;
matched: number;
skippedActions: string[];
}
/** Apply a rule's actions to all matching messages currently in `mailboxId`. */
export async function applyRuleToMailbox(rule: SieveRule, mailboxId: Id, onProgress?: (scanned: number, total: number) => void): Promise<ApplyResult> {
const mail = useMail.getState();
const accountId = mail.accountId;
if (!accountId) throw new Error("Not signed in");
const customHeaders = rule.tests.filter((t): t is Extract<SieveTest, { type: "header" }> => t.type === "header").map((t) => t.header).filter((h) => !["from", "to", "cc", "bcc", "reply-to", "sender", "subject", "message-id"].includes(h.toLowerCase()));
const needsBody = rule.tests.some((t) => t.type === "body");
const props = [...LIST_PROPS, "sender", "cc", "bcc", "replyTo", "messageId", ...customHeaders.map((h) => `header:${h}:asText`), ...(needsBody ? ["textBody", "bodyValues"] : [])];
// Gather all ids in the folder
const ids: Id[] = [];
let position = 0;
let total = 0;
for (let guard = 0; guard < 40; guard++) {
const q = await client.call<QueryResponse>("Email/query", { accountId, filter: { inMailbox: mailboxId }, sort: [{ property: "receivedAt", isAscending: false }], position, limit: 500, calculateTotal: true });
ids.push(...q.ids);
total = q.total ?? ids.length;
position += q.ids.length;
if (!q.ids.length || position >= total) break;
}
const matched: Email[] = [];
let scanned = 0;
for (const part of chunk(ids, 200)) {
const res = await client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: props, ...(needsBody ? { fetchTextBodyValues: true, maxBodyValueBytes: 64 * 1024 } : {}) });
for (const e of res.list) {
const body = needsBody ? (e.textBody?.[0]?.partId ? e.bodyValues?.[e.textBody[0].partId]?.value : undefined) : undefined;
if (evaluateRule(e, rule, body)) matched.push(e);
}
scanned += part.length;
onProgress?.(scanned, ids.length);
}
const skippedActions: string[] = [];
if (matched.length) {
const mids = matched.map((e) => e.id);
const byPath = new Map<string, Id>();
for (const m of Object.values(mail.mailboxes)) byPath.set(mail.mailboxPath(m.id).toLowerCase(), m.id);
const inboxId = mail.roleId("inbox");
for (const a of rule.actions) {
switch (a.type) {
case "fileinto": {
const target = (a.mailboxId && mail.mailboxes[a.mailboxId]?.id) || byPath.get(a.mailbox.toLowerCase()) || (a.mailbox.toLowerCase() === "inbox" ? inboxId : null) || Object.values(mail.mailboxes).find((m) => m.name.toLowerCase() === a.mailbox.toLowerCase())?.id;
if (!target) {
skippedActions.push(`move to “${a.mailbox}” (folder not found)`);
break;
}
if (target === mailboxId) break;
if (a.copy) await mail.addToMailbox(mids, target, true);
else await mail.move(mids, target, { silent: true });
break;
}
case "markread":
await mail.setKeyword(mids, "$seen", true);
break;
case "flag":
await mail.setKeyword(mids, "$flagged", true);
break;
case "addflag":
case "setflag":
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), true);
break;
case "removeflag":
if (a.flag) await mail.setKeyword(mids, normalizeFlag(a.flag), false);
break;
case "discard":
await mail.trash(mids);
break;
case "redirect":
skippedActions.push(`forward to ${a.address} (cannot resend existing mail)`);
break;
case "reject":
skippedActions.push("reject (cannot bounce existing mail)");
break;
default:
break;
}
}
void mail.refreshList();
void mail.loadMailboxes();
}
return { scanned: ids.length, matched: matched.length, skippedActions };
}
function normalizeFlag(flag: string): string {
const f = flag.trim();
if (/^\\\\?seen$/i.test(f)) return "$seen";
if (/^\\\\?flagged$/i.test(f)) return "$flagged";
if (/^\\\\?answered$/i.test(f)) return "$answered";
if (/^\\\\?draft$/i.test(f)) return "$draft";
return f.replace(/^\\+/, "");
}
/** Seed a rule from a message (used by "Filter messages like this"). */
export function ruleFromEmail(e: Email, currentMailboxId: Id | null): SieveRule {
const mail = useMail.getState();
const from = e.from?.[0]?.email ?? "";
const listId = e["header:List-Id:asText"];
const tests: SieveTest[] = listId ? [{ type: "header", header: "list-id", op: "contains", value: listId.replace(/^.*<|>.*$/g, "") }] : [{ type: "header", header: "from", op: "contains", value: from }];
const target = Object.values(mail.mailboxes).find((m) => !m.role && m.id !== currentMailboxId) ?? Object.values(mail.mailboxes).find((m) => m.role === "archive");
const name = listId ? `List: ${listId.replace(/^.*<|>.*$/g, "")}` : `From ${from}`;
return {
id: `r${Math.random().toString(36).slice(2, 9)}`,
name,
enabled: true,
join: "allof",
tests,
actions: [{ type: "fileinto", mailbox: target ? mail.mailboxPath(target.id) : "INBOX", mailboxId: target?.id }],
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Helpers to fit rich signatures into Stalwart's 2 KB identity signature limit:
* - compactHtml(): strips Office/Gmail cruft and non-essential inline styles
* - 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";
export const SIGNATURE_LIMIT = 2047;
const KEEP_STYLES = new Set(["color", "background-color", "font-weight", "font-style", "text-decoration", "font-size", "font-family", "text-align", "vertical-align", "width", "height", "max-width", "border", "border-left", "padding-left", "margin"]);
const KEEP_ATTRS = new Set(["href", "src", "alt", "width", "height", "target", "style", "title", "colspan", "rowspan", "cellpadding", "cellspacing", "border", "align", "valign"]);
const DROP_TAGS = new Set(["META", "STYLE", "SCRIPT", "LINK", "TITLE", "HEAD", "O:P", "XML", "NOSCRIPT", "IFRAME", "OBJECT", "EMBED", "FORM", "INPUT", "BUTTON"]);
export function compactHtml(input: string): string {
const doc = new DOMParser().parseFromString(`<div id="r">${input}</div>`, "text/html");
const root = doc.getElementById("r")!;
// Remove comments and junk elements
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
const comments: Node[] = [];
while (walker.nextNode()) comments.push(walker.currentNode);
comments.forEach((c) => c.parentNode?.removeChild(c));
Array.from(root.querySelectorAll("*"))
.filter((el) => DROP_TAGS.has(el.tagName.toUpperCase()) || el.tagName.includes(":"))
.forEach((n) => n.remove());
// Clean attributes and styles
root.querySelectorAll("*").forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (!KEEP_ATTRS.has(attr.name.toLowerCase())) el.removeAttribute(attr.name);
}
const style = el.getAttribute("style");
if (style) {
const kept = style
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const i = d.indexOf(":");
if (i < 0) return null;
const k = d.slice(0, i).trim().toLowerCase();
let v = d.slice(i + 1).trim();
if (!KEEP_STYLES.has(k) || v.startsWith("mso-") || /^(inherit|initial|unset)$/i.test(v)) return null;
if (k === "font-family") v = v.split(",")[0]!.trim();
if (k === "color" && /^(windowtext|black|#000000|#000|rgb\(0,\s*0,\s*0\))$/i.test(v)) return null;
if (k === "background-color" && /^(transparent|white|#fff(fff)?|rgb\(255,\s*255,\s*255\))$/i.test(v)) return null;
return `${k}:${v}`;
})
.filter(Boolean)
.join(";");
if (kept) el.setAttribute("style", kept);
else el.removeAttribute("style");
}
if (el.tagName === "A" && el.getAttribute("target")) el.removeAttribute("target");
});
// Unwrap meaningless spans/fonts and empty blocks (repeat until stable)
let changed = true;
let guard = 0;
while (changed && guard++ < 10) {
changed = false;
root.querySelectorAll("span,font,div,p,b,strong,i,em,u").forEach((el) => {
if (!el.parentNode) return;
const hasContent = (el.textContent ?? "").trim() !== "" || el.querySelector("img,br,hr,table");
if (!hasContent && el.tagName !== "BR") {
el.remove();
changed = true;
return;
}
if ((el.tagName === "SPAN" || el.tagName === "FONT") && el.attributes.length === 0) {
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
el.remove();
changed = true;
return;
}
// div/p containing only another single div/p: flatten
if ((el.tagName === "DIV" || el.tagName === "P") && el.attributes.length === 0 && el.childNodes.length === 1 && el.firstElementChild && (el.firstElementChild.tagName === "DIV" || el.firstElementChild.tagName === "P")) {
el.replaceWith(el.firstElementChild);
changed = true;
}
});
}
return root.innerHTML
.replace(/\s*\n\s*/g, " ")
.replace(/>\s+</g, "><")
.replace(/&nbsp;/g, " ")
.trim();
}
const MARKER_RE = /<!--ihasmail:sig=([A-Za-z0-9_-]+)(?::([\w/+.-]+))?-->/;
export function markerOf(htmlSignature: string | null | undefined): { blobId: string; type: string } | null {
const m = htmlSignature ? MARKER_RE.exec(htmlSignature) : null;
return m ? { blobId: m[1]!, type: m[2] ?? "text/html" } : null;
}
/** Build the short identity signature that points at a stored full signature. */
export function buildMarkerSignature(blobId: string, fullHtml: string): { htmlSignature: string; textSignature: string } {
const text = htmlToText(fullHtml);
const marker = `<!--ihasmail:sig=${blobId}:text/html-->`;
const budget = SIGNATURE_LIMIT - marker.length - 11; // <div></div>
let fallback = escapeHtml(text).replace(/\n/g, "<br>");
if (fallback.length > budget) fallback = `${fallback.slice(0, Math.max(0, budget - 1))}`;
return { htmlSignature: `${marker}<div>${fallback}</div>`, textSignature: text.length > SIGNATURE_LIMIT ? `${text.slice(0, SIGNATURE_LIMIT - 1)}` : text };
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Signature images: Stalwart caps identity signatures at 2 KB, so pictures can't
* be embedded as data: URLs. Instead we store them in JMAP Files (persistent
* blobs) under an "ihasmail" folder and reference them by blob URL; the composer
* turns such references into inline cid: parts when sending.
*/
import { CAP, client } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
const FOLDER = "ihasmail";
async function ensureFolder(accountId: string): Promise<string> {
let list: FileNode[] = [];
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
]);
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
} catch {
// Older servers: no filter support — scan everything.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "nodeType", "parentId"] }, "g"],
]);
list = (res.get("g")?.[0] as unknown as GetResponse<FileNode>).list;
}
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId: null, name: FOLDER, nodeType: "directory" } } });
const err = set.notCreated?.d;
if (err) throw new Error(err.description ?? err.type);
return set.created!.d!.id;
}
/** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) {
toast.error("Images in signatures need the Files feature, which this account doesn't have.");
throw new Error("filenode unavailable");
}
if (file.size > 512 * 1024) {
toast.error("Please use an image under 512 KB for signatures.");
throw new Error("too large");
}
try {
const type = file.type || "image/png";
const up = await client.upload(accountId, file, { type });
const folderId = await ensureFolder(accountId);
const name = `${Date.now()}-${file.name.replace(/[^\w.-]+/g, "_")}`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type } } });
const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type);
const created = res.created?.f as Partial<FileNode> | undefined;
// Prefer the node's (persistent) blobId if the server returned one.
const blobId = created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
return client.downloadUrl(accountId, blobId, name, type, true);
} catch (err) {
toast.error(`Could not store image: ${(err as Error).message}`);
throw err;
}
}
/** Store the full HTML of an over-sized signature in Files; returns the blob id. */
export async function storeSignatureHtml(html: string): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode);
if (!accountId || !client.hasCapability(CAP.filenode)) throw new Error("This signature is too long for the server and the Files feature (needed to store long signatures) is not available.");
const up = await client.upload(accountId, new Blob([html], { type: "text/html" }), { type: "text/html" });
const folderId = await ensureFolder(accountId);
const name = `signature-${Date.now()}.html`;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { f: { parentId: folderId, name, nodeType: "file", blobId: up.blobId, type: "text/html" } } });
const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type);
const created = res.created?.f as Partial<FileNode> | undefined;
return created?.blobId ?? (await nodeBlobId(accountId, created?.id)) ?? up.blobId;
}
/** Replace data: URL images (pasted pictures) in signature HTML with stored blob URLs. */
export async function externalizeDataImages(html: string): Promise<string> {
if (!html.includes("data:image/")) return html;
const doc = new DOMParser().parseFromString(`<div id="r">${html}</div>`, "text/html");
const root = doc.getElementById("r")!;
const imgs = Array.from(root.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
for (const img of imgs) {
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(img.getAttribute("src")!);
if (!m) {
img.remove();
continue;
}
const bin = atob(m[2]!);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const file = new File([bytes], `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, { type: m[1]! });
img.setAttribute("src", await uploadSignatureImage(file));
}
return root.innerHTML;
}
/** Load the full HTML of a marker signature. */
export async function loadStoredSignature(blobId: string, type = "text/html"): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode) ?? useSession.getState().accountId;
if (!accountId) throw new Error("no account");
return client.fetchBlobText(accountId, blobId, type);
}
async function nodeBlobId(accountId: string, id?: string): Promise<string | undefined> {
if (!id) return undefined;
try {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
return res.list[0]?.blobId ?? undefined;
} catch {
return undefined;
}
}
export type { QueryResponse };
+42
View File
@@ -0,0 +1,42 @@
const PREFIX = "ihasmail:";
export function loadJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
return { ...fallback, ...(JSON.parse(raw) as T) };
} catch {
return fallback;
}
}
export function loadRaw<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(PREFIX + key);
if (raw == null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function saveJson(key: string, value: unknown): void {
try {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
} catch {
/* quota exceeded or private mode */
}
}
export function removeKey(key: string): void {
try {
localStorage.removeItem(PREFIX + key);
} catch {
/* ignore */
}
}
/** Namespaced per account so multiple logins on one browser don't collide. */
export function accountKey(accountId: string | null | undefined, key: string): string {
return `${accountId ?? "anon"}:${key}`;
}
+194
View File
@@ -0,0 +1,194 @@
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
const URL_RE = /\b((?:https?:\/\/|www\.)[^\s<>"'()]+[^\s<>"'().,;:!?])/gi;
const EMAIL_RE = /\b([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})\b/gi;
/** Convert plain text into safe HTML with links and quote-level coloring. */
export function textToHtml(text: string, opts: { linkify?: boolean; quoteColors?: boolean } = {}): string {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
for (const line of lines) {
let depth = 0;
let rest = line;
if (opts.quoteColors !== false) {
const m = /^((?:>\s?)+)/.exec(line);
if (m) {
depth = (m[1]!.match(/>/g) ?? []).length;
rest = line.slice(m[1]!.length);
// keep markers visually
}
}
const html = opts.linkify === false ? escapeHtml(rest) : linkify(rest);
if (depth > 0) {
const marker = escapeHtml(line.slice(0, line.length - rest.length));
out.push(`<span class="q${Math.min(depth, 3)}">${marker}${html}</span>`);
} else out.push(html);
}
return out.join("\n");
}
/** Escape text while turning URLs / email addresses into links (tokenized so escaping never corrupts hrefs). */
function linkify(text: string): string {
const re = new RegExp(`${URL_RE.source}|${EMAIL_RE.source}`, "gi");
let out = "";
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
out += escapeHtml(text.slice(last, m.index));
const tok = m[0];
if (tok.includes("@") && !/^(https?:\/\/|www\.)/i.test(tok)) {
out += `<a href="mailto:${escapeHtml(tok)}">${escapeHtml(tok)}</a>`;
} else {
const href = tok.startsWith("www.") ? `http://${tok}` : tok;
out += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer nofollow">${escapeHtml(tok)}</a>`;
}
last = m.index + tok.length;
}
out += escapeHtml(text.slice(last));
return out;
}
/** Convert HTML to reasonably formatted plain text (for text/plain alternative + quoting). */
export function htmlToText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("script,style,head,title,noscript").forEach((n) => n.remove());
const out: string[] = [];
const walk = (node: Node, ctx: { pre: boolean; listIndex: number[]; quote: number }) => {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? "";
out.push(ctx.pre ? t : t.replace(/\s+/g, " "));
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const block = /^(p|div|section|article|header|footer|h[1-6]|ul|ol|li|table|tr|blockquote|pre|hr|br|address|center|dl|dt|dd|form|fieldset|figure|figcaption)$/.test(tag);
if (tag === "br") {
out.push("\n");
return;
}
if (tag === "hr") {
out.push("\n----------\n");
return;
}
if (tag === "img") {
const alt = el.getAttribute("alt");
if (alt) out.push(`[${alt}]`);
return;
}
if (block && tag !== "li") out.push("\n");
if (/^h[1-6]$/.test(tag)) out.push("\n");
if (tag === "li") {
const parent = el.parentElement;
if (parent?.tagName.toLowerCase() === "ol") {
const idx = (ctx.listIndex[ctx.listIndex.length - 1] ?? 0) + 1;
ctx.listIndex[ctx.listIndex.length - 1] = idx;
out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}${idx}. `);
} else out.push(`\n${" ".repeat(Math.max(0, ctx.listIndex.length - 1))}- `);
}
const nextCtx = { ...ctx };
if (tag === "pre") nextCtx.pre = true;
if (tag === "ul" || tag === "ol") nextCtx.listIndex = [...ctx.listIndex, 0];
if (tag === "blockquote") {
const start = out.length;
el.childNodes.forEach((c) => walk(c, nextCtx));
const inner = out.splice(start).join("");
out.push(
"\n" +
inner
.replace(/^\n+|\n+$/g, "")
.split("\n")
.map((l) => `> ${l}`)
.join("\n") +
"\n",
);
return;
}
if (tag === "a") {
const href = el.getAttribute("href") ?? "";
const start = out.length;
el.childNodes.forEach((c) => walk(c, nextCtx));
const inner = out.splice(start).join("");
const text = inner.trim();
if (href && !href.startsWith("mailto:") && text && text !== href && !href.startsWith("#")) out.push(`${text} <${href}>`);
else out.push(inner);
return;
}
if (tag === "td" || tag === "th") {
el.childNodes.forEach((c) => walk(c, nextCtx));
out.push("\t");
return;
}
el.childNodes.forEach((c) => walk(c, nextCtx));
if (block) out.push("\n");
};
doc.body.childNodes.forEach((c) => walk(c, { pre: false, listIndex: [], quote: 0 }));
return out
.join("")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Prefix every line with "> " for plain text quoting. */
export function quoteText(text: string): string {
return text
.replace(/\r\n?/g, "\n")
.split("\n")
.map((l) => (l.startsWith(">") ? `>${l}` : `> ${l}`))
.join("\n");
}
/** Wrap long lines at width for format=flowed-ish plain text. */
export function wrapText(text: string, width = 76): string {
return text
.split("\n")
.map((line) => {
if (line.length <= width || line.startsWith(">")) return line;
const words = line.split(" ");
const lines: string[] = [];
let cur = "";
for (const w of words) {
if ((cur + " " + w).trim().length > width && cur) {
lines.push(cur);
cur = w;
} else cur = cur ? `${cur} ${w}` : w;
}
if (cur) lines.push(cur);
return lines.join("\n");
})
.join("\n");
}
export function stripHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
return (doc.body.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** Normalize a subject for reply/forward: strip existing prefixes, add new. */
export function replySubject(subject: string | null | undefined, prefix: "Re" | "Fwd"): string {
const s = (subject ?? "").trim();
const stripped = s.replace(/^((re|fw|fwd|aw|sv|vs|tr|wg)\s*:\s*)+/i, "");
if (prefix === "Re" && /^re\s*:/i.test(s)) return s;
if (prefix === "Fwd" && /^(fwd?|fw)\s*:/i.test(s)) return s;
return `${prefix}: ${stripped}`;
}
/** Detect quoted section boundaries (for "show trimmed content"). Returns index in lines or -1. */
export function findQuoteStart(lines: string[]): number {
for (let i = 0; i < lines.length; i++) {
const l = lines[i]!;
if (/^On .+wrote:\s*$/.test(l) || /^-{3,}\s*Original Message\s*-{3,}$/i.test(l) || /^_{5,}$/.test(l) || /^From:\s.+$/.test(l) && i + 1 < lines.length && /^(Sent|Date|To):/.test(lines[i + 1] ?? "")) {
return i;
}
if (l.startsWith(">") && i > 0) {
// First run of quote lines after some content
let allQuoted = true;
for (let j = i; j < Math.min(lines.length, i + 3); j++) if (!lines[j]!.startsWith(">") && lines[j]!.trim() !== "") allQuoted = false;
if (allQuoted) return i;
}
}
return -1;
}