Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI
Backend - Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX. - Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr. - Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge); inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID. - Per-user storage quota (5 GiB default) and manual "sync all folders" action. - Account editing (display/color/server/password) and per-account HTML signature. - Account view scoped to that account's inbox; new migrations 0006–0009. Frontend - Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh. - Aligned + widened mail-list margins; reading-pane bars fixed height. - Rich-text composer: color palette, highlight, image; attachments; expand/pop-out. - Signatures: auto-insert for new mail, replace on From change, reply insert. - Restore/Delete actions per folder; drafts view merges local + remote, date-sorted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,20 @@ import { ThreadView } from "../components/thread/ThreadView";
|
||||
import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api } from "../lib/api";
|
||||
import type { ThreadSummary } from "../lib/types";
|
||||
import type { QuotaOut, ThreadSummary } from "../lib/types";
|
||||
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
|
||||
|
||||
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;
|
||||
|
||||
// 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" },
|
||||
@@ -34,7 +45,11 @@ export function MailLayout() {
|
||||
const [view, setView] = useState<string>("folder:inbox");
|
||||
const [term, setTerm] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
|
||||
const [listOrder, setListOrder] = useState<"newest" | "oldest">("newest");
|
||||
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
|
||||
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
|
||||
);
|
||||
const [page, setPage] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [composing, setComposing] = useState<ComposerInit | null>(null);
|
||||
const [composerExpanded, setComposerExpanded] = useState(false);
|
||||
const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>();
|
||||
@@ -48,6 +63,7 @@ export function MailLayout() {
|
||||
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("mail.listOrder", listOrder), [listOrder]);
|
||||
|
||||
const isDrafts = view === "folder:drafts";
|
||||
// Which special-use folder the reading pane is showing (drives the toolbar).
|
||||
@@ -63,6 +79,11 @@ export function MailLayout() {
|
||||
queryFn: api.listDrafts,
|
||||
enabled: isDrafts,
|
||||
});
|
||||
const remoteDraftsQuery = useQuery({
|
||||
queryKey: ["drafts-remote"],
|
||||
queryFn: api.remoteDrafts,
|
||||
enabled: isDrafts,
|
||||
});
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ["search", term],
|
||||
queryFn: () => api.search(term),
|
||||
@@ -70,6 +91,7 @@ export function MailLayout() {
|
||||
});
|
||||
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 allThreads: ThreadSummary[] = term.trim()
|
||||
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
|
||||
@@ -80,6 +102,16 @@ export function MailLayout() {
|
||||
// The backend returns threads newest-first; reverse for an oldest-first view.
|
||||
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
|
||||
|
||||
// Client-side pagination of the (filtered, ordered) list.
|
||||
const total = threads.length;
|
||||
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
|
||||
const curPage = Math.min(page, maxPage);
|
||||
const pageStart = curPage * PAGE_SIZE;
|
||||
const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
|
||||
const pageThreads = threads.slice(pageStart, pageEnd);
|
||||
// Reset to the first page whenever the underlying list identity changes.
|
||||
useEffect(() => setPage(0), [view, filter, term, listOrder]);
|
||||
|
||||
const threadsRef = useRef<ThreadSummary[]>(threads);
|
||||
threadsRef.current = threads;
|
||||
|
||||
@@ -101,9 +133,27 @@ export function MailLayout() {
|
||||
|
||||
const afterMove = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
||||
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"]) {
|
||||
queryClient.invalidateQueries({ queryKey: [key] });
|
||||
}
|
||||
setRefreshing(false);
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const archive = async () => {
|
||||
if (!threadId) return;
|
||||
await api.archiveThread(threadId);
|
||||
@@ -230,6 +280,14 @@ export function MailLayout() {
|
||||
>
|
||||
{t("common.signOut")}
|
||||
</button>
|
||||
{quotaQuery.data && (
|
||||
<div
|
||||
className="mt-1 border-t border-app-border px-3 pb-1 pt-2 text-xs text-app-faint"
|
||||
title={t("nav.storageQuota")}
|
||||
>
|
||||
{quotaLabel(quotaQuery.data)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -240,7 +298,7 @@ export function MailLayout() {
|
||||
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-2.5">
|
||||
<div className="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
@@ -250,7 +308,7 @@ export function MailLayout() {
|
||||
/>
|
||||
</div>
|
||||
{!isDrafts && (
|
||||
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border px-3 text-sm">
|
||||
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
|
||||
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
|
||||
{t("filter.all")}
|
||||
</FilterTab>
|
||||
@@ -261,7 +319,7 @@ export function MailLayout() {
|
||||
{t("filter.pinned")}
|
||||
</FilterTab>
|
||||
<button
|
||||
className="ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
className="-mr-1.5 ml-auto 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"))}
|
||||
@@ -274,13 +332,56 @@ export function MailLayout() {
|
||||
{isDrafts && !term.trim() ? (
|
||||
<DraftsList
|
||||
drafts={draftsQuery.data ?? []}
|
||||
remoteDrafts={remoteDraftsQuery.data ?? []}
|
||||
onOpen={(draft) => {
|
||||
setComposing(draftToInit(draft));
|
||||
setComposerExpanded(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"] });
|
||||
setComposing(draftToInit(draft));
|
||||
setComposerExpanded(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MessageList threads={threads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
|
||||
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs 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={curPage === 0}
|
||||
onClick={() => setPage(curPage - 1)}
|
||||
label={t("pager.prev")}
|
||||
dir="left"
|
||||
/>
|
||||
<PagerButton
|
||||
disabled={pageEnd >= total}
|
||||
onClick={() => setPage(curPage + 1)}
|
||||
label={t("pager.next")}
|
||||
dir="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +392,7 @@ export function MailLayout() {
|
||||
<main className="relative min-w-0 flex-1 bg-app-surface">
|
||||
{threadId && detailQuery.data ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-[52px] items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
|
||||
<div className="flex h-[52px] shrink-0 items-center gap-1 border-b border-app-border bg-app-surface px-3 text-sm">
|
||||
{currentFolder === "archive" ? (
|
||||
<>
|
||||
<ToolbarButton onClick={restore}>
|
||||
@@ -369,6 +470,7 @@ export function MailLayout() {
|
||||
setComposing(null);
|
||||
setComposerExpanded(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -401,6 +503,52 @@ function FilterTab({
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user