ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+149
View File
@@ -0,0 +1,149 @@
import type { ContactCard, EmailAddress, JSContactName } from "@/jmap/types";
/** Best display name for a card. */
export function contactDisplayName(c: ContactCard): string {
const n = c.name;
if (n?.full?.trim()) return n.full.trim();
const comps = n?.components ?? [];
const ordered = comps.filter((x) => ["given", "given2", "surname", "surname2"].includes(x.kind));
if (ordered.length) {
// Prefer given + surname order regardless of isOrdered for display.
const given = comps.filter((x) => x.kind === "given" || x.kind === "given2").map((x) => x.value).join(" ");
const sur = comps.filter((x) => x.kind === "surname" || x.kind === "surname2").map((x) => x.value).join(" ");
const s = `${given} ${sur}`.trim();
if (s) return s;
}
if (c.kind === "group" || c.kind === "org") {
const org = Object.values(c.organizations ?? {})[0]?.name;
if (org) return org;
}
const nick = Object.values(c.nicknames ?? {})[0]?.name;
if (nick) return nick;
const org = Object.values(c.organizations ?? {})[0]?.name;
if (org) return org;
const email = primaryEmail(c);
if (email) return email;
return "(no name)";
}
export function nameParts(c: ContactCard): { given: string; surname: string; prefix: string; suffix: string; middle: string } {
const comps = c.name?.components ?? [];
const pick = (k: string) => comps.filter((x) => x.kind === k).map((x) => x.value).join(" ");
return { given: pick("given"), middle: pick("given2"), surname: pick("surname"), prefix: pick("title"), suffix: pick("credential") || pick("generation") };
}
export function buildName(parts: { given?: string; middle?: string; surname?: string; prefix?: string; suffix?: string }): JSContactName | undefined {
const components: JSContactName["components"] = [];
if (parts.prefix?.trim()) components.push({ "@type": "NameComponent", kind: "title", value: parts.prefix.trim() });
if (parts.given?.trim()) components.push({ "@type": "NameComponent", kind: "given", value: parts.given.trim() });
if (parts.middle?.trim()) components.push({ "@type": "NameComponent", kind: "given2", value: parts.middle.trim() });
if (parts.surname?.trim()) components.push({ "@type": "NameComponent", kind: "surname", value: parts.surname.trim() });
if (parts.suffix?.trim()) components.push({ "@type": "NameComponent", kind: "credential", value: parts.suffix.trim() });
if (!components.length) return undefined;
const full = [parts.prefix, parts.given, parts.middle, parts.surname, parts.suffix].map((s) => s?.trim()).filter(Boolean).join(" ");
return { "@type": "Name", components, isOrdered: true, full };
}
export function primaryEmail(c: ContactCard): string | null {
const emails = Object.values(c.emails ?? {});
if (!emails.length) return null;
const sorted = [...emails].sort((a, b) => (a.pref ?? 100) - (b.pref ?? 100));
return sorted[0]!.address;
}
export function contactEmails(c: ContactCard): EmailAddress[] {
const name = contactDisplayName(c);
return Object.values(c.emails ?? {}).map((e) => ({ name: name.includes("@") ? null : name, email: e.address }));
}
export function contactPhoto(c: ContactCard, accountId: string): string | null {
const m = Object.values(c.media ?? {}).find((x) => x.kind === "photo");
if (!m) return null;
if (m.uri) return m.uri.startsWith("data:") ? m.uri : null;
if (m.blobId) return `/api/blob/${encodeURIComponent(accountId)}/${encodeURIComponent(m.blobId)}/photo?accept=${encodeURIComponent(m.mediaType ?? "image/jpeg")}&inline=1`;
return null;
}
export function sortKey(c: ContactCard, by: "surname" | "given" = "given"): string {
const p = nameParts(c);
const k = by === "surname" ? `${p.surname} ${p.given}` : `${p.given} ${p.surname}`;
return (k.trim() || contactDisplayName(c)).toLowerCase();
}
export function formatAddressLines(a: { components?: Array<{ kind: string; value: string }>; full?: string }): string[] {
if (a.full) return a.full.split(/\n/);
const get = (k: string) =>
(a.components ?? [])
.filter((c) => c.kind === k)
.map((c) => c.value)
.join(" ");
const lines: string[] = [];
const street = [get("number"), get("name"), get("apartment"), get("building"), get("floor"), get("room")].filter(Boolean).join(" ");
const pobox = get("postOfficeBox");
if (pobox) lines.push(pobox);
if (street) lines.push(street);
const city = [get("locality"), get("region")].filter(Boolean).join(", ");
const cityLine = [city, get("postcode")].filter(Boolean).join(" ");
if (cityLine) lines.push(cityLine);
if (get("country")) lines.push(get("country"));
return lines;
}
/** Generate a vCard 4.0 for export. */
export function toVCard(c: ContactCard): string {
const esc = (s: string) => s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
const lines = ["BEGIN:VCARD", "VERSION:4.0"];
lines.push(`UID:${c.uid}`);
if (c.kind && c.kind !== "individual") lines.push(`KIND:${c.kind}`);
lines.push(`FN:${esc(contactDisplayName(c))}`);
const p = nameParts(c);
if (p.given || p.surname) lines.push(`N:${esc(p.surname)};${esc(p.given)};${esc(p.middle)};${esc(p.prefix)};${esc(p.suffix)}`);
for (const n of Object.values(c.nicknames ?? {})) lines.push(`NICKNAME:${esc(n.name)}`);
for (const e of Object.values(c.emails ?? {})) {
const types = Object.keys(e.contexts ?? {}).join(",");
lines.push(`EMAIL${types ? `;TYPE=${types}` : ""}${e.pref ? `;PREF=${e.pref}` : ""}:${e.address}`);
}
for (const ph of Object.values(c.phones ?? {})) {
const types = [...Object.keys(ph.contexts ?? {}), ...Object.keys(ph.features ?? {})].join(",");
lines.push(`TEL${types ? `;TYPE=${types}` : ""}${ph.pref ? `;PREF=${ph.pref}` : ""}:${ph.number}`);
}
for (const a of Object.values(c.addresses ?? {})) {
const get = (k: string) =>
(a.components ?? [])
.filter((x) => x.kind === k)
.map((x) => x.value)
.join(" ");
const street = [get("number"), get("name"), get("apartment")].filter(Boolean).join(" ");
const types = Object.keys(a.contexts ?? {}).join(",");
lines.push(`ADR${types ? `;TYPE=${types}` : ""}:${esc(get("postOfficeBox"))};;${esc(street)};${esc(get("locality"))};${esc(get("region"))};${esc(get("postcode"))};${esc(get("country"))}`);
}
for (const o of Object.values(c.organizations ?? {})) lines.push(`ORG:${esc(o.name ?? "")}${(o.units ?? []).map((u) => `;${esc(u.name)}`).join("")}`);
for (const t of Object.values(c.titles ?? {})) lines.push(`${t.kind === "role" ? "ROLE" : "TITLE"}:${esc(t.name)}`);
for (const an of Object.values(c.anniversaries ?? {})) {
const d = an.date;
const v = d.utc ? d.utc.slice(0, 10).replace(/-/g, "") : `${d.year ?? "--"}${String(d.month ?? 0).padStart(2, "0")}${String(d.day ?? 0).padStart(2, "0")}`;
if (an.kind === "birth") lines.push(`BDAY:${v}`);
else if (an.kind === "wedding") lines.push(`ANNIVERSARY:${v}`);
}
for (const n of Object.values(c.notes ?? {})) lines.push(`NOTE:${esc(n.note)}`);
for (const l of Object.values(c.links ?? {})) lines.push(`URL:${l.uri}`);
for (const s of Object.values(c.onlineServices ?? {})) if (s.uri) lines.push(`IMPP:${s.uri}`);
if (c.members) for (const m of Object.keys(c.members)) lines.push(`MEMBER:${m}`);
lines.push("END:VCARD");
return lines.map(fold).join("\r\n") + "\r\n";
}
function fold(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
out.push((i ? " " : "") + line.slice(i, i + 74));
i += 74;
}
return out.join("\r\n");
}
export function newKey(prefix = "k"): string {
return `${prefix}${Math.random().toString(36).slice(2, 8)}`;
}