ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
@@ -0,0 +1,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: [] });
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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: {} });
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user