Files
ihasmail-inbuxa/web/src/views/mail/MailboxTree.tsx
T
jcoffey-dev 1e2db95577 Stop offering to share mail folders, and let a share be removed
Sharing a mail folder does nothing. `Mailbox/set` takes the `shareWith`
map, `Mailbox/get` reads it back, and the folder never appears for the
account it was shared with -- confirmed on the live 0.16.19 with a folder
shared read-only to another account on the same server, which never saw
it. Stalwart's sharing documentation lists calendars, address books and
file storage; mail folders are not among them. Nothing anywhere reports a
failure, so a client that trusts what it reads back shows the share as
live for ever, which is what happened.

The entry point is withdrawn. Address book sharing goes with it on a
report that it behaved the same way -- not reproduced, and contradicted
by Stalwart's own docs, so that one is expected back; it is out because
offering a share nobody can verify was worse than the gap. Files and
calendars are untouched.

Removing a share was impossible, for a reason worth writing down. The
dialog rendered the list of who a thing was shared with *inside* the
branch that runs when the directory has principals to offer. A server
with `allowDirectoryQueries` off returns none -- that is the default, and
it is how these shares came to be made in the first place -- so the
dialog showed one line of hint and nothing else. The share was there, and
there was no way to see it, let alone remove it. The list is now rendered
whatever the directory says; only the control for adding somebody new
depends on having somebody to add.

So the withdrawn entry points do not strand what they created: a folder
or book already shared still offers "Stop sharing", which is the one
thing you want when the share is invisible everywhere else.

The API was never the problem, which is worth recording since it was the
first guess: `shareWith: null` is accepted and clears the map, tested
against the live server on the stuck folder, which is now unshared.
2026-08-27 10:28:05 -07:00

385 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react";
import { useMail } from "@/store/mail";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
import { isScheduledMailbox } from "@/store/scheduled";
import { useSettings } from "@/store/settings";
import type { Id, Mailbox } from "@/jmap/types";
import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover";
import { CALENDAR_COLORS } from "@/ui/misc";
import { confirmDialog, promptDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { ShareDialog } from "../settings/ShareDialog";
import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
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} />,
};
/** Its own drag type, so a folder can only be dropped where folders belong. */
const FOLDER_MIME = "application/x-ihasmail-folder";
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);
/**
* The folder being dragged. Held here rather than read from the drag itself:
* dataTransfer.getData is blocked during dragover, so a row cannot ask what
* is over it, and every row needs to know whether it is a legal target.
*/
const [draggingId, setDraggingId] = useState<Id | null>(null);
const [rootDrop, setRootDrop] = useState(false);
/** Whether the folder in flight may be dropped on this folder, or on the root. */
const canDropOn = (targetId: Id | null): boolean => Boolean(draggingId) && canDropFolder(mailboxes, draggingId!, targetId);
const moveFolder = async (id: Id, parentId: Id | null) => {
const m = mailboxes[id];
setDraggingId(null);
try {
await useMail.getState().updateMailbox(id, { parentId });
// Show where it landed rather than leaving it hidden in a closed parent.
if (parentId) {
const next = { ...expanded, [parentId]: true };
setExpanded(next);
saveJson("mbx-expanded", next);
}
toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`);
} catch (err) {
toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
}
};
// 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${rootDrop ? " drop-target" : ""}`}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes(FOLDER_MIME) || !canDropOn(null)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!rootDrop) setRootDrop(true);
}}
onDragLeave={() => setRootDrop(false)}
onDrop={(e) => {
e.preventDefault();
setRootDrop(false);
const id = e.dataTransfer.getData(FOLDER_MIME);
if (id) void moveFolder(id, null);
}}
>
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "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); }}
dragging={draggingId === m.id}
acceptsFolder={canDropOn(m.id)}
onFolderDragStart={() => setDraggingId(m.id)}
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
onFolderDrop={(id) => void moveFolder(id, m.id)}
/>
))}
{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 folder-row" title={l.name}>
<span className="nav-label-color" style={{ "--label-color": l.color } as React.CSSProperties} />
<span className="nav-label">{l.name}</span>
</Link>
))}
</>
)}
</nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} 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, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { 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; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) {
const [dropping, setDropping] = useState(false);
// Scheduled counts like Drafts: everything in it is already read, so the
// useful number is how many messages are waiting, not how many are unseen.
const scheduled = isScheduledMailbox(m);
const own = m.role === "drafts" || scheduled ? 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" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled;
const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? <Clock size={20} /> : <Folder size={20} />;
// A chosen colour tints the icon only; the label keeps the tree's own
// contrast, which a dozen arbitrary colours would not reliably give it.
// Subscribed, not read once: picking a colour has to repaint the row.
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
const onDragOver = (e: DragEvent) => {
const folder = e.dataTransfer.types.includes(FOLDER_MIME);
if (folder ? !acceptsFolder : !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 folderId = e.dataTransfer.getData(FOLDER_MIME);
if (folderId) {
if (acceptsFolder) onFolderDrop(folderId);
return;
}
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 */
}
};
const onDragStart = (e: DragEvent) => {
e.dataTransfer.setData(FOLDER_MIME, m.id);
e.dataTransfer.effectAllowed = "move";
// A folder row is a link, and a link drag would otherwise carry its URL.
e.stopPropagation();
onFolderDragStart();
};
return (
<Link
href={`/mail/${m.id}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
title={label}
draggable={movable(m)}
onDragStart={onDragStart}
onDragEnd={onFolderDragEnd}
onDragOver={onDragOver}
onDragLeave={() => setDropping(false)}
onDrop={onDrop}
onContextMenu={(e) => {
e.preventDefault();
onMenu(m, { currentTarget: e.currentTarget });
}}
>
<span
className="nav-twisty"
role={hasChildren ? "button" : undefined}
aria-label={hasChildren ? (open ? "Collapse" : "Expand") : undefined}
aria-expanded={hasChildren ? open : undefined}
aria-hidden={hasChildren ? undefined : true}
onClick={
hasChildren
? (e) => {
e.preventDefault();
e.stopPropagation();
onToggle();
}
: undefined
}
>
{hasChildren ? open ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
<span className="folder-icon" style={tint ? ({ "--folder-color": tint } as React.CSSProperties) : undefined}>{icon}</span>
<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, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
const shared = Object.keys(m.shareWith ?? {}).length > 0;
const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors);
const update = useSettings((s) => s.update);
const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id));
const subUnread = useMail((s) => {
const all = Object.values(s.mailboxes);
let n = 0;
const walk = (parent: Id) => {
for (const x of all)
if ((x.parentId ?? null) === parent) {
n += x.unreadEmails;
walk(x.id);
}
};
walk(m.id);
return n;
});
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 = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
onClose();
const next = { ...colors };
if (c) next[m.id] = c;
else delete next[m.id];
update({ folderColors: next });
};
return (
<>
<MenuItem icon={<CheckCheck size={16} />} label="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} />
{hasChildren && (
<MenuItem
icon={<CheckCheck size={16} />}
label="Mark all as read, incl. subfolders"
kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined}
onClick={() => void useMail.getState().markMailboxRead(m.id, true)}
disabled={!m.unreadEmails && !subUnread}
/>
)}
<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"} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
docs list calendars, address books and files as shareable and not mail
folders. Offering it produced shares that looked real and did nothing.
One that already exists can still be cleared here, which is the only
reason this entry survives at all. */}
{shared && <MenuItem icon={<Share2 size={16} />} label="Stop sharing" onClick={onShare} />}
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button
key={c}
type="button"
style={{ background: c, width: 26, height: 26, outline: color?.toLowerCase() === c ? "2px solid var(--fg)" : undefined, outlineOffset: 1 }}
aria-label={c}
onClick={() => setColor(c)}
/>
))}
</div>
{color && <MenuItem icon={<X size={16} />} label="Use the default colour" onClick={() => setColor(null)} />}
<MenuSep />
{canEmpty(m.role) && <MenuItem icon={<Eraser size={16} />} label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />}
<MenuItem icon={<Trash2 size={16} />} label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} />
</>
);
}