ihasmail 2.0: rebuild as Stalwart-first JMAP webmail

Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a
React 19/Vite SPA. Mail (conversation view, search operators, labels,
sanitised HTML, privacy image proxy, invites, undo send, templates),
calendar (month/week/day/agenda, invites, free/busy, categories,
context menus), contacts (JSContact, groups, vCard), files, Sieve filter
builder (incl. filter-from-message with retroactive apply), vacation,
identities with default + Reply-To, PWA/mobile layout, push via SSE,
in-memory mock Stalwart for dev, Docker + CI.
This commit is contained in:
2026-08-23 01:07:13 -07:00
parent fe17e1d507
commit 645b8b510f
162 changed files with 20398 additions and 1072 deletions
+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} />
</>
);
}