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
+338
View File
@@ -0,0 +1,338 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, GetResponse, Id, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { settings } from "./settings";
import { useSession } from "./session";
export interface EventInstance {
/** Unique key for rendering: `${id}` (synthetic ids already unique per instance). */
key: string;
event: CalendarEvent;
start: Date;
end: Date;
allDay: boolean;
calendar: Calendar | undefined;
}
interface CalendarState {
accountId: Id | null;
available: boolean;
calendars: Record<Id, Calendar>;
events: Record<Id, CalendarEvent>;
/** Loaded ranges keyed "start|end" → event ids */
ranges: Record<string, Id[]>;
loading: boolean;
error: string | null;
identities: ParticipantIdentity[];
hidden: Record<Id, true>;
init(): Promise<void>;
loadCalendars(): Promise<void>;
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
instancesIn(start: Date, end: Date): EventInstance[];
getEvent(id: Id): Promise<CalendarEvent | null>;
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
updateEvent(id: Id, patch: Record<string, unknown>, sendInvites: boolean): Promise<void>;
destroyEvent(id: Id, sendInvites: boolean): Promise<void>;
rsvp(id: Id, status: "accepted" | "tentative" | "declined", comment?: string): Promise<void>;
createCalendar(data: Partial<Calendar>): Promise<Id>;
updateCalendar(id: Id, patch: Partial<Calendar>): Promise<void>;
destroyCalendar(id: Id): Promise<void>;
toggleHidden(id: Id): void;
availability(principalId: Id, start: Date, end: Date): Promise<BusyPeriod[]>;
findByUid(uid: string): Promise<CalendarEvent | null>;
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
applyChanges(types: Set<string>): void;
invalidate(): void;
}
/**
* Explicit property list: when `properties` is null Stalwart omits the JMAP-only
* fields baseEventId / utcStart / utcEnd, and we need baseEventId to update
* recurring instances (synthetic ids can't be patched directly).
*/
const EVENT_PROPS = [
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
"recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo",
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
];
export const useCalendar = create<CalendarState>((set, get) => ({
accountId: null,
available: false,
calendars: {},
events: {},
ranges: {},
loading: false,
error: null,
identities: [],
hidden: {},
async init() {
const accountId = useSession.getState().accountFor(CAP.calendars);
const available = Boolean(accountId && client.hasCapability(CAP.calendars));
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available });
if (!available) return;
await get().loadCalendars();
try {
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
} catch {
set({ identities: [] });
}
},
async loadCalendars() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null });
const calendars: Record<Id, Calendar> = {};
for (const c of res.list) calendars[c.id] = c;
set({ calendars, error: null });
} catch (err) {
set({ error: (err as Error).message });
}
},
async loadRange(start, end, force = false) {
const accountId = get().accountId;
if (!accountId) return;
const key = `${start.getTime()}|${end.getTime()}`;
if (!force && get().ranges[key]) return;
set({ loading: true });
const tz = settings().timeZone ?? browserTimeZone;
try {
const res = await client.chain([
[
"CalendarEvent/query",
{
accountId,
// Stalwart treats after/before as wall-clock times in `timeZone`.
filter: { after: toLocalDateTime(start), before: toLocalDateTime(end) },
timeZone: tz,
sort: [{ property: "start", isAscending: true }],
expandRecurrences: true,
limit: 2000,
},
"q",
],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: tz }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
set((s) => {
const events = { ...s.events };
for (const e of g.list) events[e.id] = e;
return { events, ranges: { ...s.ranges, [key]: q.ids }, loading: false, error: null };
});
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
instancesIn(start, end) {
const { events, ranges, calendars, hidden } = get();
const ids = new Set<Id>();
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
const out: EventInstance[] = [];
for (const id of ids) {
const e = events[id];
if (!e) continue;
const calId = Object.keys(e.calendarIds ?? {})[0];
if (calId && hidden[calId]) continue;
const inst = toInstance(e, calendars);
if (!inst) continue;
if (inst.end > start && inst.start < end) out.push(inst);
}
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
return out;
},
async getEvent(id) {
const accountId = get().accountId;
if (!accountId) return null;
const res = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: [id], properties: EVENT_PROPS });
const e = res.list[0];
if (e) set((s) => ({ events: { ...s.events, [e.id]: e } }));
return e ?? null;
},
async createEvent(event, calendarId, sendInvites) {
const accountId = get().accountId!;
const obj = { "@type": "Event", uid: crypto.randomUUID(), ...event, calendarIds: { [calendarId]: true } };
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: { e: obj }, sendSchedulingMessages: sendInvites });
const err = res.notCreated?.e;
if (err) throw new Error(err.description ?? err.type);
get().invalidate();
return res.created!.e!.id;
},
async updateEvent(id, patch, sendInvites) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, update: { [id]: patch }, sendSchedulingMessages: sendInvites });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
get().invalidate();
},
async destroyEvent(id, sendInvites) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
set((s) => {
const events = { ...s.events };
delete events[id];
return { events };
});
get().invalidate();
},
async rsvp(id, status, comment) {
const ev = get().events[id] ?? (await get().getEvent(id));
if (!ev) throw new Error("Event not found");
id = ev.baseEventId ?? id;
const mine = myParticipantKeys(ev, get().identities);
if (!mine.length) throw new Error("You are not a participant of this event");
const patch: Record<string, unknown> = {};
for (const k of mine) {
patch[`participants/${k}/participationStatus`] = status;
if (comment) patch[`participants/${k}/participationComment`] = comment;
}
await get().updateEvent(id, patch, true);
},
async createCalendar(data) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<Calendar>>("Calendar/set", { accountId, create: { c: { name: "Calendar", ...data } } });
const err = res.notCreated?.c;
if (err) throw new Error(err.description ?? err.type);
await get().loadCalendars();
return res.created!.c!.id;
},
async updateCalendar(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadCalendars();
},
async destroyCalendar(id) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Calendar/set", { accountId, destroy: [id], onDestroyRemoveEvents: true });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadCalendars();
get().invalidate();
},
toggleHidden(id) {
set((s) => {
const hidden = { ...s.hidden };
if (hidden[id]) delete hidden[id];
else hidden[id] = true;
return { hidden };
});
},
async availability(principalId, start, end) {
const accountId = useSession.getState().accountFor(CAP.principals);
if (!accountId || !client.hasCapability(CAP.availability)) return [];
const res = await client.call<{ list: BusyPeriod[] }>("Principal/getAvailability", { accountId, id: principalId, utcStart: toUTCDate(start), utcEnd: toUTCDate(end), showDetails: false }, [CAP.principals, CAP.availability]);
return res.list ?? [];
},
async findByUid(uid) {
const accountId = get().accountId;
if (!accountId) return null;
try {
const res = await client.chain([
["CalendarEvent/query", { accountId, filter: { uid }, limit: 1 }, "q"],
["CalendarEvent/get", { accountId, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<CalendarEvent>;
const e = g.list[0];
if (e) set((s) => ({ events: { ...s.events, [e.id]: e } }));
return e ?? null;
} catch {
return null;
}
},
async parseIcs(blobId) {
const accountId = get().accountId;
if (!accountId) return [];
const res = await client.call<{ parsed?: Record<string, CalendarEvent[] | CalendarEvent>; notParsable?: Id[] }>("CalendarEvent/parse", { accountId, blobIds: [blobId] });
const entry = res.parsed?.[blobId];
if (!entry) return [];
return Array.isArray(entry) ? entry : [entry];
},
async importEvent(event, calendarId) {
const { id: _id, calendarIds: _c, baseEventId: _b, utcStart: _us, utcEnd: _ue, isOrigin: _io, method: _m, ...rest } = event as CalendarEvent & { method?: string };
return get().createEvent(rest, calendarId, false);
},
applyChanges(types) {
if (types.has("Calendar")) void get().loadCalendars();
if (types.has("CalendarEvent")) get().invalidate();
},
invalidate() {
// Force reload of all ranges currently cached.
const keys = Object.keys(get().ranges);
set({ ranges: {} });
for (const k of keys) {
const [s, e] = k.split("|").map(Number) as [number, number];
void get().loadRange(new Date(s), new Date(e), true);
}
},
}));
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
const allDay = Boolean(e.showWithoutTime);
let start: Date;
let end: Date;
if (e.utcStart && e.utcEnd && !allDay) {
start = new Date(e.utcStart);
end = new Date(e.utcEnd);
} else {
const tz = allDay ? null : e.timeZone;
start = zonedToDate(e.start, tz);
const dur = parseDuration(e.duration);
end = new Date(start.getTime() + (dur || (allDay ? 86400 : 0)) * 1000);
if (allDay && end.getTime() - start.getTime() < DAY_MS) end = new Date(start.getTime() + DAY_MS);
}
if (Number.isNaN(start.getTime())) return null;
if (end <= start) end = new Date(start.getTime() + (allDay ? DAY_MS : 30 * 60_000));
const calId = Object.keys(e.calendarIds ?? {})[0];
return { key: e.id, event: e, start, end, allDay, calendar: calId ? calendars[calId] : undefined };
}
export function myParticipantKeys(ev: CalendarEvent, identities: ParticipantIdentity[]): string[] {
const mine = new Set<string>();
for (const i of identities) {
mine.add(i.calendarAddress.toLowerCase());
for (const v of Object.values(i.sendTo ?? {})) mine.add(v.toLowerCase());
}
const session = useSession.getState().session;
if (session?.username?.includes("@")) mine.add(`mailto:${session.username.toLowerCase()}`);
const keys: string[] = [];
for (const [k, p] of Object.entries(ev.participants ?? {})) {
const addrs = [...Object.values(p.sendTo ?? {}), p.email ? `mailto:${p.email}` : ""].map((a) => a.toLowerCase());
if (addrs.some((a) => mine.has(a))) keys.push(k);
}
return keys;
}
useSession.subscribe((s) => {
if (s.status !== "authenticated") useCalendar.setState({ accountId: null, calendars: {}, events: {}, ranges: {}, identities: [] });
});
+678
View File
@@ -0,0 +1,678 @@
import { create } from "zustand";
import { client } from "@/jmap/client";
import type { Email, EmailAddress, EmailBodyPart, Id, Identity, SetResponse } from "@/jmap/types";
import { formatFullDate, uid } from "@/lib/format";
import { formatAddress, sameAddress, uniqueAddresses } from "@/lib/address";
import { escapeHtml, htmlToText, quoteText, replySubject, textToHtml } from "@/lib/text";
import { sanitizeEmailHtml, sanitizeEditorHtml } from "@/lib/html";
import { toast } from "@/ui/toast";
import { useMail, FULL_PROPS, BODY_PROPS } from "./mail";
import { settings } from "./settings";
export interface ComposeAttachment {
id: string;
name: string;
type: string;
size: number;
blobId: Id | null;
progress: number;
error: string | null;
file?: File;
cid?: string;
inline?: boolean;
abort?: AbortController;
}
export type Priority = "high" | "normal" | "low";
export interface Draft {
key: string;
draftId: Id | null;
identityId: Id | null;
to: EmailAddress[];
cc: EmailAddress[];
bcc: EmailAddress[];
/** Per-message Reply-To (defaults to the identity's Reply-To). */
replyTo: EmailAddress[];
subject: string;
html: string;
text: string;
format: "html" | "text";
attachments: ComposeAttachment[];
inReplyTo: string[] | null;
references: string[] | null;
relatedEmailId: Id | null;
relatedKeyword: "$answered" | "$forwarded" | null;
requestReceipt: boolean;
priority: Priority;
showCc: boolean;
showBcc: boolean;
showReplyTo: boolean;
minimized: boolean;
maximized: boolean;
dirty: boolean;
savedAt: number | null;
saving: boolean;
sending: boolean;
error: string | null;
/** Original identity signature HTML currently embedded, to replace on identity switch. */
signatureHtml: string;
replyMode: "reply" | "replyAll" | "forward" | null;
mailboxIdOnSend?: Id | null;
}
interface ComposeState {
drafts: Draft[];
activeKey: string | null;
pendingSends: Record<string, { timer: number; toastId: number; draft: Draft }>;
open(init?: Partial<Draft>): string;
openDraftEmail(email: Email): Promise<string>;
reply(email: Email, mode: "reply" | "replyAll" | "forward", opts?: { all?: boolean }): Promise<string>;
update(key: string, patch: Partial<Draft>): void;
close(key: string, opts?: { discard?: boolean }): Promise<void>;
focus(key: string): void;
addFiles(key: string, files: File[]): void;
removeAttachment(key: string, attId: string): void;
saveDraft(key: string, opts?: { silent?: boolean }): Promise<Id | null>;
send(key: string): Promise<void>;
undoSend(key: string): void;
setIdentity(key: string, identityId: Id): void;
insertTemplate(key: string, html: string, subject?: string): void;
}
const AUTOSAVE_MS = 20_000;
const autosaveTimers = new Map<string, number>();
function blankDraft(init: Partial<Draft> = {}): Draft {
const s = settings();
return {
key: uid("d"),
draftId: null,
identityId: null,
to: [],
cc: [],
bcc: [],
replyTo: [],
subject: "",
html: "",
text: "",
format: s.composeFormat,
attachments: [],
inReplyTo: null,
references: null,
relatedEmailId: null,
relatedKeyword: null,
requestReceipt: s.requestReadReceipt,
priority: "normal",
showCc: false,
showBcc: false,
showReplyTo: false,
minimized: false,
maximized: false,
dirty: false,
savedAt: null,
saving: false,
sending: false,
error: null,
signatureHtml: "",
replyMode: null,
...init,
};
}
export function signatureBlock(identity: Identity | undefined, format: "html" | "text"): string {
if (!identity) return "";
if (format === "text") return identity.textSignature ? `\n\n-- \n${identity.textSignature}` : "";
if (identity.htmlSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>${sanitizeEditorHtml(identity.htmlSignature)}</div>`;
if (identity.textSignature) return `<div class="ihm-signature" data-ihm-sig="1"><br>-- <br>${textToHtml(identity.textSignature, { quoteColors: false }).replace(/\n/g, "<br>")}</div>`;
return "";
}
function defaultIdentity(identities: Identity[], email?: Email | null): Identity | undefined {
if (!identities.length) return undefined;
if (email) {
const candidates = [...(email.to ?? []), ...(email.cc ?? []), ...(email.bcc ?? [])];
for (const c of candidates) {
const m = identities.find((i) => sameAddress(i.email, c.email));
if (m) return m;
}
}
return useMail.getState().defaultIdentity() ?? identities[0];
}
export const useCompose = create<ComposeState>((set, get) => ({
drafts: [],
activeKey: null,
pendingSends: {},
open(init = {}) {
const identities = useMail.getState().identities;
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
if (!init.html && !init.text && ident) {
d.signatureHtml = signatureBlock(ident, "html");
d.html = `<div><br></div>${d.signatureHtml}`;
d.text = signatureBlock(ident, "text");
}
set((s) => ({ drafts: [...s.drafts.map((x) => ({ ...x, minimized: s.drafts.length >= 1 ? x.minimized : x.minimized })), d], activeKey: d.key }));
return d.key;
},
async openDraftEmail(email) {
const existing = get().drafts.find((d) => d.draftId === email.id);
if (existing) {
get().focus(existing.key);
return existing.key;
}
const full = (await useMail.getState().getEmails([email.id], true))[0] ?? email;
const identities = useMail.getState().identities;
const ident = identities.find((i) => full.from?.some((f) => sameAddress(f.email, i.email))) ?? useMail.getState().defaultIdentity() ?? identities[0];
const htmlPart = full.htmlBody?.[0];
const textPart = full.textBody?.[0];
const html = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const text = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
const accountId = useMail.getState().accountId!;
const cidMap: Record<string, string> = {};
const attachments: ComposeAttachment[] = [];
for (const a of full.attachments ?? []) {
const inline = Boolean(a.cid) && (a.disposition === "inline" || a.type.startsWith("image/"));
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
}
const d = blankDraft({
draftId: full.id,
identityId: ident?.id ?? null,
to: full.to ?? [],
cc: full.cc ?? [],
bcc: full.bcc ?? [],
replyTo: full.replyTo ?? ident?.replyTo ?? [],
showReplyTo: Boolean(full.replyTo?.length || ident?.replyTo?.length),
showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat,
attachments,
inReplyTo: full.inReplyTo ?? null,
references: full.references ?? null,
requestReceipt: Boolean(full["header:Disposition-Notification-To:asAddresses"]?.length),
priority: /^[12]/.test(full["header:X-Priority:asText"] ?? "") ? "high" : /^[45]/.test(full["header:X-Priority:asText"] ?? "") ? "low" : "normal",
});
set((s) => ({ drafts: [...s.drafts, d], activeKey: d.key }));
return d.key;
},
async reply(email, mode) {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const ident = defaultIdentity(identities, full);
const ownEmails = identities.map((i) => i.email.toLowerCase());
const isOwn = (a: EmailAddress) => ownEmails.includes(a.email.toLowerCase());
const s = settings();
let to: EmailAddress[] = [];
let cc: EmailAddress[] = [];
if (mode === "reply" || mode === "replyAll") {
const replyTo = full.replyTo?.length ? full.replyTo : (full.from ?? []);
to = uniqueAddresses(replyTo);
if (mode === "replyAll") {
const others = uniqueAddresses([...(full.to ?? []), ...(full.cc ?? [])]).filter((a) => !isOwn(a) && !to.some((t) => sameAddress(t.email, a.email)));
cc = others;
// If the message was sent by me, reply to original recipients instead.
if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to);
cc = uniqueAddresses(full.cc ?? []).filter((a) => !isOwn(a));
}
} else if (to.every(isOwn) && full.to?.length) {
to = uniqueAddresses(full.to.filter((a) => !isOwn(a)));
if (!to.length) to = uniqueAddresses(full.to);
}
}
const htmlPart = full.htmlBody?.[0];
const textPart = full.textBody?.[0];
const origHtml = htmlPart?.partId ? (full.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
const origText = textPart?.partId ? (full.bodyValues?.[textPart.partId]?.value ?? "") : "";
const accountId = mail.accountId!;
const attachments: ComposeAttachment[] = [];
const cidMap: Record<string, string> = {};
for (const a of full.attachments ?? []) {
const inline = Boolean(a.cid) && a.type.startsWith("image/");
if (inline && a.cid && a.blobId) cidMap[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
if (mode === "forward" || inline) {
attachments.push({ id: uid("a"), name: a.name ?? "attachment", type: a.type, size: a.size, blobId: a.blobId, progress: 100, error: null, cid: a.cid ?? undefined, inline });
}
}
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
const quotedHtmlBody = origHtml
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false }).html
: textToHtml(origText).replace(/\n/g, "<br>");
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
const date = formatFullDate(full.receivedAt);
let quoteHtml = "";
let quoteTxt = "";
if (mode === "forward") {
const hdr = [
`From: ${(full.from ?? []).map(formatAddress).join(", ")}`,
`Date: ${date}`,
`Subject: ${full.subject ?? ""}`,
`To: ${(full.to ?? []).map(formatAddress).join(", ")}`,
...(full.cc?.length ? [`Cc: ${full.cc.map(formatAddress).join(", ")}`] : []),
];
quoteHtml = `<div class="ihm-quote"><br><div>---------- Forwarded message ---------</div><div>${hdr.map(escapeHtml).join("<br>")}</div><br>${quotedHtmlBody}</div>`;
quoteTxt = `\n\n---------- Forwarded message ---------\n${hdr.join("\n")}\n\n${origText || (origHtml ? htmlToText(origHtml) : "")}`;
} else if (s.includeQuote) {
quoteHtml = `<div class="ihm-quote"><br><div>On ${escapeHtml(date)}, ${fromStr} wrote:</div><blockquote style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">${quotedHtmlBody}</blockquote></div>`;
quoteTxt = `\n\nOn ${date}, ${(full.from ?? []).map(formatAddress).join(", ")} wrote:\n${quoteTextOf(origText, origHtml)}`;
}
const sigHtml = signatureBlock(ident, "html");
const sigText = signatureBlock(ident, "text");
const html = s.signatureAboveQuote ? `<div><br></div>${sigHtml}${quoteHtml}` : `<div><br></div>${quoteHtml}${sigHtml}`;
const text = s.signatureAboveQuote ? `${sigText}${quoteTxt}` : `${quoteTxt}${sigText}`;
const messageId = full.messageId?.[0];
const d = blankDraft({
identityId: ident?.id ?? null,
to,
cc,
showCc: cc.length > 0,
replyTo: ident?.replyTo ?? [],
showReplyTo: Boolean(ident?.replyTo?.length),
subject: replySubject(full.subject, mode === "forward" ? "Fwd" : "Re"),
html,
text,
format: s.composeFormat,
attachments,
inReplyTo: mode === "forward" ? null : messageId ? [messageId] : null,
references: mode === "forward" ? null : messageId ? [...(full.references ?? []), messageId] : (full.references ?? null),
relatedEmailId: full.id,
relatedKeyword: mode === "forward" ? "$forwarded" : "$answered",
signatureHtml: sigHtml,
replyMode: mode,
});
set((st) => ({ drafts: [...st.drafts, d], activeKey: d.key }));
return d.key;
},
update(key, patch) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, ...patch, dirty: patch.dirty ?? (d.dirty || isContentPatch(patch)) } : d)) }));
if (isContentPatch(patch)) scheduleAutosave(key, get);
},
async close(key, opts = {}) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.delete(key);
for (const a of d.attachments) a.abort?.abort();
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? (s.drafts.find((x) => x.key !== key)?.key ?? null) : s.activeKey }));
if (opts.discard) {
if (d.draftId) {
try {
await client.call("Email/set", { accountId: useMail.getState().accountId, destroy: [d.draftId] });
void useMail.getState().refreshList();
void useMail.getState().loadMailboxes();
} catch {
/* ignore */
}
}
toast.show("Draft discarded");
return;
}
if (d.dirty && (d.to.length || d.subject || hasContent(d))) {
try {
await saveDraftInternal(d, get, set, { silent: true, final: true });
toast.show("Draft saved");
} catch (err) {
toast.error(`Could not save draft: ${(err as Error).message}`);
}
}
},
focus(key) {
set((s) => ({ activeKey: key, drafts: s.drafts.map((d) => (d.key === key ? { ...d, minimized: false } : d)) }));
},
addFiles(key, files) {
const accountId = useMail.getState().accountId;
if (!accountId) return;
const max = client.maxSizeUpload;
const atts: ComposeAttachment[] = files.map((f) => ({ id: uid("a"), name: f.name, type: f.type || "application/octet-stream", size: f.size, blobId: null, progress: 0, error: f.size > max ? `Larger than ${Math.round(max / 1048576)} MB limit` : null, file: f }));
get().update(key, { attachments: [...(get().drafts.find((d) => d.key === key)?.attachments ?? []), ...atts] });
for (const a of atts) {
if (a.error || !a.file) continue;
const abort = new AbortController();
a.abort = abort;
client
.upload(accountId, a.file, {
type: a.type,
signal: abort.signal,
onProgress: (loaded, total) => patchAtt(key, a.id, { progress: Math.round((loaded / total) * 100) }, set),
})
.then((res) => patchAtt(key, a.id, { blobId: res.blobId, progress: 100, type: res.type || a.type, size: res.size }, set))
.catch((err) => patchAtt(key, a.id, { error: (err as Error).message || "Upload failed" }, set));
}
},
removeAttachment(key, attId) {
const d = get().drafts.find((x) => x.key === key);
const a = d?.attachments.find((x) => x.id === attId);
a?.abort?.abort();
get().update(key, { attachments: (d?.attachments ?? []).filter((x) => x.id !== attId) });
},
async saveDraft(key, opts = {}) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return null;
try {
return await saveDraftInternal(d, get, set, { silent: opts.silent ?? false });
} catch (err) {
if (!opts.silent) toast.error(`Could not save draft: ${(err as Error).message}`);
return null;
}
},
async send(key) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const delay = settings().undoSendSeconds;
// Hide the composer immediately; actually send after the undo window.
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.delete(key);
set((s) => ({ drafts: s.drafts.filter((x) => x.key !== key), activeKey: s.activeKey === key ? null : s.activeKey }));
const doSend = async () => {
set((s) => {
const { [key]: _drop, ...rest } = s.pendingSends;
return { pendingSends: rest };
});
try {
await sendInternal(d, get);
toast.success("Message sent");
} catch (err) {
toast.error(`Send failed: ${(err as Error).message}`, {
action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) },
duration: 15000,
});
}
};
if (delay <= 0) {
await doSend();
return;
}
const toastId = toast.show("Sending…", { duration: delay * 1000, progress: true, action: { label: "Undo", onClick: () => get().undoSend(key) } });
const timer = window.setTimeout(() => void doSend(), delay * 1000);
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } }));
},
undoSend(key) {
const p = get().pendingSends[key];
if (!p) return;
window.clearTimeout(p.timer);
toast.dismiss(p.toastId);
set((s) => {
const { [key]: _drop, ...rest } = s.pendingSends;
return { pendingSends: rest, drafts: [...s.drafts, { ...p.draft, sending: false }], activeKey: key };
});
},
setIdentity(key, identityId) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const ident = useMail.getState().identities.find((i) => i.id === identityId);
const newSig = signatureBlock(ident, "html");
let html = d.html;
if (d.signatureHtml && html.includes(d.signatureHtml)) html = html.replace(d.signatureHtml, newSig);
else if (!d.signatureHtml && newSig) {
// insert before quote if any, else append
const idx = html.indexOf('<div class="ihm-quote">');
html = idx >= 0 ? html.slice(0, idx) + newSig + html.slice(idx) : html + newSig;
}
// Plain text: replace trailing signature block
const oldSigText = signatureBlock(useMail.getState().identities.find((i) => i.id === d.identityId), "text");
let text = d.text;
if (oldSigText && text.includes(oldSigText)) text = text.replace(oldSigText, signatureBlock(ident, "text"));
const oldIdent = useMail.getState().identities.find((i) => i.id === d.identityId);
const sameList = (a: EmailAddress[], b: EmailAddress[]) => a.length === b.length && a.every((x, i) => sameAddress(x.email, b[i]?.email));
const replyToPatch = sameList(d.replyTo, oldIdent?.replyTo ?? []) ? { replyTo: ident?.replyTo ?? [], showReplyTo: d.showReplyTo || Boolean(ident?.replyTo?.length) } : {};
get().update(key, { identityId, html, text, signatureHtml: newSig, ...replyToPatch });
},
insertTemplate(key, html, subject) {
const d = get().drafts.find((x) => x.key === key);
if (!d) return;
const patch: Partial<Draft> = { html: `<div>${sanitizeEditorHtml(html)}</div>${d.html}`, text: `${htmlToText(html)}\n${d.text}` };
if (subject && !d.subject) patch.subject = subject;
get().update(key, patch);
},
}));
function quoteTextOf(text: string, html: string): string {
const base = text || (html ? htmlToText(html) : "");
return quoteText(base);
}
function isContentPatch(p: Partial<Draft>): boolean {
return ["to", "cc", "bcc", "replyTo", "subject", "html", "text", "attachments", "identityId", "format", "priority", "requestReceipt"].some((k) => k in p);
}
function hasContent(d: Draft): boolean {
const body = d.format === "html" ? htmlToText(d.html.replace(/<div class="ihm-quote">[\s\S]*$/, "")) : d.text;
return body.replace(/--\s*[\s\S]*$/, "").trim().length > 0 || d.attachments.length > 0;
}
function patchAtt(key: string, attId: string, patch: Partial<ComposeAttachment>, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void) {
set((s) => ({ drafts: s.drafts.map((d) => (d.key === key ? { ...d, dirty: true, attachments: d.attachments.map((a) => (a.id === attId ? { ...a, ...patch } : a)) } : d)) }));
}
function scheduleAutosave(key: string, get: () => ComposeState) {
const t = autosaveTimers.get(key);
if (t) window.clearTimeout(t);
autosaveTimers.set(
key,
window.setTimeout(() => {
autosaveTimers.delete(key);
const d = get().drafts.find((x) => x.key === key);
if (d && d.dirty && !d.sending && (d.to.length || d.subject || hasContent(d))) void get().saveDraft(key, { silent: true });
}, AUTOSAVE_MS),
);
}
/** Build the JMAP Email creation object from a draft. */
async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
if (!ident) throw new Error("No sending identity available");
const from: EmailAddress = { name: ident.name || null, email: ident.email };
let html = d.format === "html" ? d.html : "";
const text = d.format === "html" ? htmlToText(d.html) : d.text;
// Inline attachments shown via blob URLs in the editor → back to cid: references.
for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html) {
const re = new RegExp(`/api/blob/[^"' )]*${a.blobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^"' )]*`, "g");
html = html.replace(re, `cid:${a.cid}`);
}
}
// Inline images (data: URLs from the editor) → upload and reference by cid.
const related: EmailBodyPart[] = [];
const relatedInline: Array<{ blobId: Id; type: string; name: string; cid: string }> = [];
// Images referencing stored blobs (e.g. signature logos kept in Files) → inline cid parts.
if (html && html.includes("/api/blob/")) {
const doc = new DOMParser().parseFromString(html, "text/html");
for (const img of Array.from(doc.querySelectorAll("img"))) {
const src = img.getAttribute("src") ?? "";
const m = /^\/api\/blob\/([^/]+)\/([^/]+)\/([^?]+)(?:\?([^#]*))?/.exec(src);
if (!m) continue;
const blobId = decodeURIComponent(m[2]!);
const name = decodeURIComponent(m[3]!);
const type = new URLSearchParams(m[4] ?? "").get("accept") ?? "image/png";
const cid = `${uid("img")}@ihasmail`;
img.setAttribute("src", `cid:${cid}`);
relatedInline.push({ blobId, type, name, cid });
}
html = doc.body.innerHTML;
}
if (html && html.includes("data:image/")) {
const doc = new DOMParser().parseFromString(html, "text/html");
const imgs = Array.from(doc.querySelectorAll("img")).filter((i) => i.getAttribute("src")?.startsWith("data:image/"));
for (const img of imgs) {
const src = img.getAttribute("src")!;
const m = /^data:(image\/[\w.+-]+);base64,(.*)$/s.exec(src);
if (!m) 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 up = await client.upload(accountId, new Blob([bytes], { type: m[1]! }), { type: m[1]! });
const cid = `${uid("img")}@ihasmail`;
img.setAttribute("src", `cid:${cid}`);
relatedInline.push({ blobId: up.blobId, type: m[1]!, name: `image.${m[1]!.split("/")[1]?.replace("jpeg", "jpg") ?? "png"}`, cid });
}
html = doc.body.innerHTML;
}
// Existing inline attachments referenced via cid (from reply/forward/draft) stay as related parts.
for (const a of d.attachments) {
if (a.inline && a.cid && a.blobId && html.includes(`cid:${a.cid}`)) relatedInline.push({ blobId: a.blobId, type: a.type, name: a.name, cid: a.cid });
}
for (const r of relatedInline) {
related.push({ partId: null, blobId: r.blobId, size: 0, name: r.name, type: r.type, charset: null, disposition: "inline", cid: r.cid });
}
const bodyValues: Record<string, { value: string }> = {};
const alternative: Record<string, unknown>[] = [];
bodyValues.text = { value: text };
alternative.push({ partId: "text", type: "text/plain" });
if (html) {
bodyValues.html = { value: wrapHtmlDocument(html) };
const htmlPart: Record<string, unknown> = { partId: "html", type: "text/html" };
if (related.length) alternative.push({ type: "multipart/related", subParts: [htmlPart, ...related.map(stripPart)] });
else alternative.push(htmlPart);
}
const regular = d.attachments.filter((a) => !a.inline && a.blobId && !a.error);
let bodyStructure: Record<string, unknown>;
const alt = html ? { type: "multipart/alternative", subParts: alternative } : alternative[0]!;
if (regular.length) {
bodyStructure = { type: "multipart/mixed", subParts: [alt, ...regular.map((a) => ({ blobId: a.blobId, type: a.type, name: a.name, disposition: "attachment" }))] };
} else bodyStructure = alt;
const obj: Record<string, unknown> = {
from: [from],
to: d.to.length ? d.to : null,
cc: d.cc.length ? d.cc : null,
bcc: d.bcc.length ? d.bcc : null,
replyTo: d.replyTo.length ? d.replyTo : ident.replyTo?.length ? ident.replyTo : null,
subject: d.subject,
sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
inReplyTo: d.inReplyTo,
references: d.references,
bodyStructure,
bodyValues,
"header:User-Agent:asText": "ihasmail/2.0",
};
if (d.priority === "high") {
obj["header:X-Priority:asText"] = "1 (Highest)";
obj["header:Importance:asText"] = "High";
} else if (d.priority === "low") {
obj["header:X-Priority:asText"] = "5 (Lowest)";
obj["header:Importance:asText"] = "Low";
}
if (d.requestReceipt) obj["header:Disposition-Notification-To:asAddresses"] = [from];
if (!opts.forSend) {
const draftsId = mail.roleId("drafts");
obj.mailboxIds = draftsId ? { [draftsId]: true } : { [mail.roleId("inbox")!]: true };
obj.keywords = { $draft: true, $seen: true };
} else {
const sentId = mail.roleId("sent") ?? mail.roleId("inbox");
obj.mailboxIds = { [sentId!]: true };
obj.keywords = { $seen: true };
}
return obj;
}
function stripPart(p: EmailBodyPart): Record<string, unknown> {
return { blobId: p.blobId, type: p.type, name: p.name, disposition: p.disposition, cid: p.cid };
}
function wrapHtmlDocument(body: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head><body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;">${body}</body></html>`;
}
async function saveDraftInternal(d: Draft, get: () => ComposeState, set: (fn: (s: ComposeState) => Partial<ComposeState>) => void, opts: { silent: boolean; final?: boolean }): Promise<Id | null> {
const mail = useMail.getState();
const accountId = mail.accountId!;
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: true } : x)) }));
try {
const email = await buildEmailObject(d, { forSend: false });
const args: Record<string, unknown> = { accountId, create: { draft: email } };
if (d.draftId) args.destroy = [d.draftId];
const res = await client.call<SetResponse<Email>>("Email/set", args);
const err = res.notCreated?.draft;
if (err) throw new Error(err.description ?? err.type);
const newId = res.created?.draft?.id ?? null;
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, draftId: newId, saving: false, dirty: false, savedAt: Date.now(), error: null } : x)) }));
void mail.loadMailboxes();
if (mail.list?.mailboxId && mail.list.mailboxId === mail.roleId("drafts")) void mail.refreshList();
return newId;
} catch (err) {
if (!opts.final) set((s) => ({ drafts: s.drafts.map((x) => (x.key === d.key ? { ...x, saving: false, error: (err as Error).message } : x)) }));
throw err;
}
}
async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
const mail = useMail.getState();
const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0];
if (!ident) throw new Error("No sending identity available");
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading");
const email = await buildEmailObject(d, { forSend: true });
const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts");
const onSuccess: Record<string, unknown> = { "keywords/$draft": null, "keywords/$seen": true };
if (sentId) onSuccess[`mailboxIds/${sentId}`] = true;
if (draftsId) onSuccess[`mailboxIds/${draftsId}`] = null;
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email }));
if (!rcpts.length) throw new Error("No recipients");
const calls: Array<[string, Record<string, unknown>, string]> = [
["Email/set", { accountId, create: { m: email }, ...(d.draftId ? { destroy: [d.draftId] } : {}) }, "e"],
[
"EmailSubmission/set",
{
accountId,
create: { s: { identityId: ident.id, emailId: "#m", envelope: { mailFrom: { email: ident.email }, rcptTo: rcpts } } },
onSuccessUpdateEmail: { "#s": onSuccess },
},
"s",
],
];
if (d.relatedEmailId && d.relatedKeyword) {
calls.push(["Email/set", { accountId, update: { [d.relatedEmailId]: { [`keywords/${d.relatedKeyword}`]: true } } }, "k"]);
}
const res = await client.chain(calls, { allowErrors: true });
const e = res.get("e")?.[0] as unknown as SetResponse<Email> & { __error?: { type: string; description?: string } };
if (e.__error) throw new Error(e.__error.description ?? e.__error.type);
if (e.notCreated?.m) throw new Error(e.notCreated.m.description ?? e.notCreated.m.type);
const s = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
if (s.__error) throw new Error(s.__error.description ?? s.__error.type);
if (s.notCreated?.s) {
const err = s.notCreated.s;
// Clean up the created (unsent) email so it doesn't linger in Sent.
const created = e.created?.m?.id;
if (created) void client.call("Email/set", { accountId, destroy: [created] });
throw new Error(err.description ?? err.type);
}
if (d.relatedEmailId && d.relatedKeyword) {
useMail.setState((st) => {
const cur = st.emails[d.relatedEmailId!];
return cur ? { emails: { ...st.emails, [d.relatedEmailId!]: { ...cur, keywords: { ...cur.keywords, [d.relatedKeyword!]: true } } } } : {};
});
}
void mail.loadMailboxes();
void mail.refreshList();
}
export { FULL_PROPS, BODY_PROPS };
+320
View File
@@ -0,0 +1,320 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
import { useSession } from "./session";
import { useMail } from "./mail";
export interface Suggestion {
name: string | null;
email: string;
source: "contact" | "gal" | "recent";
contactId?: Id;
photo?: string | null;
}
interface ContactsState {
accountId: Id | null;
available: boolean;
books: Record<Id, AddressBook>;
cards: Record<Id, ContactCard>;
loaded: boolean;
loading: boolean;
error: string | null;
principals: Principal[];
principalsLoaded: boolean;
recent: EmailAddress[];
init(): Promise<void>;
loadBooks(): Promise<void>;
loadAll(): Promise<void>;
getCard(id: Id): Promise<ContactCard | null>;
search(text: string): ContactCard[];
createCard(card: Partial<ContactCard>, addressBookId: Id): Promise<Id>;
updateCard(id: Id, patch: Record<string, unknown>): Promise<void>;
destroyCards(ids: Id[]): Promise<void>;
createBook(name: string): Promise<Id>;
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
destroyBook(id: Id): Promise<void>;
importVCard(text: string, addressBookId: Id): Promise<number>;
loadPrincipals(): Promise<void>;
suggest(query: string, limit?: number): Promise<Suggestion[]>;
addRecent(addrs: EmailAddress[]): void;
lookupByEmail(email: string): ContactCard | undefined;
applyChanges(types: Set<string>): void;
}
export const CARD_PROPS = undefined; // all properties
export const useContacts = create<ContactsState>((set, get) => ({
accountId: null,
available: false,
books: {},
cards: {},
loaded: false,
loading: false,
error: null,
principals: [],
principalsLoaded: false,
recent: [],
async init() {
const accountId = useSession.getState().accountFor(CAP.contacts);
const available = Boolean(accountId && client.hasCapability(CAP.contacts));
if (accountId !== get().accountId) set({ accountId, books: {}, cards: {}, loaded: false });
set({ available });
if (!available) return;
await get().loadBooks();
},
async loadBooks() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null });
const books: Record<Id, AddressBook> = {};
for (const b of res.list) books[b.id] = b;
set({ books, error: null });
} catch (err) {
set({ error: (err as Error).message });
}
},
async loadAll() {
const accountId = get().accountId;
if (!accountId || get().loading) return;
set({ loading: true });
try {
const cards: Record<Id, ContactCard> = {};
let position = 0;
const limit = 500;
for (let guard = 0; guard < 50; guard++) {
const res = await client.chain([
["ContactCard/query", { accountId, position, limit, calculateTotal: true }, "q"],
["ContactCard/get", { accountId, "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" } }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<ContactCard>;
for (const c of g.list) cards[c.id] = c;
position += q.ids.length;
if (q.ids.length < limit || (q.total != null && position >= q.total)) break;
}
set({ cards, loaded: true, loading: false, error: null });
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
async getCard(id) {
const accountId = get().accountId;
if (!accountId) return null;
const res = await client.call<GetResponse<ContactCard>>("ContactCard/get", { accountId, ids: [id] });
const c = res.list[0];
if (c) set((s) => ({ cards: { ...s.cards, [c.id]: c } }));
return c ?? null;
},
search(text) {
const q = text.trim().toLowerCase();
const all = Object.values(get().cards);
const filtered = q
? all.filter((c) => {
const hay = [contactDisplayName(c), ...Object.values(c.emails ?? {}).map((e) => e.address), ...Object.values(c.phones ?? {}).map((p) => p.number), ...Object.values(c.organizations ?? {}).map((o) => o.name ?? ""), ...Object.values(c.nicknames ?? {}).map((n) => n.name)]
.join(" ")
.toLowerCase();
return hay.includes(q);
})
: all;
return filtered.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
},
async createCard(card, addressBookId) {
const accountId = get().accountId!;
const obj = { "@type": "Card", version: "1.0", uid: crypto.randomUUID(), kind: "individual", ...card, addressBookIds: { [addressBookId]: true } };
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create: { c: obj } });
const err = res.notCreated?.c;
if (err) throw new Error(err.description ?? err.type);
const id = res.created!.c!.id;
await get().getCard(id);
return id;
},
async updateCard(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().getCard(id);
},
async destroyCards(ids) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("ContactCard/set", { accountId, destroy: ids });
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(failed.description ?? failed.type);
set((s) => {
const cards = { ...s.cards };
for (const id of ids) delete cards[id];
return { cards };
});
},
async createBook(name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<AddressBook>>("AddressBook/set", { accountId, create: { b: { name } } });
const err = res.notCreated?.b;
if (err) throw new Error(err.description ?? err.type);
await get().loadBooks();
return res.created!.b!.id;
},
async updateBook(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadBooks();
},
async destroyBook(id) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("AddressBook/set", { accountId, destroy: [id], onDestroyRemoveContents: true });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadBooks();
await get().loadAll();
},
async importVCard(text, addressBookId) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([text], { type: "text/vcard" }), { type: "text/vcard" });
const parsed = await client.call<{ parsed?: Record<string, ContactCard[] | ContactCard>; notParsable?: Id[] }>("ContactCard/parse", { accountId, blobIds: [up.blobId] });
const entry = parsed.parsed?.[up.blobId];
const cards: ContactCard[] = entry ? (Array.isArray(entry) ? entry : [entry]) : [];
if (!cards.length) throw new Error("No contacts found in file");
const create: Record<string, unknown> = {};
cards.forEach((c, i) => {
const { id: _id, addressBookIds: _ab, ...rest } = c as ContactCard & { id?: Id };
create[`c${i}`] = { ...rest, uid: rest.uid || crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
});
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
await get().loadAll();
return Object.keys(res.created ?? {}).length;
},
async loadPrincipals() {
if (get().principalsLoaded) return;
const accountId = useSession.getState().accountFor(CAP.principals);
if (!accountId || !client.hasCapability(CAP.principals)) {
set({ principalsLoaded: true });
return;
}
try {
const res = await client.chain([
["Principal/query", { accountId, limit: 1000 }, "q"],
["Principal/get", { accountId, "#ids": { resultOf: "q", name: "Principal/query", path: "/ids" }, properties: ["id", "type", "name", "description", "email", "timeZone"] }, "g"],
]);
const g = res.get("g")?.[0] as unknown as GetResponse<Principal>;
set({ principals: g.list, principalsLoaded: true });
} catch {
set({ principalsLoaded: true });
}
},
async suggest(query, limit = 8) {
const q = query.trim().toLowerCase();
if (!q) return [];
const st = get();
if (!st.loaded && st.available && !st.loading) void st.loadAll();
if (!st.principalsLoaded) void st.loadPrincipals();
const out: Suggestion[] = [];
const seen = new Set<string>();
const add = (s: Suggestion) => {
const k = s.email.toLowerCase();
if (!k || seen.has(k)) return;
seen.add(k);
out.push(s);
};
const score = (name: string | null, email: string): number => {
const n = (name ?? "").toLowerCase();
const e = email.toLowerCase();
if (e.startsWith(q) || n.startsWith(q)) return 0;
if (n.split(/\s+/).some((w) => w.startsWith(q))) return 1;
if (e.includes(q) || n.includes(q)) return 2;
return 99;
};
const candidates: Array<Suggestion & { score: number }> = [];
for (const c of Object.values(st.cards)) {
for (const a of contactEmails(c)) {
const sc = score(a.name, a.email);
if (sc < 99) candidates.push({ name: a.name, email: a.email, source: "contact", contactId: c.id, score: sc });
}
}
for (const p of st.principals) {
if (!p.email) continue;
const sc = score(p.name, p.email);
if (sc < 99) candidates.push({ name: p.name, email: p.email, source: "gal", score: sc + 0.5 });
}
for (const r of st.recent) {
const sc = score(r.name, r.email);
if (sc < 99) candidates.push({ name: r.name, email: r.email, source: "recent", score: sc + 0.25 });
}
candidates.sort((a, b) => a.score - b.score || (a.name ?? a.email).localeCompare(b.name ?? b.email));
for (const c of candidates) {
add(c);
if (out.length >= limit) break;
}
return out;
},
addRecent(addrs) {
const cur = get().recent;
const next = [...addrs.filter((a) => a.email), ...cur.filter((r) => !addrs.some((a) => a.email.toLowerCase() === r.email.toLowerCase()))].slice(0, 200);
set({ recent: next });
try {
localStorage.setItem(`ihasmail:${get().accountId}:recent`, JSON.stringify(next));
} catch {
/* ignore */
}
},
lookupByEmail(email) {
const e = email.toLowerCase();
return Object.values(get().cards).find((c) => Object.values(c.emails ?? {}).some((x) => x.address.toLowerCase() === e));
},
applyChanges(types) {
if (types.has("AddressBook")) void get().loadBooks();
if (types.has("ContactCard") && get().loaded) void get().loadAll();
},
}));
useSession.subscribe((s) => {
if (s.status === "authenticated") {
const accountId = s.accountFor(CAP.contacts);
let recent: EmailAddress[] = [];
try {
recent = JSON.parse(localStorage.getItem(`ihasmail:${accountId}:recent`) ?? "[]") as EmailAddress[];
} catch {
/* ignore */
}
useContacts.setState({ recent });
} else {
useContacts.setState({ accountId: null, books: {}, cards: {}, loaded: false, principals: [], principalsLoaded: false });
}
});
// Harvest recent recipients from Sent when the mail store learns about them.
useMail.subscribe((s, prev) => {
if (s.emails === prev.emails) return;
const sentId = s.roleId("sent");
if (!sentId) return;
// cheap: only look at newly-added emails in Sent
const addrs: EmailAddress[] = [];
for (const id of Object.keys(s.emails)) {
if (prev.emails[id]) continue;
const e = s.emails[id]!;
if (e.mailboxIds[sentId]) addrs.push(...(e.to ?? []), ...(e.cc ?? []));
}
if (addrs.length) useContacts.getState().addRecent(addrs.slice(0, 50));
});
+192
View File
@@ -0,0 +1,192 @@
import { create } from "zustand";
import { CAP, client, JmapMethodError } from "@/jmap/client";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
interface FilesState {
accountId: Id | null;
available: boolean;
nodes: Record<Id, FileNode>;
children: Record<string, Id[]>; // parentId ("root" for null) → ids
loading: boolean;
error: string | null;
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
init(): Promise<void>;
loadChildren(parentId: Id | null): Promise<void>;
mkdir(parentId: Id | null, name: string): Promise<Id>;
upload(parentId: Id | null, files: File[]): Promise<void>;
rename(id: Id, name: string): Promise<void>;
move(id: Id, parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>;
pathTo(id: Id | null): FileNode[];
applyChanges(types: Set<string>): void;
}
const PROPS = ["id", "parentId", "nodeType", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"];
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
let filtersSupported = true;
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
const all: FileNode[] = [];
let position = 0;
for (let guard = 0; guard < 100; guard++) {
const res = await client.chain([
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
all.push(...g.list);
position += q.ids.length;
if (!q.ids.length || (q.total != null && position >= q.total)) break;
}
const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] };
for (const n of all) nodes[n.id] = n;
for (const n of all.sort(byName)) {
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
(children[key] ??= []).push(n.id);
}
for (const n of all) children[n.id] ??= [];
set(() => ({ nodes, children, loading: false, error: null }));
}
export const useFiles = create<FilesState>((set, get) => ({
accountId: null,
available: false,
nodes: {},
children: {},
loading: false,
error: null,
uploads: [],
async init() {
const accountId = useSession.getState().accountFor(CAP.filenode);
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
if (accountId !== get().accountId) set({ accountId, nodes: {}, children: {} });
set({ available });
},
async loadChildren(parentId) {
const accountId = get().accountId;
if (!accountId) return;
set({ loading: true });
try {
if (!filtersSupported) {
await loadAllNodes(accountId, set);
return;
}
const filter = parentId ? { parentId } : { isTopLevel: true };
const res = await client.chain([
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: PROPS }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
set((s) => {
const nodes = { ...s.nodes };
for (const n of g.list) nodes[n.id] = n;
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null };
});
} catch (err) {
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
// fetching every node and building the tree client-side.
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
filtersSupported = false;
try {
await loadAllNodes(accountId, set);
return;
} catch (err2) {
set({ loading: false, error: (err2 as Error).message });
return;
}
}
set({ loading: false, error: (err as Error).message });
}
},
async mkdir(parentId, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: { parentId, name, nodeType: "directory" } } });
const err = res.notCreated?.d;
if (err) throw new Error(err.description ?? err.type);
await get().loadChildren(parentId);
return res.created!.d!.id;
},
async upload(parentId, files) {
const accountId = get().accountId!;
for (const f of files) {
const id = `${Date.now()}-${f.name}`;
set((s) => ({ uploads: [...s.uploads, { id, name: f.name, progress: 0, error: null }] }));
try {
const up = await client.upload(accountId, f, {
type: f.type || "application/octet-stream",
onProgress: (l, t) => set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, progress: Math.round((l / t) * 100) } : u)) })),
});
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
create: { f: { parentId, name: f.name, nodeType: "file", blobId: up.blobId, type: f.type || "application/octet-stream" } },
});
const err = res.notCreated?.f;
if (err) throw new Error(err.description ?? err.type);
set((s) => ({ uploads: s.uploads.filter((u) => u.id !== id) }));
} catch (err) {
set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, error: (err as Error).message } : u)) }));
}
}
await get().loadChildren(parentId);
},
async rename(id, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadChildren(get().nodes[id]?.parentId ?? null);
},
async move(id, parentId) {
const accountId = get().accountId!;
const from = get().nodes[id]?.parentId ?? null;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
},
async destroy(ids) {
const accountId = get().accountId!;
const parents = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
const res = await client.call<SetResponse>("FileNode/set", { accountId, destroy: ids, onDestroyRemoveChildren: true });
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(failed.description ?? failed.type);
for (const p of parents) await get().loadChildren(p);
},
pathTo(id) {
const out: FileNode[] = [];
let cur = id ? get().nodes[id] : undefined;
let guard = 0;
while (cur && guard++ < 50) {
out.unshift(cur);
cur = cur.parentId ? get().nodes[cur.parentId] : undefined;
}
return out;
},
applyChanges(types) {
if (types.has("FileNode")) {
for (const key of Object.keys(get().children)) void get().loadChildren(key === "root" ? null : key);
}
},
}));
useSession.subscribe((s) => {
if (s.status !== "authenticated") useFiles.setState({ accountId: null, nodes: {}, children: {} });
});
+922
View File
@@ -0,0 +1,922 @@
import { create } from "zustand";
import { client, chunk, JmapMethodError } from "@/jmap/client";
import type {
Comparator,
Email,
EmailFilter,
GetResponse,
Id,
Identity,
Mailbox,
MailboxRole,
QueryResponse,
Quota,
SetResponse,
Thread,
VacationResponse,
ChangesResponse,
} from "@/jmap/types";
import { toast } from "@/ui/toast";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
export const LIST_PROPS = [
"id",
"blobId",
"threadId",
"mailboxIds",
"keywords",
"hasAttachment",
"from",
"to",
"subject",
"receivedAt",
"sentAt",
"size",
"preview",
];
export const FULL_PROPS = [
...LIST_PROPS,
"messageId",
"inReplyTo",
"references",
"sender",
"cc",
"bcc",
"replyTo",
"bodyStructure",
"bodyValues",
"textBody",
"htmlBody",
"attachments",
"header:List-Unsubscribe:asText",
"header:List-Unsubscribe-Post:asText",
"header:List-Id:asText",
"header:Disposition-Notification-To:asAddresses",
"header:X-Priority:asText",
"header:Importance:asText",
"header:Auto-Submitted:asText",
"header:Authentication-Results:asText",
];
export const BODY_PROPS = ["partId", "blobId", "size", "name", "type", "charset", "disposition", "cid", "language", "location", "subParts", "headers"];
export interface ListQuery {
key: string;
filter: EmailFilter;
sort: Comparator[];
collapseThreads: boolean;
mailboxId: string | null;
label?: string;
}
export interface ListState extends ListQuery {
ids: Id[];
total: number;
queryState: string | null;
loading: boolean;
loadingMore: boolean;
error: string | null;
exhausted: boolean;
}
export interface MailState {
accountId: Id | null;
mailboxes: Record<Id, Mailbox>;
mailboxState: string | null;
mailboxesLoaded: boolean;
emails: Record<Id, Email>;
fullIds: Record<Id, true>;
emailState: string | null;
threads: Record<Id, Thread>;
identities: Identity[];
quotas: Quota[];
vacation: VacationResponse | null;
list: ListState | null;
selected: Record<Id, true>;
anchorId: Id | null;
loadingThreads: Record<Id, true>;
lastSeenInboxEmailIds: Id[] | null;
openThreadId: Id | null;
setOpenThread(id: Id | null): void;
setAccount(accountId: Id | null): void;
loadMailboxes(): Promise<void>;
roleId(role: MailboxRole): Id | null;
mailboxPath(id: Id): string;
childrenOf(parentId: Id | null): Mailbox[];
query(q: ListQuery, opts?: { reset?: boolean }): Promise<void>;
loadMore(): Promise<void>;
refreshList(): Promise<void>;
getEmails(ids: Id[], full?: boolean): Promise<Email[]>;
loadThread(threadId: Id): Promise<Email[]>;
threadEmails(threadId: Id): Email[];
threadIdsIn(threadId: Id, mailboxId: Id | null): Id[];
setKeyword(ids: Id[], keyword: string, value: boolean): Promise<void>;
markRead(ids: Id[], read: boolean): Promise<void>;
star(ids: Id[], on: boolean): Promise<void>;
move(ids: Id[], toMailboxId: Id, opts?: { fromMailboxId?: Id | null; silent?: boolean; label?: string }): Promise<void>;
addToMailbox(ids: Id[], mailboxId: Id, add: boolean): Promise<void>;
trash(ids: Id[]): Promise<void>;
destroy(ids: Id[]): Promise<void>;
archive(ids: Id[]): Promise<void>;
spam(ids: Id[], isSpam: boolean): Promise<void>;
emptyMailbox(mailboxId: Id): Promise<void>;
markMailboxRead(mailboxId: Id): Promise<void>;
createMailbox(name: string, parentId: Id | null): Promise<Id>;
updateMailbox(id: Id, patch: Partial<Mailbox>): Promise<void>;
destroyMailbox(id: Id, removeEmails?: boolean): Promise<void>;
loadIdentities(): Promise<Identity[]>;
/** The user's preferred identity (falls back to the first one). */
defaultIdentity(): Identity | undefined;
setDefaultIdentity(id: Id): void;
saveIdentity(id: Id | null, patch: Partial<Identity>): Promise<void>;
destroyIdentity(id: Id): Promise<void>;
loadVacation(): Promise<void>;
saveVacation(patch: Partial<VacationResponse>): Promise<void>;
loadQuota(): Promise<void>;
select(ids: Id[], on: boolean): void;
clearSelection(): void;
selectAll(): void;
setAnchor(id: Id | null): void;
applyChanges(types: Set<string>): Promise<void>;
importEml(blobId: Id, mailboxId: Id, keywords?: Record<string, boolean>): Promise<Id | null>;
}
function listKey(q: { filter: EmailFilter; sort: Comparator[]; collapseThreads: boolean }): string {
return JSON.stringify([q.filter, q.sort, q.collapseThreads]);
}
export const DEFAULT_SORT: Comparator[] = [{ property: "receivedAt", isAscending: false }];
export const useMail = create<MailState>((set, get) => ({
accountId: null,
mailboxes: {},
mailboxState: null,
mailboxesLoaded: false,
emails: {},
fullIds: {},
emailState: null,
threads: {},
identities: [],
quotas: [],
vacation: null,
list: null,
selected: {},
anchorId: null,
loadingThreads: {},
lastSeenInboxEmailIds: null,
openThreadId: null,
setOpenThread(id) {
set({ openThreadId: id });
},
setAccount(accountId) {
if (accountId === get().accountId) return;
set({
accountId,
mailboxes: {},
mailboxState: null,
mailboxesLoaded: false,
emails: {},
fullIds: {},
emailState: null,
threads: {},
identities: [],
quotas: [],
vacation: null,
list: null,
selected: {},
anchorId: null,
lastSeenInboxEmailIds: null,
});
},
async loadMailboxes() {
const accountId = get().accountId;
if (!accountId) return;
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null });
const mailboxes: Record<Id, Mailbox> = {};
for (const m of res.list) mailboxes[m.id] = m;
set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true });
},
roleId(role) {
for (const m of Object.values(get().mailboxes)) if (m.role === role) return m.id;
return null;
},
mailboxPath(id) {
const mbs = get().mailboxes;
const parts: string[] = [];
let cur: Mailbox | undefined = mbs[id];
let guard = 0;
while (cur && guard++ < 20) {
parts.unshift(cur.role === "inbox" ? "INBOX" : cur.name);
cur = cur.parentId ? mbs[cur.parentId] : undefined;
}
return parts.join("/");
},
childrenOf(parentId) {
return Object.values(get().mailboxes)
.filter((m) => (m.parentId ?? null) === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
},
async query(q, opts = {}) {
const accountId = get().accountId;
if (!accountId) return;
const key = listKey(q);
const cur = get().list;
const reuse = cur && cur.key === key && !opts.reset;
if (reuse && cur.ids.length && !cur.error) {
// Already showing; just refresh in background.
void get().refreshList();
return;
}
set({
list: { ...q, key, ids: reuse ? cur.ids : [], total: reuse ? cur.total : 0, queryState: null, loading: true, loadingMore: false, error: null, exhausted: false },
selected: {},
anchorId: null,
});
try {
const { ids, total, queryState } = await runQuery(accountId, q, 0, settings().pageSize);
if (get().list?.key !== key) return;
set((s) => ({ list: s.list ? { ...s.list, ids, total, queryState, loading: false, exhausted: ids.length >= total } : s.list }));
} catch (err) {
if (get().list?.key !== key) return;
set((s) => ({ list: s.list ? { ...s.list, loading: false, error: (err as Error).message } : s.list }));
}
},
async loadMore() {
const accountId = get().accountId;
const l = get().list;
if (!accountId || !l || l.loading || l.loadingMore || l.exhausted) return;
set({ list: { ...l, loadingMore: true } });
try {
const { ids, total, queryState } = await runQuery(accountId, l, l.ids.length, settings().pageSize);
const cur = get().list;
if (!cur || cur.key !== l.key) return;
const merged = [...cur.ids];
const seen = new Set(merged);
for (const id of ids) if (!seen.has(id)) merged.push(id);
set({ list: { ...cur, ids: merged, total, queryState, loadingMore: false, exhausted: ids.length === 0 || merged.length >= total } });
} catch (err) {
const cur = get().list;
if (cur && cur.key === l.key) set({ list: { ...cur, loadingMore: false, error: (err as Error).message } });
}
},
async refreshList() {
const accountId = get().accountId;
const l = get().list;
if (!accountId || !l) return;
try {
const limit = Math.max(settings().pageSize, l.ids.length);
const { ids, total, queryState } = await runQuery(accountId, l, 0, limit);
const cur = get().list;
if (!cur || cur.key !== l.key) return;
set({ list: { ...cur, ids, total, queryState, loading: false, error: null, exhausted: ids.length >= total } });
} catch {
/* keep old list */
}
},
async getEmails(ids, full = false) {
const accountId = get().accountId;
if (!accountId || !ids.length) return [];
const { emails, fullIds } = get();
const missing = ids.filter((id) => !emails[id] || (full && !fullIds[id]));
if (missing.length) {
const results = await Promise.all(
chunk(missing, client.maxObjectsInGet).map((part) =>
client.call<GetResponse<Email>>("Email/get", {
accountId,
ids: part,
properties: full ? FULL_PROPS : LIST_PROPS,
...(full ? { fetchHTMLBodyValues: true, fetchTextBodyValues: true, maxBodyValueBytes: 2 * 1024 * 1024, bodyProperties: BODY_PROPS } : {}),
}),
),
);
set((s) => {
const next = { ...s.emails };
const nextFull = { ...s.fullIds };
let state = s.emailState;
for (const r of results) {
state = r.state;
for (const e of r.list) {
next[e.id] = { ...next[e.id], ...e };
if (full) nextFull[e.id] = true;
}
}
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
});
}
const now = get().emails;
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e));
},
async loadThread(threadId) {
const accountId = get().accountId;
if (!accountId) return [];
set((s) => ({ loadingThreads: { ...s.loadingThreads, [threadId]: true } }));
try {
const res = await client.chain([
["Thread/get", { accountId, ids: [threadId] }, "t"],
[
"Email/get",
{
accountId,
"#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" },
properties: FULL_PROPS,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
maxBodyValueBytes: 2 * 1024 * 1024,
bodyProperties: BODY_PROPS,
},
"e",
],
]);
const thread = (res.get("t")?.[0] as unknown as GetResponse<Thread>).list[0];
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
if (!thread) return [];
set((s) => {
const next = { ...s.emails };
const nextFull = { ...s.fullIds };
for (const e of emailsRes.list) {
next[e.id] = { ...next[e.id], ...e };
nextFull[e.id] = true;
}
const { [threadId]: _drop, ...rest } = s.loadingThreads;
return { emails: next, fullIds: nextFull, threads: { ...s.threads, [threadId]: thread }, loadingThreads: rest };
});
return get().threadEmails(threadId);
} catch (err) {
set((s) => {
const { [threadId]: _drop, ...rest } = s.loadingThreads;
return { loadingThreads: rest };
});
throw err;
}
},
threadEmails(threadId) {
const { threads, emails } = get();
const t = threads[threadId];
if (!t) return [];
return t.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e));
},
threadIdsIn(threadId, mailboxId) {
const t = get().threads[threadId];
if (!t) return [];
if (!mailboxId) return [...t.emailIds];
const { emails } = get();
return t.emailIds.filter((id) => emails[id]?.mailboxIds[mailboxId]);
},
async setKeyword(ids, keyword, value) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
// optimistic
set((s) => {
const next = { ...s.emails };
for (const id of ids) {
const e = next[id];
if (!e) continue;
const kw = { ...e.keywords };
if (value) kw[keyword] = true;
else delete kw[keyword];
next[id] = { ...e, keywords: kw };
}
return { emails: next };
});
const update: Record<Id, Record<string, unknown>> = {};
for (const id of ids) update[id] = { [`keywords/${keyword}`]: value ? true : null };
try {
await setEmails(accountId, update);
} catch (err) {
toast.error(`Could not update: ${(err as Error).message}`);
void get().getEmails(ids);
}
},
markRead(ids, read) {
return get().setKeyword(ids, "$seen", read);
},
star(ids, on) {
return get().setKeyword(ids, "$flagged", on);
},
async move(ids, toMailboxId, opts = {}) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
const { emails, mailboxes } = get();
const prev: Record<Id, Record<Id, boolean>> = {};
const update: Record<Id, Record<string, unknown>> = {};
for (const id of ids) {
const e = emails[id];
prev[id] = e?.mailboxIds ?? {};
update[id] = { mailboxIds: { [toMailboxId]: true } };
}
// optimistic
set((s) => {
const next = { ...s.emails };
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: { [toMailboxId]: true } };
return { emails: next, selected: {} };
});
removeFromList(ids, set, get, toMailboxId);
try {
await setEmails(accountId, update);
if (!opts.silent) {
const name = opts.label ?? mailboxes[toMailboxId]?.name ?? "folder";
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
action: {
label: "Undo",
onClick: async () => {
const undo: Record<Id, Record<string, unknown>> = {};
for (const id of ids) undo[id] = { mailboxIds: prev[id] };
await setEmails(accountId, undo);
set((s) => {
const next = { ...s.emails };
for (const id of ids) if (next[id]) next[id] = { ...next[id]!, mailboxIds: prev[id]! };
return { emails: next };
});
void get().refreshList();
void get().loadMailboxes();
},
},
});
}
void get().loadMailboxes();
} catch (err) {
toast.error(`Move failed: ${(err as Error).message}`);
void get().getEmails(ids);
void get().refreshList();
}
},
async addToMailbox(ids, mailboxId, add) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
const update: Record<Id, Record<string, unknown>> = {};
for (const id of ids) update[id] = { [`mailboxIds/${mailboxId}`]: add ? true : null };
set((s) => {
const next = { ...s.emails };
for (const id of ids) {
const e = next[id];
if (!e) continue;
const mb = { ...e.mailboxIds };
if (add) mb[mailboxId] = true;
else delete mb[mailboxId];
next[id] = { ...e, mailboxIds: mb };
}
return { emails: next };
});
try {
await setEmails(accountId, update);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not update labels: ${(err as Error).message}`);
void get().getEmails(ids);
}
},
async trash(ids) {
const { roleId, emails } = get();
const trashId = roleId("trash");
const inTrash = ids.filter((id) => (trashId && emails[id]?.mailboxIds[trashId]) || (roleId("junk") && emails[id]?.mailboxIds[roleId("junk")!]));
const toMove = ids.filter((id) => !inTrash.includes(id));
if (inTrash.length) await get().destroy(inTrash);
if (toMove.length && trashId) await get().move(toMove, trashId, { label: "Trash" });
else if (toMove.length) await get().destroy(toMove);
},
async destroy(ids) {
const accountId = get().accountId;
if (!accountId || !ids.length) return;
removeFromList(ids, set, get, null);
set((s) => {
const next = { ...s.emails };
for (const id of ids) delete next[id];
return { emails: next, selected: {} };
});
try {
const res = await client.call<SetResponse>("Email/set", { accountId, destroy: ids });
const failed = Object.keys(res.notDestroyed ?? {});
if (failed.length) toast.error(`${failed.length} message(s) could not be deleted`);
else toast.show(`${ids.length === 1 ? "Message" : `${ids.length} messages`} deleted forever`);
void get().loadMailboxes();
} catch (err) {
toast.error(`Delete failed: ${(err as Error).message}`);
void get().refreshList();
}
},
async archive(ids) {
const archiveId = get().roleId("archive") ?? get().roleId("all");
if (!archiveId) {
toast.error("No Archive folder found. Create one named “Archive” first.");
return;
}
await get().move(ids, archiveId, { label: "Archive" });
},
async spam(ids, isSpam) {
const { roleId } = get();
const target = isSpam ? roleId("junk") : roleId("inbox");
if (!target) return;
const kw: Record<Id, Record<string, unknown>> = {};
for (const id of ids) kw[id] = { "keywords/$junk": isSpam ? true : null, "keywords/$notjunk": isSpam ? null : true };
const accountId = get().accountId!;
try {
await setEmails(accountId, kw);
} catch {
/* keyword may be rejected; still move */
}
await get().move(ids, target, { label: isSpam ? "Spam" : "Inbox" });
},
async emptyMailbox(mailboxId) {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.chain([
["Email/query", { accountId, filter: { inMailbox: mailboxId }, limit: 5000 }, "q"],
["Email/set", { accountId, "#destroy": { resultOf: "q", name: "Email/query", path: "/ids" } }, "s"],
]);
const s = res.get("s")?.[0] as unknown as SetResponse;
const n = s.destroyed?.length ?? 0;
toast.show(`Deleted ${n} message${n === 1 ? "" : "s"}`);
set({ list: get().list ? { ...get().list!, ids: get().list!.mailboxId === mailboxId ? [] : get().list!.ids, total: 0 } : null });
void get().loadMailboxes();
void get().refreshList();
} catch (err) {
toast.error(`Could not empty folder: ${(err as Error).message}`);
}
},
async markMailboxRead(mailboxId) {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.chain([
["Email/query", { accountId, filter: { inMailbox: mailboxId, notKeyword: "$seen" }, limit: 5000 }, "q"],
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: ["id"] }, "g"],
]);
const ids = ((res.get("g")?.[0] as unknown as GetResponse<Email>).list ?? []).map((e) => e.id);
if (ids.length) await get().markRead(ids, true);
void get().loadMailboxes();
} catch (err) {
toast.error(`Could not mark as read: ${(err as Error).message}`);
}
},
async createMailbox(name, parentId) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<Mailbox>>("Mailbox/set", { accountId, create: { n: { name, parentId, isSubscribed: true } } });
const err = res.notCreated?.n;
if (err) throw new Error(err.description ?? err.type);
await get().loadMailboxes();
return res.created!.n!.id;
},
async updateMailbox(id, patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, update: { [id]: patch } });
const err = res.notUpdated?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadMailboxes();
},
async destroyMailbox(id, removeEmails = true) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Mailbox/set", { accountId, destroy: [id], onDestroyRemoveEmails: removeEmails });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadMailboxes();
},
async loadIdentities() {
const accountId = get().accountId;
if (!accountId) return [];
const res = await client.call<GetResponse<Identity>>("Identity/get", { accountId, ids: null });
set({ identities: sortIdentities(res.list, accountId) });
// Long signatures live in Files; swap the stored marker for the full HTML.
const { markerOf } = await import("@/lib/signatureHtml");
const pending = res.list.filter((i) => markerOf(i.htmlSignature));
if (pending.length) {
const { loadStoredSignature } = await import("@/lib/signatureImages");
const full = await Promise.all(pending.map(async (i) => { const m = markerOf(i.htmlSignature)!; try { return [i.id, await loadStoredSignature(m.blobId, m.type)] as const; } catch { return [i.id, null] as const; } }));
if (get().accountId === accountId) {
set((s) => ({ identities: s.identities.map((i) => { const f = full.find(([id]) => id === i.id)?.[1]; return f ? { ...i, htmlSignature: f } : i; }) }));
}
}
return get().identities;
},
defaultIdentity() {
const { identities, accountId } = get();
const pref = accountId ? settings().defaultIdentityByAccount[accountId] : undefined;
return identities.find((i) => i.id === pref) ?? identities[0];
},
setDefaultIdentity(id) {
const accountId = get().accountId;
if (!accountId) return;
useSettings.getState().update({ defaultIdentityByAccount: { ...settings().defaultIdentityByAccount, [accountId]: id } });
set({ identities: sortIdentities(get().identities, accountId) });
},
async saveIdentity(id, patch) {
const accountId = get().accountId!;
const res = id
? await client.call<SetResponse<Identity>>("Identity/set", { accountId, update: { [id]: patch } })
: await client.call<SetResponse<Identity>>("Identity/set", { accountId, create: { n: patch } });
const err = id ? res.notUpdated?.[id] : res.notCreated?.n;
if (err) throw new Error(err.description ?? err.type);
await get().loadIdentities();
},
async destroyIdentity(id) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("Identity/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().loadIdentities();
},
async loadVacation() {
const accountId = get().accountId;
if (!accountId) return;
try {
const res = await client.call<GetResponse<VacationResponse>>("VacationResponse/get", { accountId, ids: null });
set({ vacation: res.list[0] ?? null });
} catch {
set({ vacation: null });
}
},
async saveVacation(patch) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("VacationResponse/set", { accountId, update: { singleton: patch } });
const err = res.notUpdated?.singleton;
if (err) throw new Error(err.description ?? err.type);
await get().loadVacation();
},
async loadQuota() {
const accountId = get().accountId;
if (!accountId || !client.hasCapability("urn:ietf:params:jmap:quota")) return;
try {
const res = await client.call<GetResponse<Quota>>("Quota/get", { accountId, ids: null });
set({ quotas: res.list });
} catch {
set({ quotas: [] });
}
},
select(ids, on) {
set((s) => {
const next = { ...s.selected };
for (const id of ids) {
if (on) next[id] = true;
else delete next[id];
}
return { selected: next };
});
},
clearSelection() {
set({ selected: {} });
},
selectAll() {
const l = get().list;
if (!l) return;
const next: Record<Id, true> = {};
for (const id of l.ids) next[id] = true;
set({ selected: next });
},
setAnchor(id) {
set({ anchorId: id });
},
async applyChanges(types) {
const accountId = get().accountId;
if (!accountId) return;
if (types.has("Mailbox")) void get().loadMailboxes();
if (types.has("Email")) {
const state = get().emailState;
if (state) {
try {
let since = state;
let guard = 0;
const updated = new Set<Id>();
const created = new Set<Id>();
const destroyed = new Set<Id>();
// Page through Email/changes.
while (guard++ < 10) {
const ch = await client.call<ChangesResponse>("Email/changes", { accountId, sinceState: since, maxChanges: 500 });
ch.created.forEach((id) => created.add(id));
ch.updated.forEach((id) => updated.add(id));
ch.destroyed.forEach((id) => destroyed.add(id));
since = ch.newState;
if (!ch.hasMoreChanges) break;
}
set((s) => {
const next = { ...s.emails };
const nextFull = { ...s.fullIds };
for (const id of destroyed) {
delete next[id];
delete nextFull[id];
}
// Drop cached versions of updated emails so they're refetched lazily.
for (const id of updated) {
if (next[id] && nextFull[id]) delete nextFull[id];
}
return { emails: next, fullIds: nextFull, emailState: since };
});
// Refresh the list-level props of updated/cached emails.
const cached = [...updated].filter((id) => get().emails[id]);
if (cached.length) {
const results = await Promise.all(
chunk(cached, client.maxObjectsInGet).map((part) => client.call<GetResponse<Email>>("Email/get", { accountId, ids: part, properties: LIST_PROPS })),
);
set((s) => {
const next = { ...s.emails };
for (const r of results) for (const e of r.list) next[e.id] = { ...next[e.id], ...e };
return { emails: next };
});
}
if (created.size) await notifyNewMail([...created], get);
} catch (err) {
if (err instanceof JmapMethodError && err.type === "cannotCalculateChanges") {
set({ emailState: null });
}
}
}
void get().refreshList();
void get().loadMailboxes();
}
if (types.has("Thread") || types.has("Email")) {
const open = get().openThreadId;
if (open) void get().loadThread(open).catch(() => undefined);
}
if (types.has("Identity")) void get().loadIdentities();
if (types.has("VacationResponse")) void get().loadVacation();
if (types.has("Quota")) void get().loadQuota();
},
async importEml(blobId, mailboxId, keywords = {}) {
const accountId = get().accountId;
if (!accountId) return null;
const res = await client.call<{ created?: Record<string, Email>; notCreated?: Record<string, { type: string; description?: string }> }>("Email/import", {
accountId,
emails: { i: { blobId, mailboxIds: { [mailboxId]: true }, keywords } },
});
if (res.notCreated?.i) throw new Error(res.notCreated.i.description ?? res.notCreated.i.type);
void get().refreshList();
void get().loadMailboxes();
return res.created?.i?.id ?? null;
},
}));
function sortIdentities(list: Identity[], accountId: Id): Identity[] {
const pref = settings().defaultIdentityByAccount[accountId];
return [...list].sort((a, b) => (a.id === pref ? -1 : b.id === pref ? 1 : a.email.localeCompare(b.email)));
}
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
const calls: Array<[string, Record<string, unknown>, string]> = [
["Email/query", { accountId, filter: q.filter, sort: q.sort, collapseThreads: q.collapseThreads, position, limit, calculateTotal: true }, "q"],
["Email/get", { accountId, "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, properties: LIST_PROPS }, "e"],
];
if (q.collapseThreads) {
calls.push(["Thread/get", { accountId, "#ids": { resultOf: "e", name: "Email/get", path: "/list/*/threadId" } }, "t"]);
calls.push(["Email/get", { accountId, "#ids": { resultOf: "t", name: "Thread/get", path: "/list/*/emailIds" }, properties: LIST_PROPS }, "te"]);
}
const res = await client.chain(calls);
const query = res.get("q")?.[0] as unknown as QueryResponse;
const emailsRes = res.get("e")?.[0] as unknown as GetResponse<Email>;
const threadsRes = res.get("t")?.[0] as unknown as GetResponse<Thread> | undefined;
const threadEmails = res.get("te")?.[0] as unknown as GetResponse<Email> | undefined;
useMail.setState((s) => {
const emails = { ...s.emails };
for (const e of emailsRes.list) emails[e.id] = { ...emails[e.id], ...e };
for (const e of threadEmails?.list ?? []) emails[e.id] = { ...emails[e.id], ...e };
const threads = { ...s.threads };
for (const t of threadsRes?.list ?? []) threads[t.id] = t;
return { emails, threads, emailState: s.emailState ?? emailsRes.state };
});
return { ids: query.ids, total: query.total ?? query.ids.length, queryState: query.queryState };
}
async function setEmails(accountId: Id, update: Record<Id, Record<string, unknown>>) {
const ids = Object.keys(update);
for (const part of chunk(ids, 400)) {
const sub: Record<Id, Record<string, unknown>> = {};
for (const id of part) sub[id] = update[id]!;
const res = await client.call<SetResponse>("Email/set", { accountId, update: sub });
const failed = Object.entries(res.notUpdated ?? {});
if (failed.length) {
const [, err] = failed[0]!;
throw new Error(`${err.type}${err.description ? `: ${err.description}` : ""}${failed.length > 1 ? ` (+${failed.length - 1} more)` : ""}`);
}
}
}
/** Remove given email ids (and threads they represent) from the current list optimistically. */
function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial<MailState>) => void, get: () => MailState, targetMailboxId: Id | null) {
const l = get().list;
if (!l) return;
// If the list is showing the mailbox we're moving into, don't remove.
if (targetMailboxId && l.mailboxId === targetMailboxId) return;
const idSet = new Set(ids);
const { emails, threads } = get();
const removeRow = (rowId: Id): boolean => {
if (idSet.has(rowId)) return true;
if (!l.collapseThreads) return false;
const e = emails[rowId];
if (!e) return false;
const t = threads[e.threadId];
if (!t) return false;
// Row goes away if no email of the thread remains in this mailbox after the move.
if (l.mailboxId) {
const remaining = t.emailIds.filter((id) => !idSet.has(id) && emails[id]?.mailboxIds[l.mailboxId!]);
return remaining.length === 0;
}
return t.emailIds.every((id) => idSet.has(id));
};
const nextIds = l.ids.filter((id) => !removeRow(id));
if (nextIds.length !== l.ids.length) {
set((s) => ({ list: s.list ? { ...s.list, ids: nextIds, total: Math.max(0, s.list.total - (l.ids.length - nextIds.length)) } : s.list }));
}
}
async function notifyNewMail(created: Id[], get: () => MailState) {
const s = settings();
const inbox = get().roleId("inbox");
if (!inbox) return;
const emails = await get().getEmails(created);
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return;
const { showNotification, playNewMailSound } = await import("@/lib/notify");
if (s.notificationSound) playNewMailSound();
if (s.desktopNotifications) {
for (const e of fresh.slice(0, 3)) {
const from = e.from?.[0];
showNotification(from?.name || from?.email || "New message", {
body: `${e.subject || "(no subject)"}\n${e.preview ?? ""}`.trim(),
tag: e.id,
onClick: () => {
window.location.hash = "";
window.history.pushState({}, "", `/mail/${inbox}/${e.threadId}`);
window.dispatchEvent(new PopStateEvent("popstate"));
},
});
}
}
}
/** Keep the store bound to the selected account. */
useSession.subscribe((s) => {
useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null);
});
export function mailboxIcon(role: MailboxRole): string {
switch (role) {
case "inbox":
return "inbox";
case "drafts":
return "file";
case "sent":
return "send";
case "trash":
return "trash";
case "junk":
return "alert";
case "archive":
return "archive";
case "all":
return "mail";
case "flagged":
return "star";
case "important":
return "tag";
default:
return "folder";
}
}
export const ROLE_ORDER: Record<string, number> = { inbox: 0, flagged: 1, important: 2, drafts: 3, sent: 4, archive: 5, all: 6, junk: 7, trash: 8 };
+100
View File
@@ -0,0 +1,100 @@
import { create } from "zustand";
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types";
import { push } from "@/jmap/push";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
interface SessionState {
status: AuthStatus;
session: JmapSession | null;
/** Selected mail account (defaults to primary). */
accountId: Id | null;
error: string | null;
pushConnected: boolean;
bootstrap(): Promise<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
logout(): Promise<void>;
refresh(): Promise<void>;
setAccount(id: Id): void;
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
accountFor(cap: string): Id | null;
}
export const useSession = create<SessionState>((set, get) => ({
status: "loading",
session: null,
accountId: null,
error: null,
pushConnected: false,
async bootstrap() {
try {
const s = await apiFetch<JmapSession>("/api/auth/session");
applySession(s, set);
} catch (err) {
if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null });
else set({ status: "anonymous", error: (err as Error).message });
}
},
async login(username, password, totp, remember) {
set({ error: null });
const s = await apiFetch<JmapSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password, totp: totp || undefined, remember }),
});
applySession(s, set);
},
async logout() {
push.stop();
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
client.session = null;
set({ status: "anonymous", session: null, accountId: null });
},
async refresh() {
try {
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s;
set({ session: s });
} catch {
/* ignore */
}
},
setAccount(id) {
set({ accountId: id });
},
accountFor(cap) {
const s = get().session;
if (!s) return null;
const selected = get().accountId;
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
return s.primaryAccounts[cap] ?? selected ?? null;
},
}));
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s;
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
}
client.onUnauthenticated(() => {
push.stop();
client.session = null;
useSession.setState({ status: "anonymous", session: null, accountId: null });
});
push.onConnection((connected) => useSession.setState({ pushConnected: connected }));
export function hasCap(cap: string): boolean {
return client.hasCapability(cap);
}
+173
View File
@@ -0,0 +1,173 @@
import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage";
export type Theme = "system" | "light" | "dark";
export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts";
export type ComposeFormat = "html" | "text";
export interface Template {
id: string;
name: string;
subject: string;
html: string;
}
export interface Settings {
theme: Theme;
accent: string;
density: Density;
readingPane: ReadingPane;
conversationMode: boolean;
showPreview: boolean;
showAvatars: boolean;
pageSize: number;
markReadDelay: number; // seconds; -1 = never auto
imagePolicy: ImagePolicy;
undoSendSeconds: number;
composeFormat: ComposeFormat;
replyAllDefault: boolean;
signatureAboveQuote: boolean;
includeQuote: boolean;
requestReadReceipt: boolean;
confirmDelete: boolean;
desktopNotifications: boolean;
notificationSound: boolean;
attachmentReminder: boolean;
weekStart: 0 | 1 | 6;
timeFormat: "12" | "24" | "auto";
calendarDefaultView: "month" | "week" | "day" | "agenda";
workDayStart: number;
workDayEnd: number;
defaultEventDuration: number; // minutes
defaultAlertMinutes: number;
timeZone: string | null; // null = browser
language: string;
labelsSidebar: boolean;
fontSize: "small" | "medium" | "large";
templates: Template[];
labels: Array<{ keyword: string; name: string; color: string }>;
sidebarCollapsed: boolean;
showHiddenFolders: boolean;
trustedImageSenders: string[];
archiveOnReply: boolean;
autoAdvance: "newer" | "older" | "list";
spellcheck: boolean;
sendAndArchive: boolean;
/** Width (px) of the message list when the reading pane is on the right. */
listPaneWidth: number;
/** Height (px) of the message list when the reading pane is below. */
listPaneHeight: number;
/** Outlook-style colour categories for calendar events. */
eventCategories: Array<{ name: string; color: string }>;
/** Default sending identity per account (JMAP has no such flag). */
defaultIdentityByAccount: Record<string, string>;
}
export const DEFAULT_SETTINGS: Settings = {
theme: "system",
accent: "teal",
density: "cozy",
readingPane: "right",
conversationMode: true,
showPreview: true,
showAvatars: true,
pageSize: 50,
markReadDelay: 0,
imagePolicy: "ask",
undoSendSeconds: 8,
composeFormat: "html",
replyAllDefault: false,
signatureAboveQuote: true,
includeQuote: true,
requestReadReceipt: false,
confirmDelete: false,
desktopNotifications: false,
notificationSound: false,
attachmentReminder: true,
weekStart: 1,
timeFormat: "auto",
calendarDefaultView: "week",
workDayStart: 8,
workDayEnd: 18,
defaultEventDuration: 60,
defaultAlertMinutes: 10,
timeZone: null,
language: "en",
labelsSidebar: true,
fontSize: "medium",
templates: [],
labels: [],
sidebarCollapsed: false,
showHiddenFolders: false,
trustedImageSenders: [],
archiveOnReply: false,
autoAdvance: "list",
spellcheck: true,
sendAndArchive: false,
listPaneWidth: 520,
listPaneHeight: 340,
eventCategories: [
{ name: "Important", color: "#dc2626" },
{ name: "Work", color: "#2563eb" },
{ name: "Personal", color: "#16a34a" },
{ name: "Travel", color: "#ea580c" },
{ name: "Family", color: "#9333ea" },
],
defaultIdentityByAccount: {},
};
interface SettingsState {
settings: Settings;
update(patch: Partial<Settings>): void;
reset(): void;
exportJson(): string;
importJson(json: string): boolean;
}
export const useSettings = create<SettingsState>((set, get) => ({
settings: loadJson<Settings>("settings", DEFAULT_SETTINGS),
update(patch) {
const settings = { ...get().settings, ...patch };
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
},
reset() {
saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS);
},
exportJson() {
return JSON.stringify(get().settings, null, 2);
},
importJson(json) {
try {
const parsed = JSON.parse(json) as Partial<Settings>;
get().update(parsed);
return true;
} catch {
return false;
}
},
}));
export function applyTheme(s: Settings = useSettings.getState().settings): void {
const root = document.documentElement;
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
root.dataset.theme = dark ? "dark" : "light";
root.dataset.density = s.density;
root.dataset.accent = s.accent;
root.dataset.fontsize = s.fontSize;
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
}
if (typeof window !== "undefined") {
applyTheme();
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
}
export const settings = () => useSettings.getState().settings;
+139
View File
@@ -0,0 +1,139 @@
import { create } from "zustand";
import { CAP, client } from "@/jmap/client";
import type { GetResponse, Id, SetResponse, SieveScript } from "@/jmap/types";
import { rulesToSieve, sieveToRules, type SieveRule } from "@/lib/sieve";
import { useSession } from "./session";
export const IHASMAIL_SCRIPT = "ihasmail";
interface SieveState {
accountId: Id | null;
available: boolean;
scripts: SieveScript[];
/** Content of each script by id. */
contents: Record<Id, string>;
loading: boolean;
error: string | null;
init(): Promise<void>;
load(): Promise<void>;
getContent(id: Id): Promise<string>;
/** Rules derived from the "ihasmail" script (null = the active script is hand-written). */
rules(): { script: SieveScript | null; rules: SieveRule[] | null; content: string };
saveRules(rules: SieveRule[]): Promise<void>;
saveScript(id: Id | null, name: string, content: string, activate: boolean): Promise<Id>;
activate(id: Id | null): Promise<void>;
destroy(id: Id): Promise<void>;
validate(content: string): Promise<string | null>;
applyChanges(types: Set<string>): void;
}
export const useSieve = create<SieveState>((set, get) => ({
accountId: null,
available: false,
scripts: [],
contents: {},
loading: false,
error: null,
async init() {
const accountId = useSession.getState().accountFor(CAP.sieve);
const available = Boolean(accountId && client.hasCapability(CAP.sieve));
set({ accountId, available });
if (available) await get().load();
},
async load() {
const accountId = get().accountId;
if (!accountId) return;
set({ loading: true });
try {
const res = await client.call<GetResponse<SieveScript>>("SieveScript/get", { accountId, ids: null });
set({ scripts: res.list, loading: false, error: null });
// Preload contents
const contents: Record<Id, string> = {};
await Promise.all(
res.list.map(async (s) => {
try {
contents[s.id] = await client.fetchBlobText(accountId, s.blobId, "application/sieve");
} catch {
contents[s.id] = "";
}
}),
);
set({ contents });
} catch (err) {
set({ loading: false, error: (err as Error).message });
}
},
async getContent(id) {
const cached = get().contents[id];
if (cached != null) return cached;
const s = get().scripts.find((x) => x.id === id);
if (!s) return "";
const text = await client.fetchBlobText(get().accountId!, s.blobId, "application/sieve");
set((st) => ({ contents: { ...st.contents, [id]: text } }));
return text;
},
rules() {
const { scripts, contents } = get();
const script = scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? scripts.find((s) => s.isActive) ?? null;
const content = script ? (contents[script.id] ?? "") : "";
return { script, rules: script ? sieveToRules(content) : [], content };
},
async saveRules(rules) {
const existing = get().scripts.find((s) => s.name === IHASMAIL_SCRIPT) ?? null;
await get().saveScript(existing?.id ?? null, IHASMAIL_SCRIPT, rulesToSieve(rules), true);
},
async saveScript(id, name, content, activate) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
const args: Record<string, unknown> = { accountId };
if (id) args.update = { [id]: { name, blobId: up.blobId } };
else args.create = { s: { name, blobId: up.blobId } };
if (activate) args.onSuccessActivateScript = id ?? "#s";
const res = await client.call<SetResponse<SieveScript>>("SieveScript/set", args);
const err = id ? res.notUpdated?.[id] : res.notCreated?.s;
if (err) throw new Error(err.description ?? err.type);
const newId = id ?? res.created!.s!.id;
set((s) => ({ contents: { ...s.contents, [newId]: content } }));
await get().load();
return newId;
},
async activate(id) {
const accountId = get().accountId!;
const args: Record<string, unknown> = { accountId };
if (id) args.onSuccessActivateScript = id;
else args.onSuccessDeactivateScript = true;
// A no-op set with activation hooks.
await client.call<SetResponse>("SieveScript/set", args);
await get().load();
},
async destroy(id) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("SieveScript/set", { accountId, destroy: [id] });
const err = res.notDestroyed?.[id];
if (err) throw new Error(err.description ?? err.type);
await get().load();
},
async validate(content) {
const accountId = get().accountId!;
try {
const up = await client.upload(accountId, new Blob([content], { type: "application/sieve" }), { type: "application/sieve" });
const res = await client.call<{ error: { type: string; description?: string } | null }>("SieveScript/validate", { accountId, blobId: up.blobId });
return res.error ? (res.error.description ?? res.error.type) : null;
} catch (err) {
return (err as Error).message;
}
},
applyChanges(types) {
if (types.has("SieveScript")) void get().load();
},
}));