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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("<");
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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() : "";
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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); }
|
||||
`;
|
||||
@@ -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();
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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]!);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 }],
|
||||
};
|
||||
}
|
||||
@@ -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(/ /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 };
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user