ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit c17887e48e
162 changed files with 20398 additions and 1072 deletions
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import type { Email, Id } from "@/jmap/types";
import { useSieve } from "@/store/sieve";
import { useMail } from "@/store/mail";
import { ruleFromEmail, applyRuleToMailbox } from "@/lib/sieveApply";
import type { SieveRule } from "@/lib/sieve";
import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
const sieve = useSieve();
const mailbox = useMail((s) => (mailboxId ? s.mailboxes[mailboxId] : undefined));
const [rule] = useState<SieveRule>(() => ruleFromEmail(email, mailboxId));
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
if (sieve.available && !sieve.scripts.length && !sieve.loading) await sieve.load();
setReady(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!sieve.available) {
return (
<Dialog open onClose={onClose} title="Filters unavailable" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<p>Sieve filtering is not enabled for this account.</p>
</Dialog>
);
}
if (!ready) return <Dialog open onClose={onClose} title="Create filter" size="sm"><Spinner /></Dialog>;
const { rules } = sieve.rules();
if (rules === null) {
return (
<Dialog open onClose={onClose} title="Create filter" size="sm" footer={<button className="btn" onClick={onClose}>Close</button>}>
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>Settings Filters & rules</b> to edit the script or switch to managed rules.</p>
</Dialog>
);
}
return (
<RuleDialog
rule={rule}
title="Filter messages like this"
saveLabel="Create filter"
applyMailbox={mailbox ? { id: mailbox.id, name: mailbox.name } : null}
onClose={onClose}
onSave={(r, applyNow) => {
onClose();
void saveAndApply(r, rules, applyNow && mailbox ? mailbox.id : null);
}}
/>
);
}
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
const sieve = useSieve.getState();
try {
await sieve.saveRules([...existing.filter((x) => x.id !== r.id), r]);
} catch (err) {
toast.error(`Could not save filter: ${(err as Error).message}`);
return;
}
if (!applyMailboxId) {
toast.success("Filter created — it will run on new mail");
return;
}
const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
try {
const res = await applyRuleToMailbox(r, applyMailboxId);
toast.dismiss(tid);
toast.success(`Filter created · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
} catch (err) {
toast.dismiss(tid);
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
}
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { Calendar, Check, HelpCircle, MapPin, X } from "lucide-react";
import { useLocation } from "wouter";
import type { CalendarEvent, Email, EmailBodyPart } from "@/jmap/types";
import { useCalendar, toInstance, myParticipantKeys } from "@/store/calendar";
import { formatTimeRange } from "@/lib/dates";
import { toast } from "@/ui/toast";
export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart }) {
const cal = useCalendar();
const [, navigate] = useLocation();
const [events, setEvents] = useState<CalendarEvent[] | null>(null);
const [existing, setExisting] = useState<CalendarEvent | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!cal.available || !part.blobId) return;
let cancelled = false;
cal
.parseIcs(part.blobId)
.then(async (evs) => {
if (cancelled) return;
setEvents(evs);
const first = evs[0];
if (first?.uid) setExisting(await cal.findByUid(first.uid));
})
.catch((err) => !cancelled && setError((err as Error).message));
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [part.blobId, cal.available]);
if (!cal.available) return null;
if (error) return null;
const ev = events?.[0];
if (!ev) return null;
const method = (ev.method ?? "").toUpperCase();
const inst = toInstance({ ...ev, id: "tmp", calendarIds: {} } as CalendarEvent, cal.calendars);
const organizer = Object.values(ev.participants ?? {}).find((p) => p.roles?.owner);
const location = Object.values(ev.locations ?? {})[0]?.name;
const myStatus = existing ? (myParticipantKeys(existing, cal.identities).map((k) => existing.participants?.[k]?.participationStatus)[0] ?? null) : null;
const attendees = Object.values(ev.participants ?? {}).filter((p) => p.roles?.attendee);
const respond = async (status: "accepted" | "tentative" | "declined") => {
setBusy(status);
try {
let target = existing;
if (!target) {
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
if (!calId) throw new Error("No calendar available");
const id = await cal.importEvent(ev, calId);
target = await cal.getEvent(id);
}
if (!target) throw new Error("Could not add the event to your calendar");
await cal.rsvp(target.id, status);
setExisting(await cal.getEvent(target.id));
toast.success(status === "accepted" ? "Invitation accepted" : status === "declined" ? "Invitation declined" : "Marked as tentative");
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(null);
}
};
const addToCalendar = async () => {
setBusy("add");
try {
const calId = Object.values(cal.calendars).find((c) => c.isDefault)?.id ?? Object.keys(cal.calendars)[0];
if (!calId) throw new Error("No calendar available");
const id = await cal.importEvent(ev, calId);
setExisting(await cal.getEvent(id));
toast.success("Added to your calendar");
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(null);
}
};
const title = method === "CANCEL" ? "Cancelled event" : method === "REPLY" ? "Invitation reply" : method === "REQUEST" ? (existing ? "Invitation (in your calendar)" : "Invitation") : "Event";
return (
<div className="invite-card">
<div className="row" style={{ alignItems: "flex-start" }}>
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
<div className="grow">
<div className="hint" style={{ marginBottom: 2 }}>{title}{method === "REPLY" && organizer ? "" : ""}</div>
<h4>{ev.title || "(untitled event)"}</h4>
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>}
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
{organizer && <div className="small muted">Organizer: {organizer.name || organizer.email || Object.values(organizer.sendTo ?? {})[0]?.replace("mailto:", "")}</div>}
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>}
{method === "REPLY" && (
<div className="small" style={{ marginTop: 4 }}>
{attendees.map((a) => <div key={a.email ?? a.name}>{a.name || a.email}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
</div>
)}
</div>
</div>
{method !== "REPLY" && method !== "CANCEL" && (
<div className="rsvp">
{(method === "REQUEST" || attendees.length > 0) ? (
<>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("accepted")}><Check size={14} /> {myStatus === "accepted" ? "Accepted" : "Yes"}</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("tentative")}><HelpCircle size={14} /> {myStatus === "tentative" ? "Tentative" : "Maybe"}</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("declined")}><X size={14} /> {myStatus === "declined" ? "Declined" : "No"}</button>
</>
) : (
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
)}
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>Open in calendar</button>}
</div>
)}
{method === "CANCEL" && existing && (
<div className="rsvp">
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing.id, false); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>Remove from calendar</button>
</div>
)}
<span className="sr-only">{email.id}</span>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { useSettings } from "@/store/settings";
import { useMail } from "@/store/mail";
import { Popover } from "@/ui/popover";
import type { Id } from "@/jmap/types";
import { CALENDAR_COLORS } from "@/ui/misc";
/** Labels are IMAP keywords on the messages; their names/colors live in settings. */
export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; anchor: { x: number; y: number }; onClose: () => void; onApplied?: () => void }) {
const labels = useSettings((s) => s.settings.labels);
const update = useSettings((s) => s.update);
const emails = useMail((s) => s.emails);
const setKeyword = useMail((s) => s.setKeyword);
const [q, setQ] = useState("");
const [creating, setCreating] = useState(false);
const has = (kw: string) => ids.every((id) => emails[id]?.keywords[kw]);
const some = (kw: string) => ids.some((id) => emails[id]?.keywords[kw]);
const filtered = labels.filter((l) => l.name.toLowerCase().includes(q.toLowerCase()));
const create = () => {
const name = q.trim();
if (!name) return;
const keyword = name.toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
if (labels.some((l) => l.keyword === keyword)) return;
const color = CALENDAR_COLORS[labels.length % CALENDAR_COLORS.length]!;
update({ labels: [...labels, { keyword, name, color }] });
void setKeyword(ids, keyword, true).then(onApplied);
setQ("");
setCreating(false);
};
return (
<Popover anchor={{ x: anchor.x, y: anchor.y, w: 0, h: 0 }} onClose={onClose} width={260} closeOnClick={false}>
<div className="menu-title">Label as</div>
<div className="menu-search">
<input
className="input sm"
autoFocus
placeholder={labels.length ? "Search or create label" : "New label name"}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length === 1 && !creating) {
const l = filtered[0]!;
void setKeyword(ids, l.keyword, !has(l.keyword)).then(onApplied);
} else create();
}
}}
/>
</div>
{filtered.map((l) => {
const all = has(l.keyword);
const partial = !all && some(l.keyword);
return (
<label key={l.keyword} className="menu-item" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={all}
ref={(el) => {
if (el) el.indeterminate = partial;
}}
onChange={(e) => void setKeyword(ids, l.keyword, e.target.checked).then(onApplied)}
style={{ accentColor: l.color }}
/>
<span className="label-dot" style={{ background: l.color }} />
<span className="grow truncate">{l.name}</span>
</label>
);
})}
{q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && (
<button className="menu-item" onClick={create}>
<Plus size={16} />
<span>Create {q.trim()}</span>
</button>
)}
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>Type a name to create your first label.</div>}
</Popover>
);
}
+313
View File
@@ -0,0 +1,313 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearch } from "wouter";
import { DEFAULT_SORT, useMail, type ListQuery } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose";
import { buildFilter, describeFilter, parseQuery } from "@/lib/search";
import { keyboard } from "@/lib/keyboard";
import { useIsNarrow } from "@/ui/misc";
import { Splitter } from "@/ui/Splitter";
import { MessageList } from "./MessageList";
import { ThreadView } from "./ThreadView";
import { MailboxPicker } from "./MailboxPicker";
import { LabelPicker } from "./LabelPicker";
import type { Id } from "@/jmap/types";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
const [, navigate] = useLocation();
const searchStr = useSearch();
const mailboxes = useMail((s) => s.mailboxes);
const mailboxesLoaded = useMail((s) => s.mailboxesLoaded);
const inboxId = useMail((s) => s.roleId("inbox"));
const query = useMail((s) => s.query);
const list = useMail((s) => s.list);
const settings = useSettings((s) => s.settings);
const narrow = useIsNarrow();
const [focusId, setFocusId] = useState<Id | null>(null);
const [movePicker, setMovePicker] = useState<{ ids: Id[] } | null>(null);
const [labelPicker, setLabelPicker] = useState<{ ids: Id[]; anchor: { x: number; y: number } } | null>(null);
const q = useMemo(() => (search ? (new URLSearchParams(searchStr).get("q") ?? "") : ""), [search, searchStr]);
// Redirect /mail → inbox
useEffect(() => {
if (!search && !mailboxId && inboxId) navigate(`/mail/${inboxId}`, { replace: true });
}, [search, mailboxId, inboxId, navigate]);
// Build & run the list query
const listQuery = useMemo<ListQuery | null>(() => {
if (search) {
if (!q) return null;
const parsed = parseQuery(q);
const filter = buildFilter(parsed, mailboxes, null);
const inMb = parsed.in ? (Object.values(mailboxes).find((m) => m.name.toLowerCase() === parsed.in!.toLowerCase())?.id ?? null) : null;
return { key: "", filter, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode, mailboxId: inMb, label: describeFilter(parsed) };
}
if (!mailboxId) return null;
const mb = mailboxes[mailboxId];
const isDraftsOrSent = mb?.role === "drafts" || mb?.role === "sent";
return { key: "", filter: { inMailbox: mailboxId }, sort: DEFAULT_SORT, collapseThreads: settings.conversationMode && !isDraftsOrSent, mailboxId };
}, [search, q, mailboxId, mailboxes, settings.conversationMode]);
useEffect(() => {
if (listQuery && mailboxesLoaded) void query(listQuery);
}, [listQuery, query, mailboxesLoaded]);
const openThread = useCallback(
(tid: Id | null) => {
const base = search ? `/search` : `/mail/${mailboxId}`;
const qs = search ? `?q=${encodeURIComponent(q)}` : "";
navigate(tid ? `${base}/${tid}${qs}` : `${base}${qs}`);
},
[navigate, search, mailboxId, q],
);
// Row ids in list + helpers for keyboard nav
const ids = list?.ids ?? [];
const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
const selected = useMail((s) => s.selected);
const rowThreadId = useCallback((rowId: Id) => emails[rowId]?.threadId, [emails]);
const currentRowIndex = useMemo(() => {
if (focusId) {
const i = ids.indexOf(focusId);
if (i >= 0) return i;
}
if (threadId) return ids.findIndex((id) => rowThreadId(id) === threadId);
return -1;
}, [ids, focusId, threadId, rowThreadId]);
/** Email ids affected by an action on rows (selection or focused/open row). */
const targetIds = useCallback(
(rowIds?: Id[]): Id[] => {
const rows = rowIds ?? (Object.keys(selected).length ? Object.keys(selected) : focusId ? [focusId] : threadId ? ids.filter((id) => rowThreadId(id) === threadId) : []);
const out = new Set<Id>();
for (const r of rows) {
const e = emails[r];
if (!e) continue;
if (list?.collapseThreads) {
const t = threads[e.threadId];
const inScope = t ? t.emailIds.filter((id) => (list.mailboxId ? emails[id]?.mailboxIds[list.mailboxId] : true)) : [r];
for (const id of inScope.length ? inScope : [r]) out.add(id);
} else out.add(r);
}
return [...out];
},
[selected, focusId, threadId, ids, rowThreadId, emails, threads, list],
);
const afterAction = useCallback(
(removed: boolean) => {
useMail.getState().clearSelection();
if (!removed) return;
// auto-advance
if (threadId) {
const idx = currentRowIndex;
const adv = settings.autoAdvance;
if (adv === "list" || idx < 0) openThread(null);
else {
const next = adv === "older" ? ids[idx + 1] : ids[idx - 1];
const nt = next ? rowThreadId(next) : undefined;
if (nt) openThread(nt);
else openThread(null);
}
}
},
[threadId, currentRowIndex, settings.autoAdvance, ids, rowThreadId, openThread],
);
const actions = useMemo(
() => ({
archive: async (rows?: Id[]) => {
const t = targetIds(rows);
if (!t.length) return;
await useMail.getState().archive(t);
afterAction(true);
},
trash: async (rows?: Id[]) => {
const t = targetIds(rows);
if (!t.length) return;
const mail = useMail.getState();
const trashId = mail.roleId("trash");
const permanent = t.every((id) => trashId && mail.emails[id]?.mailboxIds[trashId]);
if (permanent || settings.confirmDelete) {
const ok = await confirmDialog({ title: permanent ? "Delete forever?" : "Delete?", message: permanent ? `${t.length} message(s) will be permanently deleted.` : `Move ${t.length} message(s) to Trash?`, confirmLabel: "Delete", danger: permanent });
if (!ok) return;
}
await mail.trash(t);
afterAction(true);
},
spam: async (rows?: Id[]) => {
const t = targetIds(rows);
if (!t.length) return;
const mail = useMail.getState();
const junk = mail.roleId("junk");
const inJunk = t.every((id) => junk && mail.emails[id]?.mailboxIds[junk]);
await mail.spam(t, !inJunk);
afterAction(true);
},
read: async (read: boolean, rows?: Id[]) => {
const t = targetIds(rows);
if (t.length) await useMail.getState().markRead(t, read);
useMail.getState().clearSelection();
},
star: async (on: boolean, rows?: Id[]) => {
const t = targetIds(rows);
if (t.length) await useMail.getState().star(t, on);
},
move: (rows?: Id[]) => {
const t = targetIds(rows);
if (t.length) setMovePicker({ ids: t });
},
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => {
const t = targetIds(rows);
if (t.length) setLabelPicker({ ids: t, anchor });
},
moveTo: async (ids: Id[], mailboxId: Id) => {
await useMail.getState().move(ids, mailboxId);
afterAction(true);
},
}),
[targetIds, afterAction, settings.confirmDelete],
);
// Keyboard shortcuts for the list/thread
const focusRef = useRef(focusId);
focusRef.current = focusId;
useEffect(() => {
const moveFocus = (delta: number) => {
const cur = focusRef.current ? ids.indexOf(focusRef.current) : currentRowIndex;
const next = Math.max(0, Math.min(ids.length - 1, (cur < 0 ? (delta > 0 ? -1 : 0) : cur) + delta));
const id = ids[next];
if (!id) return;
setFocusId(id);
if (threadId && settings.readingPane !== "off") {
const t = rowThreadId(id);
if (t) openThread(t);
}
document.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
};
return keyboard.pushScope("mail", [
{ keys: "j", description: "Next conversation", group: "Mail", handler: () => moveFocus(1) },
{ keys: "k", description: "Previous conversation", group: "Mail", handler: () => moveFocus(-1) },
{ keys: "arrowdown", description: "", group: "Mail", handler: () => moveFocus(1) },
{ keys: "arrowup", description: "", group: "Mail", handler: () => moveFocus(-1) },
{ keys: "o", description: "Open conversation", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) openThread(t); } },
{ keys: "enter", description: "", group: "Mail", handler: () => { const id = focusRef.current; const t = id ? rowThreadId(id) : undefined; if (t) { openThread(t); return; } return false; } },
{ keys: "u", description: "Back to list", group: "Mail", handler: () => openThread(null) },
{ keys: "esc", description: "Back to list / clear selection", group: "Mail", handler: () => { if (Object.keys(useMail.getState().selected).length) useMail.getState().clearSelection(); else openThread(null); } },
{ keys: "x", description: "Select conversation", group: "Mail", handler: () => { const id = focusRef.current ?? ids[currentRowIndex]; if (id) useMail.getState().select([id], !useMail.getState().selected[id]); } },
{ keys: "e", description: "Archive", group: "Actions", handler: () => void actions.archive() },
{ keys: "y", description: "", group: "Actions", handler: () => void actions.archive() },
{ keys: "#", description: "Delete", group: "Actions", handler: () => void actions.trash() },
{ keys: "delete", description: "", group: "Actions", handler: () => void actions.trash() },
{ keys: "!", description: "Report spam / not spam", group: "Actions", handler: () => void actions.spam() },
{ keys: "s", description: "Star / unstar", group: "Actions", handler: () => { const t = targetIds(); const on = !t.every((id) => emails[id]?.keywords.$flagged); void actions.star(on); } },
{ keys: "shift+i", description: "Mark as read", group: "Actions", handler: () => void actions.read(true) },
{ keys: "shift+u", description: "Mark as unread", group: "Actions", handler: () => void actions.read(false) },
{ keys: "v", description: "Move to…", group: "Actions", handler: () => actions.move() },
{ keys: "l", description: "Label…", group: "Actions", handler: () => actions.label(undefined, { x: window.innerWidth / 2, y: 80 }) },
{ keys: "*+a", description: "", group: "Actions", handler: () => useMail.getState().selectAll() },
{ keys: "mod+a", description: "Select all", group: "Mail", handler: () => { useMail.getState().selectAll(); } },
{ keys: "r", description: "Reply", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "reply" })) },
{ keys: "a", description: "Reply all", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "replyAll" })) },
{ keys: "f", description: "Forward", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:reply", { detail: "forward" })) },
{ keys: "n", description: "Next message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: 1 })) },
{ keys: "p", description: "Previous message in conversation", group: "Conversation", handler: () => window.dispatchEvent(new CustomEvent("ihm:msg-nav", { detail: -1 })) },
{ keys: "]", description: "Archive and next", group: "Conversation", handler: () => void actions.archive() },
]);
}, [ids, currentRowIndex, threadId, settings.readingPane, rowThreadId, openThread, actions, targetIds, emails]);
const openDraft = useCompose((s) => s.openDraftEmail);
const onOpenRow = useCallback(
(rowId: Id) => {
const e = emails[rowId];
if (!e) return;
setFocusId(rowId);
const mb = mailboxId ? mailboxes[mailboxId] : undefined;
if (mb?.role === "drafts" && e.keywords.$draft) {
void openDraft(e);
return;
}
openThread(e.threadId);
},
[emails, mailboxId, mailboxes, openThread, openDraft],
);
const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
const reading = Boolean(threadId);
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
const showReading = settings.readingPane !== "off" || reading;
const layoutRef = useRef<HTMLDivElement>(null);
const updateSettings = useSettings((s) => s.update);
const [liveSize, setLiveSize] = useState<number | null>(null);
const paneSize = liveSize ?? (settings.readingPane === "bottom" ? settings.listPaneHeight : settings.listPaneWidth);
const onSplit = (delta: number) => {
const el = layoutRef.current;
const total = el ? (settings.readingPane === "bottom" ? el.clientHeight : el.clientWidth) : 1200;
const min = settings.readingPane === "bottom" ? 160 : 320;
const max = Math.max(min, total - (settings.readingPane === "bottom" ? 200 : 420));
setLiveSize((cur) => Math.min(max, Math.max(min, (cur ?? paneSize) + delta)));
};
const onSplitEnd = () => {
if (liveSize == null) return;
updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: liveSize } : { listPaneWidth: liveSize });
setLiveSize(null);
};
return (
<div ref={layoutRef} className={`mail-layout ${paneClass} ${reading ? "reading" : ""}`} style={{ "--list-size": `${paneSize}px` } as React.CSSProperties}>
{showList && (
<MessageList
title={title}
list={list}
openThreadId={threadId ?? null}
focusId={focusId}
setFocusId={setFocusId}
onOpen={onOpenRow}
actions={actions}
mailboxId={mailboxId ?? null}
isSearch={Boolean(search)}
/>
)}
{showList && showReading && settings.readingPane !== "off" && !narrow && (
<Splitter direction={settings.readingPane === "bottom" ? "horizontal" : "vertical"} onResize={onSplit} onEnd={onSplitEnd} onReset={() => updateSettings(settings.readingPane === "bottom" ? { listPaneHeight: 340 } : { listPaneWidth: 520 })} ariaLabel="Resize message list" />
)}
{showReading && (
<div className="mail-reading-pane">
{threadId ? (
<ThreadView key={threadId} threadId={threadId} mailboxId={mailboxId ?? null} onBack={() => openThread(null)} actions={actions} onNavigate={(delta) => { const idx = currentRowIndex; const next = ids[idx + delta]; const t = next ? rowThreadId(next) : undefined; if (t) { setFocusId(next!); openThread(t); } }} hasPrev={currentRowIndex > 0} hasNext={currentRowIndex >= 0 && currentRowIndex < ids.length - 1} />
) : (
<div className="no-thread">
<img src="/img/logo.png" alt="" />
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
<div className="hint">Select a conversation to read it here · Press <kbd className="kbd">?</kbd> for shortcuts</div>
</div>
)}
</div>
)}
{movePicker && (
<MailboxPicker
title={`Move ${movePicker.ids.length} message${movePicker.ids.length === 1 ? "" : "s"} to…`}
onClose={() => setMovePicker(null)}
onPick={(mbId) => {
setMovePicker(null);
void actions.moveTo(movePicker.ids, mbId);
}}
/>
)}
{labelPicker && (
<LabelPicker
ids={labelPicker.ids}
anchor={labelPicker.anchor}
onClose={() => setLabelPicker(null)}
onApplied={() => toast.show("Labels updated")}
/>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useMemo, useState } from "react";
import { Folder, Inbox } from "lucide-react";
import { useMail } from "@/store/mail";
import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const list = useMemo(() => {
const all = Object.values(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
.map((m) => ({ m, path: mailboxPath(m.id) }))
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
const ql = q.trim().toLowerCase();
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
}, [mailboxes, mailboxPath, q, exclude]);
return (
<Dialog open onClose={onClose} title={title} size="sm">
<input
className="input"
autoFocus
placeholder="Type a folder name…"
value={q}
onChange={(e) => {
setQ(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(list.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const m = list[active]?.m;
if (m) onPick(m.id);
}
}}
/>
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
{list.map(({ m, path }, i) => (
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
))}
{!list.length && <div className="empty" style={{ padding: 24 }}>No matching folders</div>}
</div>
</Dialog>
);
}
function PickerRow({ m, path, active, onClick, onHover }: { m: Mailbox; path: string; active: boolean; onClick: () => void; onHover: () => void }) {
return (
<button className={`menu-item ${active ? "active" : ""}`} onClick={onClick} onMouseEnter={onHover} role="option" aria-selected={active}>
{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
<span className="grow truncate">{path}</span>
<span className="menu-kbd">{m.totalEmails}</span>
</button>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import type { Id, Mailbox } from "@/jmap/types";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage";
const ROLE_ICONS: Record<string, ReactNode> = {
inbox: <Inbox size={20} />,
drafts: <File size={20} />,
sent: <Send size={20} />,
trash: <Trash2 size={20} />,
junk: <AlertOctagon size={20} />,
archive: <Archive size={20} />,
all: <Mail size={20} />,
flagged: <Star size={20} />,
important: <Tag size={20} />,
};
export function MailboxTree() {
const mailboxes = useMail((s) => s.mailboxes);
const loaded = useMail((s) => s.mailboxesLoaded);
const [location] = useLocation();
const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined;
const showHidden = useSettings((s) => s.settings.showHiddenFolders);
const labels = useSettings((s) => s.settings.labels);
const labelsSidebar = useSettings((s) => s.settings.labelsSidebar);
const menu = useMenu();
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
// Tree: AZ at every level (Inbox pinned to the top of the root), subfolders nested and
// collapsed by default. Expansion state is remembered per folder.
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
const toggle = (id: Id) => {
const next = { ...expanded, [id]: !expanded[id] };
setExpanded(next);
saveJson("mbx-expanded", next);
};
const rows = useMemo(() => {
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of all) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
const cmp = (a: Mailbox, b: Mailbox) => {
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
};
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0);
const walk = (parent: Id | null, depth: number) => {
for (const m of (byParent.get(parent) ?? []).sort(cmp)) {
const kids = byParent.get(m.id) ?? [];
const open = Boolean(expanded[m.id]);
const childUnread = kids.length ? subtreeUnread(m.id) : 0;
out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread });
if (kids.length && open) walk(m.id, depth + 1);
}
};
walk(null, 0);
return out;
}, [mailboxes, showHidden, expanded]);
const createFolder = async (parentId: Id | null) => {
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
if (!name?.trim()) return;
try {
await useMail.getState().createMailbox(name.trim(), parentId);
toast.success(`Folder “${name.trim()}” created`);
} catch (err) {
toast.error((err as Error).message);
}
};
if (!loaded) {
return (
<div style={{ padding: "8px 12px", display: "flex", flexDirection: "column", gap: 8 }}>
{[...Array(6)].map((_, i) => (
<div key={i} className="skeleton" style={{ height: 28, width: `${70 + (i % 3) * 10}%` }} />
))}
</div>
);
}
return (
<>
<nav aria-label="Folders" style={{ marginTop: 6 }}>
<div className="nav-section">
<span>Folders</span>
<button className="icon-btn" title="New folder" aria-label="New folder" onClick={() => void createFolder(null)}>
<Plus size={16} />
</button>
</div>
{rows.map(({ m, depth, hasChildren, open, hiddenUnread, childUnread }) => (
<FolderRow key={m.id} mailbox={m} label={m.name} depth={depth} hasChildren={hasChildren} open={open} hiddenUnread={hiddenUnread} childUnread={childUnread} onToggle={() => toggle(m.id)} currentId={currentId} onMenu={(mb, e) => { setMenuTarget(mb); menu.open(e); }} />
))}
{labelsSidebar && labels.length > 0 && (
<>
<div className="nav-section">
<span>Labels</span>
<Link href="/settings/labels" className="icon-btn" title="Manage labels" aria-label="Manage labels">
<Pencil size={14} />
</Link>
</div>
{labels.map((l) => (
<Link key={l.keyword} href={`/search?q=label:${encodeURIComponent(l.keyword)}`} className="nav-item" title={l.name}>
<span className="nav-label-color" style={{ background: l.color }} />
<span className="nav-label">{l.name}</span>
</Link>
))}
</>
)}
</nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={240}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
</Popover>
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
</>
);
}
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void }) {
const [dropping, setDropping] = useState(false);
const own = m.role === "drafts" ? m.totalEmails : m.unreadEmails;
const count = own + hiddenUnread;
// Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold).
const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts";
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : <Folder size={20} />;
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dropping) setDropping(true);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDropping(false);
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
if (!raw) return;
try {
const ids = JSON.parse(raw) as string[];
void useMail.getState().move(ids, m.id);
} catch {
/* ignore */
}
};
return (
<Link
href={`/mail/${m.id}`}
className={`nav-item depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""}`}
title={label}
onDragOver={onDragOver}
onDragLeave={() => setDropping(false)}
onDrop={onDrop}
onContextMenu={(e) => {
e.preventDefault();
onMenu(m, { currentTarget: e.currentTarget });
}}
>
{hasChildren ? (
<span
className="nav-twisty"
role="button"
aria-label={open ? "Collapse" : "Expand"}
aria-expanded={open}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
) : (
depth > 0 && <span style={{ width: 4 }} />
)}
{icon}
<span className="nav-label">{label}</span>
{count > 0 && <span className="nav-count" title={hiddenUnread ? `${own} here, ${hiddenUnread} in subfolders` : undefined}>{count > 9999 ? "9999+" : count}</span>}
{count > 0 && <span className="nav-dot" />}
<button
className="icon-btn nav-more"
aria-label="Folder options"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onMenu(m, e);
}}
>
<MoreVertical size={16} />
</button>
</Link>
);
}
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) {
const [, navigate] = useLocation();
const rename = async () => {
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
if (!name?.trim() || name.trim() === m.name) return;
try {
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
} catch (err) {
toast.error((err as Error).message);
}
};
const remove = async () => {
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
if (!ok) return;
try {
await useMail.getState().destroyMailbox(m.id, true);
toast.success("Folder deleted");
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
} catch (err) {
toast.error((err as Error).message);
}
};
const empty = async () => {
const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true });
if (ok) await useMail.getState().emptyMailbox(m.id);
};
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
return (
<>
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
<MenuItem icon={<FolderPlus size={16} />} label="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? "Hide from list" : "Show in list"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
<MenuItem icon={<Share2 size={16} />} label="Share…" onClick={onShare} />
<MenuSep />
{(m.role === "trash" || m.role === "junk") && <MenuItem icon={<Eraser size={16} />} label="Empty folder" onClick={() => void empty()} danger />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</>
);
}
+441
View File
@@ -0,0 +1,441 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail";
import { useSettings } from "@/store/settings";
import type { Email, Id } from "@/jmap/types";
import { formatListDate } from "@/lib/format";
import { displayName, shortName } from "@/lib/address";
import { Avatar, Empty, useIsMobile } from "@/ui/misc";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { useCompose } from "@/store/compose";
import { FilterFromMessageDialog } from "./FilterFromMessage";
export interface ListActions {
archive: (rows?: Id[]) => Promise<void>;
trash: (rows?: Id[]) => Promise<void>;
spam: (rows?: Id[]) => Promise<void>;
read: (read: boolean, rows?: Id[]) => Promise<void>;
star: (on: boolean, rows?: Id[]) => Promise<void>;
move: (rows?: Id[]) => void;
label: (rows: Id[] | undefined, anchor: { x: number; y: number }) => void;
moveTo: (ids: Id[], mailboxId: Id) => Promise<void>;
}
interface Props {
title: string;
list: ListState | null;
openThreadId: Id | null;
focusId: Id | null;
setFocusId: (id: Id | null) => void;
onOpen: (rowId: Id) => void;
actions: ListActions;
mailboxId: Id | null;
isSearch: boolean;
}
export function MessageList({ title, list, openThreadId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
const [, navigate] = useLocation();
const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
const selected = useMail((s) => s.selected);
const select = useMail((s) => s.select);
const selectAll = useMail((s) => s.selectAll);
const clearSelection = useMail((s) => s.clearSelection);
const loadMore = useMail((s) => s.loadMore);
const refreshList = useMail((s) => s.refreshList);
const mailboxes = useMail((s) => s.mailboxes);
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
const parentRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const [paneWidth, setPaneWidth] = useState(0);
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 0;
setPaneWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const twoLine = isMobile || (paneWidth > 0 && paneWidth < 640);
const ctxMenu = useMenu();
const [ctxRow, setCtxRow] = useState<Id | null>(null);
const moreMenu = useMenu();
const [refreshing, setRefreshing] = useState(false);
const [filterFrom, setFilterFrom] = useState<Email | null>(null);
const lastClick = useRef<Id | null>(null);
const ids = list?.ids ?? [];
const selCount = Object.keys(selected).length;
const mailbox = mailboxId ? mailboxes[mailboxId] : undefined;
const isTrashOrJunk = mailbox?.role === "trash" || mailbox?.role === "junk";
const isDrafts = mailbox?.role === "drafts";
const rowHeight = twoLine ? (settings.density === "compact" ? 56 : settings.density === "comfortable" ? 78 : 66) : settings.density === "compact" ? 36 : settings.density === "comfortable" ? 52 : 44;
const virtualizer = useVirtualizer({
count: ids.length + (list && !list.exhausted ? 1 : 0),
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 12,
});
// Re-measure when the row height changes (one-line ↔ two-line, density).
useEffect(() => {
virtualizer.measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowHeight]);
// Infinite scroll
const items = virtualizer.getVirtualItems();
useEffect(() => {
const last = items[items.length - 1];
if (!last || !list) return;
if (last.index >= ids.length - 5 && !list.loadingMore && !list.exhausted && !list.loading) void loadMore();
}, [items, ids.length, list, loadMore]);
// Pull-to-refresh-ish: manual refresh button
const doRefresh = async () => {
setRefreshing(true);
await refreshList();
await useMail.getState().loadMailboxes();
setRefreshing(false);
};
const onRowClick = useCallback(
(e: MouseEvent, rowId: Id) => {
if (e.shiftKey && lastClick.current) {
const a = ids.indexOf(lastClick.current);
const b = ids.indexOf(rowId);
if (a >= 0 && b >= 0) {
const [s, en] = a < b ? [a, b] : [b, a];
select(ids.slice(s, en + 1), true);
window.getSelection()?.removeAllRanges();
return;
}
}
if (e.ctrlKey || e.metaKey) {
select([rowId], !selected[rowId]);
lastClick.current = rowId;
return;
}
lastClick.current = rowId;
if (selCount > 0 && isMobile) {
select([rowId], !selected[rowId]);
return;
}
onOpen(rowId);
},
[ids, select, selected, selCount, isMobile, onOpen],
);
const onContext = useCallback(
(e: MouseEvent, rowId: Id) => {
e.preventDefault();
setCtxRow(rowId);
setFocusId(rowId);
ctxMenu.openAt(e.clientX, e.clientY);
},
[ctxMenu, setFocusId],
);
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
const allSelected = ids.length > 0 && ids.every((id) => selected[id]);
const someUnread = ctxTargets.some((id) => !emails[id]?.keywords.$seen);
const someUnstarred = ctxTargets.some((id) => !emails[id]?.keywords.$flagged);
return (
<div className="mail-list-pane">
<div className="list-toolbar">
{isMobile && isSearch && (
<button className="icon-btn" onClick={() => navigate("/mail")} aria-label="Back">
<ArrowLeft size={20} />
</button>
)}
<input
type="checkbox"
className="select-all"
aria-label="Select all"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = selCount > 0 && !allSelected;
}}
onChange={() => (allSelected || selCount > 0 ? clearSelection() : selectAll())}
/>
{selCount > 0 ? (
<>
<span className="tb-count">{selCount} selected</span>
<span className="tb-sep" />
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive()}><Archive size={19} /></button>
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<span className="tb-sep" />
<button className="icon-btn" title="Mark as read (Shift+I)" onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
<button className="icon-btn hide-mobile" title="Mark as unread (Shift+U)" onClick={() => void actions.read(false)}><Mail size={19} /></button>
<button className="icon-btn" title="Move to (v)" onClick={() => actions.move()}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => actions.label(undefined, { x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
</>
) : (
<>
<span className="tb-title">{title}</span>
{list && !list.loading && <span className="tb-count">{list.total.toLocaleString()}</span>}
<span className="spacer" />
<button className={`icon-btn ${refreshing ? "active" : ""}`} title="Refresh" onClick={() => void doRefresh()} aria-label="Refresh">
<RefreshCw size={18} className={refreshing ? "spin" : ""} style={refreshing ? { animation: "spin .8s linear infinite" } : undefined} />
</button>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More">
<MoreVertical size={18} />
</button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
<MenuTitle>Reading pane</MenuTitle>
<MenuItem icon={<PanelRight size={16} />} label="Right of the list" checked={settings.readingPane === "right"} onClick={() => updateSettings({ readingPane: "right" })} />
<MenuItem icon={<PanelBottom size={16} />} label="Below the list" checked={settings.readingPane === "bottom"} onClick={() => updateSettings({ readingPane: "bottom" })} />
<MenuItem icon={<PanelTop size={16} />} label="Hidden (open full width)" checked={settings.readingPane === "off"} onClick={() => updateSettings({ readingPane: "off" })} />
<MenuSep />
<MenuItem icon={<CheckSquare size={16} />} label="Select all" onClick={selectAll} />
<MenuItem icon={<MailOpen size={16} />} label="Mark all as read" onClick={() => mailboxId && void useMail.getState().markMailboxRead(mailboxId)} disabled={!mailboxId} />
{isTrashOrJunk && (
<>
<MenuSep />
<MenuItem
danger
icon={<Eraser size={16} />}
label={`Empty ${mailbox?.name}`}
onClick={async () => {
if (await confirmDialog({ title: `Empty ${mailbox?.name}?`, message: "All messages will be permanently deleted.", confirmLabel: "Empty", danger: true })) void useMail.getState().emptyMailbox(mailboxId!);
}}
/>
</>
)}
</Popover>
</>
)}
</div>
{list?.error && (
<div className="list-hint">
<span className="grow" style={{ color: "var(--danger)" }}>{list.error}</span>
<button onClick={() => void doRefresh()}>Retry</button>
</div>
)}
<div ref={parentRef} className={`mail-list ${selCount ? "has-selection" : ""} ${twoLine ? "two-line" : ""} ${settings.density === "compact" ? "compact" : ""}`} tabIndex={-1}>
{list?.loading && ids.length === 0 ? (
<div style={{ padding: 8 }}>
{[...Array(12)].map((_, i) => (
<div key={i} className="row" style={{ height: rowHeight, padding: "0 8px", gap: 12 }}>
<span className="skeleton" style={{ width: 32, height: 32, borderRadius: 16 }} />
<span className="skeleton" style={{ width: 140, height: 14 }} />
<span className="skeleton grow" style={{ height: 14 }} />
<span className="skeleton" style={{ width: 50, height: 12 }} />
</div>
))}
</div>
) : ids.length === 0 && list && !list.loading ? (
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
</Empty>
) : (
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
{items.map((vi) => {
const id = ids[vi.index];
if (!id) {
return (
<div key="loader" className="list-footer" style={{ position: "absolute", top: vi.start, left: 0, right: 0, height: vi.size }}>
{list?.loadingMore ? <span className="spinner" style={{ display: "inline-block" }} /> : ""}
</div>
);
}
const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
return (
<Row
key={id}
email={e}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined}
top={vi.start}
height={vi.size}
selected={Boolean(selected[id])}
focused={focusId === id}
open={openThreadId === e.threadId}
twoLine={twoLine}
showAvatar={settings.showAvatars}
showPreview={settings.showPreview}
isDrafts={isDrafts}
mailboxId={mailboxId}
isSent={mailbox?.role === "sent"}
onClick={onRowClick}
onContext={onContext}
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }}
onStar={(rowId, on) => void actions.star(on, [rowId])}
onArchive={(rowId) => void actions.archive([rowId])}
onTrash={(rowId) => void actions.trash([rowId])}
onRead={(rowId, read) => void actions.read(read, [rowId])}
selectedIds={selected}
/>
);
})}
</div>
)}
</div>
<Popover anchor={ctxMenu.anchor} onClose={ctxMenu.close} width={250}>
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "reply"); }} />
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) void useCompose.getState().reply(e, "forward"); }} />
<MenuSep />
<MenuItem icon={<Archive size={16} />} label="Archive" kbd="e" onClick={() => void actions.archive(ctxTargets)} />
<MenuItem icon={<Trash2 size={16} />} label="Delete" kbd="#" onClick={() => void actions.trash(ctxTargets)} />
<MenuItem icon={<AlertOctagon size={16} />} label={mailbox?.role === "junk" ? "Not spam" : "Report spam"} kbd="!" onClick={() => void actions.spam(ctxTargets)} />
<MenuSep />
<MenuItem icon={someUnread ? <MailOpen size={16} /> : <Mail size={16} />} label={someUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(someUnread, ctxTargets)} />
<MenuItem icon={<Star size={16} />} label={someUnstarred ? "Add star" : "Remove star"} kbd="s" onClick={() => void actions.star(someUnstarred, ctxTargets)} />
<MenuItem icon={<FolderInput size={16} />} label="Move to…" kbd="v" onClick={() => actions.move(ctxTargets)} />
<MenuItem icon={<Tag size={16} />} label="Label…" kbd="l" onClick={() => actions.label(ctxTargets, ctxMenu.anchor ?? { x: 0, y: 0 })} />
<MenuSep />
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => { const e = ctxRow ? emails[ctxRow] : undefined; if (e) setFilterFrom(e); }} />
</Popover>
{filterFrom && <FilterFromMessageDialog email={filterFrom} mailboxId={mailboxId} onClose={() => setFilterFrom(null)} />}
</div>
);
}
interface RowProps {
email: Email;
threadEmails?: Email[];
top: number;
height: number;
selected: boolean;
focused: boolean;
open: boolean;
twoLine: boolean;
showAvatar: boolean;
showPreview: boolean;
isDrafts: boolean;
isSent: boolean;
mailboxId: Id | null;
selectedIds: Record<Id, true>;
onClick: (e: MouseEvent, id: Id) => void;
onContext: (e: MouseEvent, id: Id) => void;
onSelect: (id: Id, on: boolean) => void;
onStar: (id: Id, on: boolean) => void;
onArchive: (id: Id) => void;
onTrash: (id: Id) => void;
onRead: (id: Id, read: boolean) => void;
}
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
const labels = useSettings((s) => s.settings.labels);
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
const scope = inScope.length ? inScope : [e];
const unread = scope.some((x) => !x.keywords.$seen);
const starred = scope.some((x) => x.keywords.$flagged);
const hasAtt = scope.some((x) => x.hasAttachment);
const answered = e.keywords.$answered;
const forwarded = e.keywords.$forwarded;
const latest = scope.reduce((a, b) => (a.receivedAt > b.receivedAt ? a : b), scope[0]!);
const count = threadEmails ? scope.length : 0;
// Participants: Gmail-style "Ann, Bob, Me (3)"
const names = useMemo(() => {
const out: string[] = [];
const seen = new Set<string>();
const src = isSent || isDrafts ? scope.flatMap((x) => x.to ?? []) : scope.map((x) => x.from?.[0]).filter(Boolean);
for (const a of src) {
if (!a) continue;
const k = a.email.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
out.push(count > 1 ? shortName(a) : displayName(a));
}
return out;
}, [scope, isSent, isDrafts, count]);
const who = (isSent || isDrafts ? (names.length ? `To: ${names.join(", ")}` : "(no recipients)") : names.join(", ")) || "(unknown)";
const rowLabels = labels.filter((l) => scope.some((x) => x.keywords[l.keyword]));
const onDragStart = (ev: DragEvent) => {
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
// include thread emails in scope
const all = new Set<Id>();
for (const id of ids) {
all.add(id);
}
for (const x of scope) all.add(x.id);
ev.dataTransfer.setData("application/x-ihasmail-emails", JSON.stringify([...all]));
ev.dataTransfer.effectAllowed = "move";
const ghost = document.createElement("div");
ghost.className = "drag-ghost";
ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`;
document.body.appendChild(ghost);
ev.dataTransfer.setDragImage(ghost, 10, 10);
setTimeout(() => ghost.remove(), 0);
};
return (
<div
className={`msg-row ${unread ? "unread" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${open ? "open" : ""}`}
style={{ top, height }}
data-row-id={e.id}
onClick={(ev) => onClick(ev, e.id)}
onContextMenu={(ev) => onContext(ev, e.id)}
draggable
onDragStart={onDragStart}
role="row"
aria-selected={selected}
>
<input type="checkbox" className="msg-check" checked={selected} onClick={(ev) => ev.stopPropagation()} onChange={(ev) => onSelect(e.id, ev.target.checked)} aria-label="Select" />
{!twoLine && (
<button className={`msg-star ${starred ? "on" : ""}`} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={starred ? "Unstar" : "Star"}>
<Star size={18} fill={starred ? "currentColor" : "none"} />
</button>
)}
{showAvatar && <Avatar who={isSent || isDrafts ? (e.to?.[0] ?? null) : (latest.from?.[0] ?? null)} />}
{twoLine ? (
<div className="msg-body">
<div className="msg-line1">
<span className="msg-from truncate">
{who}
{count > 1 && <span className="thread-count"> {count}</span>}
</span>
<span className="msg-meta">
{hasAtt && <Paperclip size={14} className="msg-attach" />}
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
</span>
</div>
<div className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)" }}>Draft</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label="Star">
<Star size={16} fill={starred ? "currentColor" : "none"} />
</button>
</div>
{rowLabels.length > 0 && <div className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</div>}
</div>
) : (
<>
<span className="msg-from" title={who}>
<span className="truncate">{who}</span>
{count > 1 && <span className="thread-count">{count}</span>}
</span>
<span className="msg-main">
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>Draft</span>}
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
<span className="msg-subject">{e.subject || "(no subject)"}</span>
{showPreview && <span className="msg-preview">{latest.preview}</span>}
</span>
<span className="msg-meta">
{(answered || forwarded) && <span className="msg-answered" title={answered ? "Replied" : "Forwarded"}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
{hasAtt && <Paperclip size={14} className="msg-attach" />}
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
<span className="msg-actions">
<button className="icon-btn sm" title="Archive" onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
<button className="icon-btn sm" title="Delete" onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
</span>
</span>
</>
)}
</div>
);
});
+466
View File
@@ -0,0 +1,466 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose";
import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client";
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
import { displayName, formatAddress } from "@/lib/address";
import { EMAIL_BASE_CSS, TEXT_EMAIL_CSS, sanitizeEmailHtml } from "@/lib/html";
import { findQuoteStart, textToHtml } from "@/lib/text";
import { Avatar } from "@/ui/misc";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Dialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { ListActions } from "./MessageList";
import { InviteCard } from "./InviteCard";
import { VCardCard } from "./VCardCard";
import { useSession } from "@/store/session";
interface Props {
email: Email;
expanded: boolean;
onToggle: () => void;
isLast: boolean;
actions: ListActions;
}
export const MessageView = memo(function MessageView({ email: e, expanded, onToggle, actions }: Props) {
const accountId = useMail((s) => s.accountId)!;
const settings = useSettings((s) => s.settings);
const updateSettings = useSettings((s) => s.update);
const reply = useCompose((s) => s.reply);
const [details, setDetails] = useState(false);
const [showSource, setShowSource] = useState(false);
const [showHeaders, setShowHeaders] = useState(false);
const [source, setSource] = useState<string | null>(null);
const [allowRemote, setAllowRemote] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const moreMenu = useMenu();
const from = e.from?.[0];
const senderTrusted = settings.trustedImageSenders.includes((from?.email ?? "").toLowerCase());
const inContacts = useContacts((s) => Boolean(from && s.loaded && s.lookupByEmail(from.email)));
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
const htmlPart = e.htmlBody?.[0];
const textPart = e.textBody?.[0];
const htmlRaw = htmlPart?.partId ? e.bodyValues?.[htmlPart.partId]?.value : undefined;
const textRaw = textPart?.partId ? e.bodyValues?.[textPart.partId]?.value : undefined;
const showHtml = Boolean(htmlRaw);
// Inline images map
const cidMap = useMemo(() => {
const map: Record<string, string> = {};
for (const a of e.attachments ?? []) if (a.cid && a.blobId) map[a.cid] = client.downloadUrl(accountId, a.blobId, a.name ?? "image", a.type, true);
const walk = (p?: EmailBodyPart) => {
if (!p) return;
if (p.cid && p.blobId && !map[p.cid]) map[p.cid] = client.downloadUrl(accountId, p.blobId, p.name ?? "image", p.type, true);
p.subParts?.forEach(walk);
};
walk(e.bodyStructure);
return map;
}, [e.attachments, e.bodyStructure, accountId]);
const rendered = useMemo(() => {
if (!expanded) return null;
if (showHtml) return sanitizeEmailHtml(htmlRaw!, { cidMap, allowRemote: remoteAllowed, proxyRemote: imageProxy });
return null;
}, [expanded, showHtml, htmlRaw, cidMap, remoteAllowed, imageProxy]);
const attachments = useMemo(() => (e.attachments ?? []).filter((a) => !(a.cid && a.disposition === "inline" && a.type.startsWith("image/") && htmlRaw?.includes(`cid:${a.cid}`))), [e.attachments, htmlRaw]);
const icsPart = useMemo(() => findPart(e.bodyStructure, (p) => p.type === "text/calendar" || (p.name ?? "").toLowerCase().endsWith(".ics")), [e.bodyStructure]);
const vcfParts = useMemo(() => (e.attachments ?? []).filter((p) => p.type === "text/vcard" || p.type === "text/x-vcard" || (p.name ?? "").toLowerCase().endsWith(".vcf")), [e.attachments]);
const unsubscribe = e["header:List-Unsubscribe:asText"];
const isHighPriority = /^[12]/.test(e["header:X-Priority:asText"] ?? "") || /high/i.test(e["header:Importance:asText"] ?? "");
const receiptRequested = Boolean(e["header:Disposition-Notification-To:asAddresses"]?.length);
const authFailed = /\b(dkim|spf|dmarc)=fail\b/i.test(e["header:Authentication-Results:asText"] ?? "");
const openSource = async () => {
setShowSource(true);
if (source === null) {
try {
setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822"));
} catch (err) {
setSource(`Could not load source: ${(err as Error).message}`);
}
}
};
const downloadEml = () => {
const a = document.createElement("a");
a.href = client.downloadUrl(accountId, e.blobId, `${(e.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822");
a.download = "";
a.click();
};
const onUnsubscribe = async () => {
if (!unsubscribe) return;
const urls = [...unsubscribe.matchAll(/<([^>]+)>/g)].map((m) => m[1]!);
const mailto = urls.find((u) => u.startsWith("mailto:"));
const http = urls.find((u) => /^https?:/i.test(u));
if (mailto) {
const [addr, qs] = mailto.slice(7).split("?");
const q = new URLSearchParams(qs ?? "");
useCompose.getState().open({ to: [{ name: null, email: addr ?? "" }], subject: q.get("subject") ?? "unsubscribe", html: `<div>${q.get("body") ?? "unsubscribe"}</div>`, text: q.get("body") ?? "unsubscribe" });
toast.show("Unsubscribe message prepared — just hit Send");
} else if (http) {
window.open(http, "_blank", "noopener,noreferrer");
}
};
const collapsedClick = () => {
if (!expanded) onToggle();
};
return (
<article className={`message ${expanded ? "" : "collapsed"} ${!e.keywords.$seen ? "unread-msg" : ""}`} data-msg-id={e.id} onClick={collapsedClick}>
<header className="message-head" onClick={(ev) => { if (expanded && !(ev.target as HTMLElement).closest("button,a,.message-details")) onToggle(); }}>
<Avatar who={from ?? null} />
<div className="who">
<div className="from">
<span>{displayName(from)}</span>
{expanded && from && <span className="email">&lt;{from.email}&gt;</span>}
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>Important</span>}
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
</div>
{expanded ? (
<div className="to">
<span className="truncate">to {summarizeRecipients(e)}</span>
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label="Show details" title="Show details">
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
) : (
<div className="snippet">{e.preview}</div>
)}
</div>
<div className="meta">
{e.hasAttachment && !expanded && <Paperclip size={14} />}
<span className="date" title={formatFullDate(e.receivedAt)}>{expanded ? formatFullDate(e.receivedAt) : formatListDate(e.receivedAt)}</span>
<button className={`icon-btn sm ${e.keywords.$flagged ? "active" : ""}`} style={e.keywords.$flagged ? { color: "var(--star)", background: "transparent" } : undefined} title="Star" onClick={(ev) => { ev.stopPropagation(); void actions.star(!e.keywords.$flagged, [e.id]); }}>
<Star size={17} fill={e.keywords.$flagged ? "currentColor" : "none"} />
</button>
{expanded && (
<>
<button className="icon-btn sm hide-mobile" title="Reply (r)" onClick={(ev) => { ev.stopPropagation(); void reply(e, "reply"); }}><Reply size={17} /></button>
<button className="icon-btn sm" onClick={(ev) => { ev.stopPropagation(); moreMenu.open(ev); }} aria-label="More"><MoreVertical size={17} /></button>
</>
)}
</div>
</header>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="end" width={240}>
<MenuItem icon={<Reply size={16} />} label="Reply" onClick={() => void reply(e, "reply")} />
<MenuItem icon={<ReplyAll size={16} />} label="Reply all" onClick={() => void reply(e, "replyAll")} />
<MenuItem icon={<Forward size={16} />} label="Forward" onClick={() => void reply(e, "forward")} />
<MenuSep />
<MenuItem icon={<Mail size={16} />} label={e.keywords.$seen ? "Mark as unread" : "Mark as read"} onClick={() => void useMail.getState().markRead([e.id], !e.keywords.$seen)} />
<MenuItem icon={<Trash2 size={16} />} label="Delete this message" onClick={() => void useMail.getState().trash([e.id])} />
<MenuSep />
<MenuItem icon={<Eye size={16} />} label="Show original" onClick={() => void openSource()} />
<MenuItem icon={<Code size={16} />} label="Show headers" onClick={() => setShowHeaders(true)} />
<MenuItem icon={<Download size={16} />} label="Download (.eml)" onClick={downloadEml} />
<MenuItem icon={<Printer size={16} />} label="Print" onClick={() => window.print()} />
<MenuItem icon={<Filter size={16} />} label="Filter messages like this…" onClick={() => setFilterOpen(true)} />
{from && (
<>
<MenuSep />
<MenuItem icon={<Ban size={16} />} label={senderTrusted ? "Stop trusting sender images" : "Always show images from sender"} onClick={() => updateSettings({ trustedImageSenders: senderTrusted ? settings.trustedImageSenders.filter((x) => x !== from.email.toLowerCase()) : [...settings.trustedImageSenders, from.email.toLowerCase()] })} />
</>
)}
</Popover>
{expanded && (
<>
{details && (
<dl className="message-details" onClick={(ev) => ev.stopPropagation()}>
<dt>From</dt><dd>{(e.from ?? []).map(formatAddress).join(", ")}</dd>
{e.sender?.length && !(e.sender.length === 1 && e.from?.some((f) => f.email === e.sender![0]!.email)) ? <><dt>Sender</dt><dd>{e.sender.map(formatAddress).join(", ")}</dd></> : null}
{e.replyTo?.length ? <><dt>Reply-To</dt><dd>{e.replyTo.map(formatAddress).join(", ")}</dd></> : null}
<dt>To</dt><dd>{(e.to ?? []).map(formatAddress).join(", ") || "—"}</dd>
{e.cc?.length ? <><dt>Cc</dt><dd>{e.cc.map(formatAddress).join(", ")}</dd></> : null}
{e.bcc?.length ? <><dt>Bcc</dt><dd>{e.bcc.map(formatAddress).join(", ")}</dd></> : null}
<dt>Date</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
<dt>Subject</dt><dd>{e.subject || "(no subject)"}</dd>
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
{receiptRequested && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
</dl>
)}
{rendered && rendered.remoteCount > 0 && !remoteAllowed && (
<div className="remote-banner" style={{ margin: "0 16px 8px" }}>
<ImageIcon size={16} />
<span className="grow">Remote images are blocked to protect your privacy.</span>
<button onClick={() => setAllowRemote(true)}>Show images</button>
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
</div>
)}
{icsPart && <InviteCard email={e} part={icsPart} />}
{vcfParts.map((p) => <VCardCard key={p.blobId ?? p.partId ?? ""} part={p} accountId={accountId} />)}
<div className="message-body">
{showHtml && rendered ? <HtmlBody html={rendered.html} bodyStyle={rendered.bodyStyle} onShowImages={() => setAllowRemote(true)} /> : <TextBody text={textRaw ?? ""} />}
</div>
{attachments.length > 0 && <AttachmentList attachments={attachments} accountId={accountId} email={e} />}
{unsubscribe && (
<div className="unsubscribe-row">
<span>This looks like a mailing list.</span>
<button className="btn btn-ghost btn-sm" onClick={() => void onUnsubscribe()}>Unsubscribe</button>
</div>
)}
</>
)}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
</Dialog>
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
<dl className="message-details" style={{ margin: 0 }}>
{Object.entries(e).filter(([k]) => k.startsWith("header:")).map(([k, v]) => (
<>
<dt key={`${k}-t`}>{k.split(":")[1]}</dt>
<dd key={`${k}-d`} className="mono small">{Array.isArray(v) ? v.map((x: unknown) => (typeof x === "object" && x ? formatAddress(x as EmailAddress) : String(x))).join(", ") : String(v ?? "—")}</dd>
</>
))}
<dt>Received</dt><dd>{formatFullDate(e.receivedAt)}</dd>
{e.inReplyTo?.length ? <><dt>In-Reply-To</dt><dd className="mono small">{e.inReplyTo.join(" ")}</dd></> : null}
{e.references?.length ? <><dt>References</dt><dd className="mono small">{e.references.join(" ")}</dd></> : null}
</dl>
<p className="hint">Use Show original for the complete raw message.</p>
</Dialog>
</article>
);
});
function summarizeRecipients(e: Email): string {
const all = [...(e.to ?? []), ...(e.cc ?? [])];
if (!all.length) return "(undisclosed recipients)";
const me = useMail.getState().identities.map((i) => i.email.toLowerCase());
const names = all.map((a) => (me.includes(a.email.toLowerCase()) ? "me" : displayName(a).split(" ")[0] || a.email));
if (names.length <= 3) return names.join(", ");
return `${names.slice(0, 3).join(", ")} +${names.length - 3}`;
}
function findPart(p: EmailBodyPart | undefined, pred: (p: EmailBodyPart) => boolean): EmailBodyPart | null {
if (!p) return null;
if (pred(p)) return p;
for (const s of p.subParts ?? []) {
const r = findPart(s, pred);
if (r) return r;
}
return null;
}
/* ---------- Body renderers ---------- */
const QUOTE_SELECTORS = [".gmail_quote", "blockquote[type=cite]", ".moz-cite-prefix", "#divRplyFwdMsg", ".yahoo_quoted", "div[id^=appendonsend]", ".ms-outlook-mobile-reference-message", "#OLK_SRC_BODY_SECTION", ".protonmail_quote", ".ihm-quote"];
function HtmlBody({ html, bodyStyle, onShowImages }: { html: string; bodyStyle: string; onShowImages: () => void }) {
const hostRef = useRef<HTMLDivElement>(null);
const [hasQuote, setHasQuote] = useState(false);
const [quoteOpen, setQuoteOpen] = useState(false);
const openCompose = useCompose((s) => s.open);
const onClick = useCallback(
(ev: Event) => {
const t = ev.target as HTMLElement;
const a = t.closest("a");
if (a) {
const href = a.getAttribute("href") ?? "";
if (href.startsWith("mailto:")) {
ev.preventDefault();
const [addr, qs] = href.slice(7).split("?");
const q = new URLSearchParams(qs ?? "");
openCompose({ to: addr ? addr.split(",").map((x) => ({ name: null, email: decodeURIComponent(x.trim()) })) : [], subject: q.get("subject") ?? "", html: q.get("body") ? `<div>${q.get("body")}</div>` : "" });
return;
}
if (/^(javascript|data|vbscript):/i.test(href)) {
ev.preventDefault();
return;
}
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer nofollow");
}
const img = t.closest("img[data-ihm-blocked]");
if (img) onShowImages();
},
[openCompose, onShowImages],
);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
root.innerHTML = `<style>${EMAIL_BASE_CSS}</style><div class="ihm-email-root" style="${bodyStyle.replace(/"/g, "'")}">${html}</div>`;
// Collapse quoted content
const container = root.querySelector(".ihm-email-root") as HTMLElement | null;
let found = false;
if (container) {
let q: Element | null = null;
for (const sel of QUOTE_SELECTORS) {
q = container.querySelector(sel);
if (q) break;
}
if (!q) {
// Heuristic: a blockquote preceded by text ending in "wrote:"
const bqs = Array.from(container.querySelectorAll("blockquote"));
for (const bq of bqs) {
const prev = bq.previousElementSibling;
if (prev && /wrote:\s*$|Original Message|Von:|De :|From:/i.test(prev.textContent ?? "")) {
q = prev;
break;
}
}
if (!q && bqs.length === 1 && (bqs[0]!.textContent?.length ?? 0) > 200) q = bqs[0]!;
}
if (q && q.parentElement) {
// Move q and subsequent siblings into a hidden wrapper (only if q isn't the whole body)
const parent = q.parentElement;
const textBefore = (container.textContent ?? "").indexOf((q.textContent ?? "").slice(0, 40));
if (textBefore > 0 || q.previousElementSibling) {
const wrap = root.ownerDocument.createElement("div");
wrap.className = "ihm-quoted";
wrap.hidden = true;
const nodes: ChildNode[] = [];
let n: ChildNode | null = q.classList.contains("moz-cite-prefix") ? q : q;
while (n) {
nodes.push(n);
n = n.nextSibling;
}
parent.insertBefore(wrap, q);
for (const node of nodes) wrap.appendChild(node);
found = true;
}
}
}
setHasQuote(found);
setQuoteOpen(false);
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}, [html, bodyStyle, onClick]);
useEffect(() => {
const root = hostRef.current?.shadowRoot;
const q = root?.querySelector<HTMLElement>(".ihm-quoted");
if (q) q.hidden = !quoteOpen;
}, [quoteOpen]);
return (
<>
<div ref={hostRef} className="body-host" />
{hasQuote && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
{quoteOpen ? "Hide quoted text" : ""}
</button>
)}
</>
);
}
function TextBody({ text }: { text: string }) {
const hostRef = useRef<HTMLDivElement>(null);
const [quoteOpen, setQuoteOpen] = useState(false);
const openCompose = useCompose((s) => s.open);
const { main, quoted } = useMemo(() => {
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const idx = findQuoteStart(lines);
if (idx > 2) return { main: lines.slice(0, idx).join("\n"), quoted: lines.slice(idx).join("\n") };
return { main: text, quoted: "" };
}, [text]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
root.innerHTML = `<style>${TEXT_EMAIL_CSS}</style><div class="ihm-text-root">${textToHtml(main)}${quoted ? `<div class="ihm-quoted" ${quoteOpen ? "" : "hidden"}>\n${textToHtml(quoted)}</div>` : ""}</div>`;
const onClick = (ev: Event) => {
const a = (ev.target as HTMLElement).closest("a");
if (a && a.getAttribute("href")?.startsWith("mailto:")) {
ev.preventDefault();
openCompose({ to: [{ name: null, email: a.getAttribute("href")!.slice(7) }] });
}
};
root.addEventListener("click", onClick);
return () => root.removeEventListener("click", onClick);
}, [main, quoted, quoteOpen, openCompose]);
return (
<>
<div ref={hostRef} className="body-host" />
{quoted && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
{quoteOpen ? "Hide quoted text" : ""}
</button>
)}
</>
);
}
/* ---------- Attachments ---------- */
export function attachmentIcon(type: string, name?: string | null) {
const t = type.toLowerCase();
const n = (name ?? "").toLowerCase();
if (t.startsWith("image/")) return <ImageIcon size={18} />;
if (t.startsWith("video/")) return <Film size={18} />;
if (t.startsWith("audio/")) return <Music size={18} />;
if (t === "application/pdf") return <FileText size={18} />;
if (/zip|tar|gzip|7z|rar|compressed/.test(t) || /\.(zip|tgz|gz|7z|rar)$/.test(n)) return <FileArchive size={18} />;
if (/spreadsheet|excel|csv/.test(t) || /\.(xlsx?|csv)$/.test(n)) return <FileSpreadsheet size={18} />;
if (t === "text/calendar") return <Calendar size={18} />;
if (t.includes("vcard")) return <UserPlus size={18} />;
if (t.startsWith("text/") || /word|document/.test(t)) return <FileText size={18} />;
return <File size={18} />;
}
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
const viewable = (a: EmailBodyPart) => (a.type.startsWith("image/") && a.type !== "image/svg+xml") || a.type === "application/pdf" || a.type === "text/plain";
return (
<>
<div className="attachments">
{attachments.map((a, i) => {
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
const inlineUrl = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type, true) : "#";
return (
<a key={a.blobId ?? i} className="attachment" href={url} download={a.name ?? undefined} title={`${a.name ?? "attachment"} (${formatSize(a.size)})`} onClick={(ev) => { if (viewable(a)) { ev.preventDefault(); setPreview(a); } }}>
<span className="att-icon">{a.type.startsWith("image/") && a.type !== "image/svg+xml" && a.blobId ? <img src={inlineUrl} alt="" loading="lazy" /> : attachmentIcon(a.type, a.name)}</span>
<span className="att-text">
<span className="att-name">{a.name ?? "(unnamed)"}</span>
<span className="att-size">{formatSize(a.size)}</span>
<span className="att-actions">
<button className="icon-btn xs" title="Download" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); const l = document.createElement("a"); l.href = url; l.download = a.name ?? ""; l.click(); }}><Download size={14} /></button>
{viewable(a) && <button className="icon-btn xs" title="Open in new tab" onClick={(ev) => { ev.preventDefault(); ev.stopPropagation(); window.open(inlineUrl, "_blank", "noopener"); }}><ExternalLink size={14} /></button>}
</span>
</span>
</a>
);
})}
{attachments.length > 1 && (
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "center" }} onClick={() => { for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
<Download size={14} /> Download all
</button>
)}
</div>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
{preview?.type === "application/pdf" && <iframe title="PDF" src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
</Dialog>
</>
);
}
function TextAttachment({ url }: { url: string }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
}, [url]);
return <pre className="code" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
}
+211
View File
@@ -0,0 +1,211 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertOctagon, Archive, ArrowLeft, ChevronDown, ChevronUp, FolderInput, Forward, Mail, MailOpen, MoreVertical, Printer, Reply, ReplyAll, ShieldCheck, Star, Tag, Trash2, Download } from "lucide-react";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import { useCompose } from "@/store/compose";
import type { Email, Id } from "@/jmap/types";
import { MessageView } from "./MessageView";
import type { ListActions } from "./MessageList";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { Spinner } from "@/ui/misc";
import { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker";
interface Props {
threadId: Id;
mailboxId: Id | null;
onBack: () => void;
actions: ListActions;
onNavigate: (delta: number) => void;
hasPrev: boolean;
hasNext: boolean;
}
export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, hasPrev, hasNext }: Props) {
const loadThread = useMail((s) => s.loadThread);
const thread = useMail((s) => s.threads[threadId]);
const emails = useMail((s) => s.emails);
const fullIds = useMail((s) => s.fullIds);
const loading = useMail((s) => Boolean(s.loadingThreads[threadId]));
const mailboxes = useMail((s) => s.mailboxes);
const settings = useSettings((s) => s.settings);
const labels = settings.labels;
const reply = useCompose((s) => s.reply);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<Id, boolean>>({});
const [allExpanded, setAllExpanded] = useState(false);
const [labelAnchor, setLabelAnchor] = useState<{ x: number; y: number } | null>(null);
const moreMenu = useMenu();
const scrollRef = useRef<HTMLDivElement>(null);
const markTimer = useRef<number | null>(null);
// Load
useEffect(() => {
setError(null);
useMail.getState().setOpenThread(threadId);
loadThread(threadId).catch((err) => setError((err as Error).message));
return () => {
if (useMail.getState().openThreadId === threadId) useMail.getState().setOpenThread(null);
};
}, [threadId, loadThread]);
const messages = useMemo(() => {
if (!thread) return [] as Email[];
const all = thread.emailIds.map((id) => emails[id]).filter((e): e is Email => Boolean(e && fullIds[e.id]));
// Conversation view: hide trash/junk messages unless we're in that folder.
const mail = useMail.getState();
const trash = mail.roleId("trash");
const junk = mail.roleId("junk");
const filtered = all.filter((e) => {
if (mailboxId && (mailboxId === trash || mailboxId === junk)) return true;
if (trash && e.mailboxIds[trash]) return false;
if (junk && e.mailboxIds[junk]) return false;
return true;
});
return (filtered.length ? filtered : all).sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
}, [thread, emails, fullIds, mailboxId]);
// Default expansion: unread + last message expanded, others collapsed
const lastId = messages[messages.length - 1]?.id;
const isExpanded = useCallback(
(e: Email) => {
if (e.id in expanded) return expanded[e.id]!;
if (allExpanded) return true;
return !e.keywords.$seen || e.id === lastId || messages.length === 1;
},
[expanded, allExpanded, lastId, messages.length],
);
// Mark as read after delay
useEffect(() => {
if (!messages.length) return;
const unread = messages.filter((e) => !e.keywords.$seen && isExpanded(e)).map((e) => e.id);
if (!unread.length || settings.markReadDelay < 0) return;
if (markTimer.current) window.clearTimeout(markTimer.current);
markTimer.current = window.setTimeout(() => void useMail.getState().markRead(unread, true), settings.markReadDelay * 1000);
return () => {
if (markTimer.current) window.clearTimeout(markTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
// Scroll last expanded into view on load
useEffect(() => {
if (!messages.length || !scrollRef.current) return;
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(lastId ?? "")}"]`);
if (el && messages.length > 1) el.scrollIntoView({ block: "start" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, messages.length > 0]);
// Keyboard: reply/forward events from MailView
useEffect(() => {
const onReply = (ev: Event) => {
const mode = (ev as CustomEvent<"reply" | "replyAll" | "forward">).detail;
const last = messages[messages.length - 1];
if (last) void reply(last, mode);
};
const onNav = (ev: Event) => {
const delta = (ev as CustomEvent<number>).detail;
const els = Array.from(scrollRef.current?.querySelectorAll<HTMLElement>("[data-msg-id]") ?? []);
if (!els.length) return;
const top = scrollRef.current!.getBoundingClientRect().top;
let idx = els.findIndex((el) => el.getBoundingClientRect().top - top > 8);
if (idx < 0) idx = els.length;
const target = els[Math.max(0, Math.min(els.length - 1, (delta > 0 ? idx : idx - 2)))];
if (target) {
const id = target.dataset.msgId!;
setExpanded((x) => ({ ...x, [id]: true }));
target.scrollIntoView({ block: "start", behavior: "smooth" });
}
};
window.addEventListener("ihm:reply", onReply);
window.addEventListener("ihm:msg-nav", onNav);
return () => {
window.removeEventListener("ihm:reply", onReply);
window.removeEventListener("ihm:msg-nav", onNav);
};
}, [messages, reply]);
const subject = messages[0]?.subject || emails[thread?.emailIds[0] ?? ""]?.subject || "(no subject)";
const rowIds = thread ? thread.emailIds.filter((id) => emails[id]) : [];
const anyUnread = messages.some((e) => !e.keywords.$seen);
const anyStarred = messages.some((e) => e.keywords.$flagged);
const inJunk = Boolean(mailboxId && mailboxes[mailboxId]?.role === "junk");
const threadLabels = labels.filter((l) => messages.some((m) => m.keywords[l.keyword]));
const threadMailboxes = useMemo(() => {
const set = new Set<string>();
for (const m of messages) for (const id of Object.keys(m.mailboxIds)) if (mailboxes[id] && id !== mailboxId) set.add(mailboxes[id]!.name);
return [...set];
}, [messages, mailboxes, mailboxId]);
const last = messages[messages.length - 1];
const accountId = useMail((s) => s.accountId);
return (
<div className="thread-view">
<div className="thread-toolbar">
<button className="icon-btn" onClick={onBack} aria-label="Back to list" title="Back (u)">
<ArrowLeft size={20} />
</button>
<button className="icon-btn" title="Archive (e)" onClick={() => void actions.archive(rowIds)}><Archive size={19} /></button>
<button className="icon-btn" title={inJunk ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam(rowIds)}>{inJunk ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
<button className="icon-btn" title="Delete (#)" onClick={() => void actions.trash(rowIds)}><Trash2 size={19} /></button>
<span className="tb-sep hide-mobile" />
<button className="icon-btn hide-mobile" title={anyUnread ? "Mark as read" : "Mark as unread"} onClick={() => void actions.read(anyUnread, rowIds)}>{anyUnread ? <MailOpen size={19} /> : <Mail size={19} />}</button>
<button className="icon-btn hide-mobile" title="Move to (v)" onClick={() => actions.move(rowIds)}><FolderInput size={19} /></button>
<button className="icon-btn hide-mobile" title="Labels (l)" onClick={(e) => setLabelAnchor({ x: e.clientX, y: e.clientY })}><Tag size={19} /></button>
<button className="icon-btn" onClick={moreMenu.open} aria-label="More"><MoreVertical size={19} /></button>
<Popover anchor={moreMenu.anchor} onClose={moreMenu.close} align="start" width={240}>
<MenuItem icon={<Star size={16} />} label={anyStarred ? "Remove star" : "Add star"} onClick={() => void actions.star(!anyStarred, rowIds)} />
<MenuItem icon={<Tag size={16} />} label="Label…" onClick={() => setLabelAnchor({ x: window.innerWidth / 2, y: 100 })} />
<MenuItem icon={allExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} label={allExpanded ? "Collapse all" : "Expand all"} onClick={() => { setAllExpanded((v) => !v); setExpanded({}); }} />
<MenuSep />
<MenuItem icon={<Printer size={16} />} label="Print conversation" onClick={() => window.print()} />
{last && accountId && (
<MenuItem icon={<Download size={16} />} label="Download latest as .eml" onClick={() => { const a = document.createElement("a"); a.href = client.downloadUrl(accountId, last.blobId, `${(last.subject || "message").replace(/[^\w.-]+/g, "_")}.eml`, "message/rfc822"); a.download = ""; a.click(); }} />
)}
</Popover>
<div className="thread-nav hide-mobile">
<button className="icon-btn sm" disabled={!hasPrev} onClick={() => onNavigate(-1)} title="Newer (k)"><ChevronUp size={18} /></button>
<button className="icon-btn sm" disabled={!hasNext} onClick={() => onNavigate(1)} title="Older (j)"><ChevronDown size={18} /></button>
</div>
</div>
<div className="thread-scroll" ref={scrollRef}>
<div className="thread-subject">
<div className="grow">
<h1>{subject}</h1>
{(threadLabels.length > 0 || threadMailboxes.length > 0) && (
<div className="labels">
{threadMailboxes.map((n) => <span key={n} className="chip">{n}</span>)}
{threadLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}
</div>
)}
</div>
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
</div>
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
{loading && !messages.length && <Spinner label="Loading conversation…" />}
{messages.map((e, i) => (
<MessageView
key={e.id}
email={e}
expanded={isExpanded(e)}
onToggle={() => setExpanded((x) => ({ ...x, [e.id]: !isExpanded(e) }))}
isLast={i === messages.length - 1}
actions={actions}
/>
))}
{last && (
<div className="reply-box">
<div className="reply-prompt">
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> Reply</button>
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> Reply all</button>
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> Forward</button>
</div>
</div>
)}
</div>
{labelAnchor && <LabelPicker ids={rowIds} anchor={labelAnchor} onClose={() => setLabelAnchor(null)} />}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
import { UserPlus } from "lucide-react";
import type { EmailBodyPart, Id } from "@/jmap/types";
import { useContacts } from "@/store/contacts";
import { client } from "@/jmap/client";
import { toast } from "@/ui/toast";
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
const contacts = useContacts();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
if (!contacts.available || !part.blobId) return null;
const add = async () => {
setBusy(true);
try {
const text = await client.fetchBlobText(accountId, part.blobId!, "text/vcard");
const book = Object.values(contacts.books).find((b) => b.isDefault) ?? Object.values(contacts.books)[0];
if (!book) throw new Error("No address book available");
const n = await contacts.importVCard(text, book.id);
setDone(true);
toast.success(`Added ${n} contact${n === 1 ? "" : "s"}`);
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="vcard-card">
<UserPlus size={20} style={{ color: "var(--accent)" }} />
<div className="grow">
<div style={{ fontWeight: 600 }}>{part.name ?? "Contact card"}</div>
<div className="hint">vCard attachment</div>
</div>
<button className="btn btn-sm" disabled={busy || done} onClick={() => void add()}>{done ? "Added" : "Add to contacts"}</button>
</div>
);
}