Files
webmail-client/frontend/src/routes/MailLayout.tsx
T
lukasandClaude Opus 4.8 d46ad13b7b M3 plugin platform: frontend host, reference plugins, UI kit + docs
Completes M3 (SPEC §11 + §12 acceptance) on top of the backend platform (4e077f1).

Frontend host (§11.3):
- src/plugins/: manifest loader, runtime ESM bundle loader (framework-agnostic
  mount contract), PluginSlot, PluginPage (app.page at /p/*), plugin context
  (scoped api/events/i18n), capabilities hook. Wired slots into ThreadView
  (message.actions/message.banner), MailLayout sidebar (sidebar.section + page nav)
  and Settings→Accounts (settings.page); ws.ts fans out core + plugin.* events.
- plugin-ui.css: theme-aware .wm-* classes so bundles don't re-derive inline styles.

SDK additions:
- CurrentUserId + require_csrf (plugin-router auth), messages.envelope/header,
  actions.set_flags/trash, sdk.enqueue.

Reference plugins (plugins/, installed via webmail.plugins entry points):
- hello: skeleton page-plugin (app.page + plg_hello_note w/ RLS + cron job) —
  install→run→uninstall --drop-data leaves zero residue.
- labels: message.actions + sidebar.section slots, KV only, zero core diffs.
- filters: message.new hook -> match (subject/from/to) -> Mark read / Pin / Delete
  (delete = move thread to Trash via the core path).

docs/plugins.md: full authoring guide + a complete "Unsubscribe button" worked
example (the §12 "writable from docs alone" bar).

Also: conftest resets the arq pool between tests (loop-bound global, like engine/redis).

Gates: backend ruff + mypy --strict (107) + full suite 337; frontend tsc + eslint
+ vitest (11) + vite build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
2026-07-27 08:30:24 +02:00

1076 lines
42 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 { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate } from "react-router-dom";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { DraftsList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
import { MessageList } from "../components/list/MessageList";
import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner";
import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
import { PluginSlot } from "../plugins/PluginSlot";
function quotaLabel(q: QuotaOut): string {
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
const pctStr = pct >= 10 ? String(Math.round(pct)) : String(Math.round(pct * 10) / 10);
const gb = q.quota_bytes / 1024 ** 3;
const gbStr = Number.isInteger(gb) ? String(gb) : gb.toFixed(1);
return `${pctStr}% of ${gbStr} GB`;
}
// Threads shown per page in the mail list.
const PAGE_SIZE = 50;
// Collapsed sidebar width: just wide enough for the icons.
const COLLAPSED_W = 60;
// Unified per-folder views. "Spam" maps to the IMAP special-use "junk".
const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
{ view: "folder:inbox", icon: "inbox", label: "nav.inbox" },
{ view: "folder:archive", icon: "archive", label: "nav.archive" },
{ view: "folder:drafts", icon: "drafts", label: "nav.drafts" },
{ view: "folder:sent", icon: "sent", label: "nav.sent" },
{ view: "folder:junk", icon: "spam", label: "nav.spam" },
{ view: "folder:trash", icon: "trash", label: "nav.trash" },
];
export function MailLayout() {
const t = useT();
const pluginPages = usePluginPages();
const navigate = useNavigate();
const queryClient = useQueryClient();
const match = useMatch("/mail/t/:threadId");
const threadId = match?.params.threadId;
const [view, setView] = useState<string>("folder:inbox");
const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
const [debouncedTerm, setDebouncedTerm] = useState("");
useEffect(() => {
const h = window.setTimeout(() => setDebouncedTerm(term), 180);
return () => window.clearTimeout(h);
}, [term]);
const searching = debouncedTerm.trim().length > 0;
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
);
const [page, setPage] = useState(0);
const [refreshing, setRefreshing] = useState(false);
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
// bulk-action bar / count while any conversation is selected.
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
// Rows just archived/trashed/deleted from the list: greyed out and unclickable
// for immediate feedback (search results don't always drop the row on refetch).
const [dimmedIds, setDimmedIds] = useState<Set<string>>(new Set());
const toggleCheck = (id: string) =>
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const [composing, setComposing] = useState<ComposerInit | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
// Bumped each time a composer is opened so the <Composer> remounts (fresh state)
// when switching between drafts instead of keeping the previous draft's editor.
const [composeKey, setComposeKey] = useState(0);
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
const searchRef = useRef<HTMLInputElement>(null);
// Resizable panel widths (persisted). Drag the dividers to adjust.
const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240);
const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384);
// Collapsed sidebar shows icons only (persisted).
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1");
const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420);
// The list can be dragged up to 75% of the window width.
const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75);
useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]);
useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]);
useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]);
useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]);
const isDrafts = view === "folder:drafts";
// Which special-use folder the reading pane is showing (drives the toolbar).
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
const threadsQuery = useQuery({
queryKey: ["threads", view, filter, orderParam, page],
queryFn: () =>
api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }),
enabled: !isDrafts && !searching,
placeholderData: keepPreviousData, // keep the current page visible while the next loads
});
const draftsQuery = useQuery({
queryKey: ["drafts"],
queryFn: api.listDrafts,
enabled: isDrafts,
});
const remoteDraftsQuery = useQuery({
queryKey: ["drafts-remote"],
queryFn: api.remoteDrafts,
enabled: isDrafts,
});
// Trash/Spam are excluded from normal search, so while viewing them the search
// box scopes to that folder instead (via an in: operator) and searches only it.
const searchScope =
currentFolder === "trash" ? "in:trash" : currentFolder === "junk" ? "in:junk" : "";
const effectiveQuery = searchScope
? `${searchScope} ${debouncedTerm}`.trim()
: debouncedTerm;
const searchQuery = useQuery({
queryKey: ["search", effectiveQuery],
queryFn: () => api.search(effectiveQuery),
enabled: searching,
});
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
// Folder/inbox views paginate server-side with an accurate grand total; search
// results (a single fetched page of hits) still paginate client-side.
let pageThreads: ThreadSummary[];
let total: number;
if (searching) {
const hits = (searchQuery.data?.threads ?? []).map((h) => h.thread);
const f = hits.filter((th) =>
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
);
const ordered = listOrder === "newest" ? f : [...f].reverse();
total = ordered.length;
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
} else {
pageThreads = threadsQuery.data?.threads ?? [];
total = threadsQuery.data?.total ?? 0;
}
const pageStart = page * PAGE_SIZE;
const pageEnd = pageStart + pageThreads.length;
// Reset to the first page whenever the list identity (not the page) changes.
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]);
// Master "select all" state over the currently listed threads.
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
const allSelected = pageThreads.length > 0 && listedSelectedCount === pageThreads.length;
const someSelected = listedSelectedCount > 0 && !allSelected;
const toggleSelectAll = () =>
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
// Keyboard next/prev navigates within the currently visible page.
const threadsRef = useRef<ThreadSummary[]>(pageThreads);
threadsRef.current = pageThreads;
const detailQuery = useQuery({
queryKey: ["thread", threadId, threadOrder, view],
queryFn: () => api.thread(threadId!, threadOrder, view),
enabled: Boolean(threadId),
});
const markRead = useMutation({
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
// Saved searches (virtual mailboxes, §12 M2).
const savedSearches = savedSearchesQuery.data ?? [];
const saveSearch = useMutation({
mutationFn: (name: string) => api.createSavedSearch(name, term.trim()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
});
const deleteSaved = useMutation({
mutationFn: (id: string) => api.deleteSavedSearch(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
});
const onSaveSearch = () => {
const q = term.trim();
if (!q) return;
const name = window.prompt(t("search.saveNamePrompt"), q);
if (name && name.trim()) saveSearch.mutate(name.trim());
};
const runSavedSearch = (s: SavedSearch) => {
setTerm(s.query);
navigate("/mail");
};
// Switch folder/account view. Clears any active search so the folder actually
// loads — otherwise the list stays stuck on (now-stale) search results.
const selectView = (v: string) => {
setTerm("");
setView(v);
navigate("/mail");
};
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
// cancel it if you leave before then, so a quick glance doesn't mark it read.
useEffect(() => {
const detail = detailQuery.data;
if (!detail || !detail.messages.some((m) => m.is_unread)) return;
const id = detail.id;
const timer = window.setTimeout(() => markRead.mutate(id), 4000);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailQuery.data?.id]);
// Open the composer with fresh state, remounting it if one is already open
// (e.g. clicking a different draft replaces the current editor).
const openComposer = (init: ComposerInit, expand = false) => {
setComposing(init);
setComposerExpanded(expand);
setComposeKey((k) => k + 1);
};
const closeComposer = () => {
setComposing(null);
setComposerExpanded(false);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
};
// Leaving a folder closes any open composer/draft editor: the reading pane
// should reflect the folder you navigated to, not a leftover draft editor.
const firstView = useRef(true);
useEffect(() => {
if (firstView.current) {
firstView.current = false;
return;
}
closeComposer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const afterMove = () => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
navigate("/mail");
};
// Trigger a server-side sync of all folders; refresh the views once the worker
// has had a moment to poll (live WebSocket events also update them).
const doRefresh = async () => {
setRefreshing(true);
try {
await api.syncNow();
} catch {
/* ignore */
}
window.setTimeout(() => {
for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) {
queryClient.invalidateQueries({ queryKey: [key] });
}
setRefreshing(false);
}, 2500);
};
const archive = async () => {
if (!threadId) return;
await api.archiveThread(threadId);
afterMove();
};
const trash = async () => {
if (!threadId) return;
await api.trashThread(threadId);
afterMove();
};
const restore = async () => {
if (!threadId) return;
await api.restoreThread(threadId);
afterMove();
};
const deletePermanent = async () => {
if (!threadId) return;
await api.deleteThreadPermanent(threadId);
afterMove();
};
const togglePin = async () => {
const detail = detailQuery.data;
if (!detail) return;
await api.threadFlags(
detail.id,
detail.pinned ? [] : ["\\Flagged"],
detail.pinned ? ["\\Flagged"] : [],
);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
};
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
// currently-open thread). Close the reading pane only if the acted thread is open.
const listAfterMove = (id: string) => {
// In a folder the refetch below removes the row (it left the folder's scope).
// In search the row may still match a global query, so grey it out for feedback.
if (searching) setDimmedIds((prev) => new Set(prev).add(id));
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (id === threadId) navigate("/mail");
};
const listArchive = async (id: string) => {
await api.archiveThread(id);
listAfterMove(id);
};
const listTrash = async (id: string) => {
await api.trashThread(id);
listAfterMove(id);
};
const listRestore = async (id: string) => {
await api.restoreThread(id);
listAfterMove(id);
};
const listDeletePermanent = async (id: string) => {
await api.deleteThreadPermanent(id);
listAfterMove(id);
};
const listTogglePin = async (id: string, pinned: boolean) => {
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results
queryClient.invalidateQueries({ queryKey: ["thread", id] });
};
// Selection resets when you leave the folder.
useEffect(() => setCheckedIds(new Set()), [view]);
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
// Bulk actions run the given operation over every selected thread, then clear the
// selection and refresh (navigating away if the open thread was among them).
const runBulk = async (action: (id: string) => Promise<unknown>) => {
const ids = [...checkedIds];
for (const id of ids) {
try {
await action(id);
} catch {
/* keep going */
}
}
setCheckedIds(new Set());
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (threadId && ids.includes(threadId)) navigate("/mail");
};
const bulkArchive = () => runBulk((id) => api.archiveThread(id));
const bulkPin = () => runBulk((id) => api.threadFlags(id, ["\\Flagged"], []));
const bulkTrash = () => runBulk((id) => api.trashThread(id));
const bulkRestore = () => runBulk((id) => api.restoreThread(id));
const bulkDeletePermanent = () => runBulk((id) => api.deleteThreadPermanent(id));
// "Empty" a Trash/Spam/Archive folder: permanently delete everything in it. The
// work runs in a worker; refresh the views after it and via the WS message.deleted.
const [emptying, setEmptying] = useState(false);
const emptyCurrentFolder = async () => {
if (!window.confirm(t("mail.emptyConfirm"))) return;
setEmptying(true);
try {
await api.emptyFolder(currentFolder);
} catch {
/* ignore */
}
navigate("/mail"); // an open thread from this folder is about to be deleted
window.setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
setEmptying(false);
}, 1500);
};
const openRelative = (delta: number) => {
const list = threadsRef.current;
if (list.length === 0) return;
const idx = list.findIndex((th) => th.id === threadId);
const next = Math.min(Math.max((idx < 0 ? -1 : idx) + delta, 0), list.length - 1);
navigate(`/mail/t/${list[next].id}`);
};
useKeyboardShortcuts({
onSearch: () => searchRef.current?.focus(),
onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }),
onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }),
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
onArchive: archive,
onGoInbox: () => selectView("folder:inbox"),
onEscape: () => setComposing(null),
onNext: () => openRelative(1),
onPrev: () => openRelative(-1),
});
const accounts = accountsQuery.data ?? [];
return (
<div className="flex h-screen bg-app-bg text-app-fg">
<BackfillBanner />
{/* Sidebar */}
<aside
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
>
<div
className={`flex pb-1 pt-3 ${
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
{!collapsed && (
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
<button
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<Icon name={collapsed ? "show" : "hide"} size={16} />
</button>
<ThemeToggle />
</div>
</div>
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
onClick={() => accounts[0] && openComposer({ accountId: accounts[0].id, kind: "new" })}
>
<Icon name="compose" size={16} />
{!collapsed && t("nav.compose")}
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-[15px]">
{FOLDERS.map((f) => {
const badge =
f.view === "folder:inbox"
? countsQuery.data?.inbox_unread
: f.view === "folder:drafts"
? countsQuery.data?.drafts
: undefined;
return (
<SidebarItem
key={f.view}
active={view === f.view}
collapsed={collapsed}
title={collapsed ? t(f.label) : undefined}
onClick={() => selectView(f.view)}
>
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
{!collapsed && badge ? (
<span className="ml-auto shrink-0 tabular-nums text-[13px] text-app-muted">{badge}</span>
) : null}
</SidebarItem>
);
})}
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")}
</div>
{accounts.map((a) => (
<SidebarItem
key={a.id}
active={view === `account:${a.id}`}
onClick={() => selectView(`account:${a.id}`)}
>
<AccountDot
color={a.color}
syncing={a.state === "backfilling" && a.backfill_total > 0}
total={a.backfill_total}
done={a.backfill_done}
/>
<span className="truncate">{a.email}</span>
</SidebarItem>
))}
{savedSearches.length > 0 && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("search.saved")}
</div>
{savedSearches.map((s) => (
<div key={s.id} className="group/saved relative">
<SidebarItem
active={searching && debouncedTerm === s.query}
onClick={() => runSavedSearch(s)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5L14 14" />
</svg>
<span className="flex-1 truncate">{s.name}</span>
</SidebarItem>
<button
type="button"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={(e) => {
e.stopPropagation();
deleteSaved.mutate(s.id);
}}
className="absolute right-2 top-1/2 hidden -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg group-hover/saved:block"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</>
)}
</>
)}
{/* Plugin pages (app.page) get a nav entry; clicking opens /p{route}. */}
{pluginPages.map((p) => (
<SidebarItem
key={`${p.plugin}${p.route}`}
active={false}
collapsed={collapsed}
title={collapsed ? p.nav.label : undefined}
onClick={() => navigate(`/p${p.route}`)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="shrink-0" aria-hidden>
<path d="M10 3.5a1.5 1.5 0 0 1 3 0V5h3.5A1.5 1.5 0 0 1 18 6.5V10h1.5a1.5 1.5 0 0 1 0 3H18v3.5a1.5 1.5 0 0 1-1.5 1.5H13v-1.5a1.5 1.5 0 0 0-3 0V18H6.5A1.5 1.5 0 0 1 5 16.5V13H6.5a1.5 1.5 0 0 0 0-3H5V6.5A1.5 1.5 0 0 1 6.5 5H10V3.5z" />
</svg>
{!collapsed && <span className="flex-1 truncate">{p.nav.label}</span>}
</SidebarItem>
))}
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav>
<div className="border-t border-app-border p-2 text-[15px]">
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
<Icon name="settings" size={22} />
{!collapsed && t("nav.settings")}
</Link>
<button
className={`flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
title={collapsed ? t("common.signOut") : undefined}
onClick={async () => {
await api.logout();
queryClient.clear();
navigate("/login");
}}
>
<Icon name="log-out" size={16} />
{!collapsed && t("common.signOut")}
</button>
{!collapsed && quotaQuery.data && (
<div
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-[13px] text-app-faint"
title={t("nav.storageQuota")}
>
{quotaLabel(quotaQuery.data)}
</div>
)}
</div>
</aside>
{!collapsed && <Resizer onDelta={(dx) => setSidebarW((w) => clampSidebar(w + dx))} />}
{/* Message list */}
<div
style={{ width: listW }}
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface"
>
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<div className="relative w-full">
<input
ref={searchRef}
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
term ? "pr-14" : "pr-8"
}`}
placeholder={
currentFolder === "trash"
? t("nav.searchInTrash")
: currentFolder === "junk"
? t("nav.searchInSpam")
: t("nav.search")
}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{term && (
<>
<button
type="button"
title={t("search.save")}
aria-label={t("search.save")}
onClick={onSaveSearch}
className="absolute right-8 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-accent"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 2.5h8v11l-4-2.6-4 2.6z" />
</svg>
</button>
<button
type="button"
title={t("common.clear")}
aria-label={t("common.clear")}
onClick={() => {
setTerm("");
searchRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</>
)}
</div>
</div>
{!isDrafts && (
<div className="flex h-[52px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-[15px]">
<button
type="button"
aria-label={t("mail.selectAll")}
title={t("mail.selectAll")}
onClick={toggleSelectAll}
className={`ml-[28px] flex h-[17px] w-[17px] shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
allSelected || someSelected
? "border-app-accent bg-app-accent text-white"
: "border-app-faint hover:border-app-muted"
}`}
>
{allSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3.5 8.5l3 3 6-7" />
</svg>
) : someSelected ? (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round">
<path d="M4 8h8" />
</svg>
) : null}
</button>
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
{t("filter.all")}
</FilterTab>
<FilterTab active={filter === "unread"} onClick={() => setFilter("unread")}>
{t("filter.unread")}
</FilterTab>
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button
className="rounded px-2 py-1 text-[13px] font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
onClick={emptyCurrentFolder}
disabled={emptying}
>
{currentFolder === "trash" ? t("mail.emptyTrash") : t("mail.emptySpam")}
</button>
)}
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")}
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))}
>
<SortIcon />
</button>
</div>
</div>
)}
<div className="min-h-0 flex-1">
{isDrafts && !term.trim() ? (
<DraftsList
drafts={draftsQuery.data ?? []}
remoteDrafts={remoteDraftsQuery.data ?? []}
onOpen={(draft) => openComposer(draftToInit(draft), true)}
onOpenRemote={async (remote) => {
// Adopt the server draft into an editable local draft, then open it.
const draft = await api.adoptDraft(remote.message_id);
queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
openComposer(draftToInit(draft), true);
}}
/>
) : (
<MessageList
threads={pageThreads}
selectedId={threadId}
folder={currentFolder}
onSelect={(id) => navigate(`/mail/t/${id}`)}
onArchive={listArchive}
onRestore={listRestore}
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
checkedIds={checkedIds}
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-[13px] text-app-muted">
<button
className="-ml-1 rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:opacity-60"
title={t("nav.refresh")}
aria-label={t("nav.refresh")}
onClick={doRefresh}
disabled={refreshing}
>
<RefreshIcon spinning={refreshing} />
</button>
{!isDrafts && total > 0 ? (
<div className="flex items-center gap-3">
<span>
{pageStart + 1}–{pageEnd} of {total}
</span>
<div className="-mr-1 flex items-center gap-0.5">
<PagerButton
disabled={page === 0}
onClick={() => setPage(page - 1)}
label={t("pager.prev")}
dir="left"
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(page + 1)}
label={t("pager.next")}
dir="right"
/>
</div>
</div>
) : (
<span />
)}
</div>
</div>
<Resizer onDelta={(dx) => setListW((w) => clampList(w + dx))} />
{/* Thread pane */}
<main className="relative min-w-0 flex-1 bg-app-surface">
{checkedIds.size > 0 ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ||
currentFolder === "trash" ||
currentFolder === "junk" ? (
<>
<ToolbarButton onClick={bulkRestore}>
<Icon name="restore" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton
onClick={currentFolder === "trash" ? bulkDeletePermanent : bulkTrash}
>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={bulkArchive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={bulkPin}>
<Icon name="pin" size={16} />
{t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={bulkTrash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
</div>
<div className="flex flex-1 items-center justify-center text-3xl text-app-faint">
{checkedIds.size === 1
? t("mail.conversationSelected")
: t("mail.conversationsSelected", { count: checkedIds.size })}
</div>
</div>
) : threadId && detailQuery.data ? (
<div className="flex h-full flex-col">
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-[15px]">
{currentFolder === "archive" ? (
<>
<ToolbarButton onClick={restore}>
<Icon name="inbox" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={trash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : currentFolder === "trash" ? (
<>
<ToolbarButton onClick={restore}>
<Icon name="inbox" size={16} />
{t("mail.restore")}
</ToolbarButton>
<ToolbarButton onClick={deletePermanent}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
) : (
<>
<ToolbarButton onClick={archive}>
<Icon name="archive" size={16} />
{t("mail.archive")}
</ToolbarButton>
<ToolbarButton onClick={togglePin} active={detailQuery.data.pinned}>
<Icon
name="pin"
size={16}
className={detailQuery.data.pinned ? "text-[rgb(220_129_143)]" : ""}
/>
{detailQuery.data.pinned ? t("mail.unpin") : t("mail.pin")}
</ToolbarButton>
<ToolbarButton onClick={trash}>
<Icon name="trash" size={16} />
{t("mail.delete")}
</ToolbarButton>
</>
)}
<span className="ml-auto text-[13px] text-app-faint">
{t("mail.messageCount", { count: detailQuery.data.messages.length })}
</span>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<ThreadView
subject={detailQuery.data.subject}
messages={detailQuery.data.messages}
order={detailQuery.data.order}
mailboxes={mailboxesQuery.data ?? []}
accounts={accounts}
replyKindSignal={replySignal}
/>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
<EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
</div>
)}
{composing && (
<div
className={
composerExpanded
? "absolute inset-0 z-30"
: "absolute bottom-0 right-4 z-30 w-[47.5rem] max-w-[calc(100%-2rem)]"
}
>
<Composer
key={composeKey}
init={composing}
expanded={composerExpanded}
onToggleExpand={() => setComposerExpanded((v) => !v)}
onClose={closeComposer}
/>
</div>
)}
</main>
</div>
);
}
// Account bullet in the sidebar. While the account is backfilling it becomes a
// spinning ring that fills with the account colour to show import progress
// (SPEC §12 M2); once live it's the plain colour-filled dot.
function AccountDot({
color,
syncing,
total,
done,
}: {
color: string;
syncing: boolean;
total: number;
done: number;
}) {
if (!syncing) {
return (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
);
}
const pct = total > 0 ? Math.min(100, Math.round((done / Math.max(done, total)) * 100)) : 0;
const ring = "rgba(128,128,128,0.35)";
return (
<span
className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full"
style={{
animationDuration: "1.1s",
background: `conic-gradient(${color} ${pct}%, ${ring} ${pct}% 100%)`,
// Punch out the centre to leave a ~3px ring.
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
}}
title={`${pct}%`}
/>
);
}
function FilterTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`flex h-full items-center border-b-2 ${
active
? "border-app-accent font-semibold text-app-fg"
: "border-transparent text-app-muted hover:text-app-fg"
}`}
>
{children}
</button>
);
}
function PagerButton({
disabled,
onClick,
label,
dir,
}: {
disabled: boolean;
onClick: () => void;
label: string;
dir: "left" | "right";
}) {
return (
<button
disabled={disabled}
onClick={onClick}
title={label}
aria-label={label}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg disabled:pointer-events-none disabled:text-app-faint disabled:opacity-40"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={dir === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"} />
</svg>
</button>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className={spinning ? "animate-spin" : ""}
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
<path d="M13.5 2v3h-3" />
</svg>
);
}
function SortIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 3v10M3 5l2-2 2 2M11 13V3M9 11l2 2 2-2" />
</svg>
);
}
function SidebarItem({
active,
onClick,
collapsed = false,
title,
children,
}: {
active: boolean;
onClick: () => void;
collapsed?: boolean;
title?: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
title={title}
className={`flex w-full items-center gap-2.5 rounded-lg py-1.5 text-left ${
collapsed ? "justify-center px-0" : "px-3"
} ${
active ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
{children}
</button>
);
}
function ToolbarButton({
onClick,
active = false,
children,
}: {
onClick: () => void;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
className={`flex items-center gap-1.5 rounded px-2.5 py-1 hover:bg-app-hover hover:text-app-fg ${
active ? "text-app-fg" : "text-app-muted"
}`}
onClick={onClick}
>
{children}
</button>
);
}