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,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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user