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