From fdb6ee002ea16fc118f2a8531c983a059db3a273 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Wed, 22 Jul 2026 07:40:29 +0200 Subject: [PATCH] mail list redesign, folder actions, empty-folder, unread badges & empty-state art Frontend: - Redesign the mail list rows (avatar + sender + subject/snippet + right rail), even-spaced attachment/pin/archive/trash icons, hover-reveal actions, pink always-on pin, monocolor paperclip, row gaps, wider sender-subject gap. - Sidebar folder badges: Inbox unread + Drafts counts (live via WS + mutations). - Trash/Spam/Archive: hover Restore icon (replaces Archive), permanent delete in Trash; "Empty trash" / "Empty spam" buttons on the sort bar. - Reading pane empty state: email illustration above an enlarged "Select a conversation". - Search field clear (X) button; mark-read after a 4s dwell instead of instantly. Backend: - GET /api/mailboxes/counts (inbox unread + drafts) for the sidebar badges. - POST /api/folders/{special}/empty + empty_folder_job to permanently clear a Trash/Spam/Archive folder (server EXPUNGE + DB rows + thread cleanup). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA --- backend/app/api/actions.py | 13 + backend/app/api/mailboxes.py | 44 +- backend/app/sync/actions.py | 82 +- backend/tests/api/test_api.py | 16 + backend/tests/mail/test_actions.py | 12 + backend/workers/worker.py | 2 + frontend/icons/email-illustration.svg | 1247 +++++++++++++++++ frontend/icons/restore.svg | 1 + frontend/src/components/EmptyIllustration.tsx | 6 + frontend/src/components/Icon.tsx | 2 + frontend/src/components/list/MessageList.tsx | 213 ++- frontend/src/components/thread/ThreadView.tsx | 5 +- frontend/src/i18n/en.ts | 4 + frontend/src/lib/api.ts | 4 + frontend/src/lib/types.ts | 5 + frontend/src/lib/ws.ts | 6 + frontend/src/routes/MailLayout.tsx | 164 ++- screenshots/email-illustration.jpg | Bin 0 -> 25540 bytes screenshots/mail_list.jpg | Bin 0 -> 63647 bytes screenshots/mail_list_1.png | Bin 0 -> 82884 bytes screenshots/mail_list_2.png | Bin 0 -> 83473 bytes screenshots/mail_list_hover.jpg | Bin 0 -> 64404 bytes 22 files changed, 1758 insertions(+), 68 deletions(-) create mode 100644 frontend/icons/email-illustration.svg create mode 100644 frontend/icons/restore.svg create mode 100644 frontend/src/components/EmptyIllustration.tsx create mode 100644 screenshots/email-illustration.jpg create mode 100644 screenshots/mail_list.jpg create mode 100644 screenshots/mail_list_1.png create mode 100644 screenshots/mail_list_2.png create mode 100644 screenshots/mail_list_hover.jpg diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py index acf7667..8853bbe 100644 --- a/backend/app/api/actions.py +++ b/backend/app/api/actions.py @@ -133,6 +133,19 @@ async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) return {"status": "queued"} +# Folders whose "Empty" action permanently deletes everything they hold. +_EMPTYABLE_FOLDERS = frozenset({"trash", "junk", "archive"}) + + +@router.post("/folders/{special}/empty") +async def empty_folder(special: str, user: CurrentUser, db: DbSession) -> dict[str, str]: + """Permanently delete every message in a Trash/Spam/Archive folder.""" + if special not in _EMPTYABLE_FOLDERS: + raise_api_error(400, "bad_folder", "this folder cannot be emptied") + await enqueue_job("empty_folder_job", str(user.id), special) + return {"status": "queued"} + + @router.post("/messages/{message_id}/move") async def move_message( message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession diff --git a/backend/app/api/mailboxes.py b/backend/app/api/mailboxes.py index fe75138..378e0f1 100644 --- a/backend/app/api/mailboxes.py +++ b/backend/app/api/mailboxes.py @@ -7,12 +7,12 @@ from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session from app.identity.deps import CurrentUser -from app.models import Mailbox +from app.models import Draft, Mailbox, Message router = APIRouter(prefix="/mailboxes", tags=["mail"]) @@ -26,6 +26,11 @@ class MailboxOut(BaseModel): special_use: str | None +class FolderCounts(BaseModel): + inbox_unread: int # unread messages sitting in Inbox mailboxes + drafts: int # drafts shown in the Drafts folder (local + un-adopted remote) + + @router.get("", response_model=list[MailboxOut]) async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]: rows = await db.scalars( @@ -35,3 +40,38 @@ async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]: MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use) for m in rows ] + + +@router.get("/counts", response_model=FolderCounts) +async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts: + """Badge counts for the sidebar: unread Inbox messages and Drafts-folder items.""" + inbox_unread = await db.scalar( + select(func.count()) + .select_from(Message) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where( + Message.user_id == user.id, + Mailbox.special_use == "inbox", + ~Message.flags.contains(["\\Seen"]), + ) + ) + + # Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror + # UIDs excluded), matching what list_drafts + list_remote_drafts return. + local = await db.scalar(select(func.count()).select_from(Draft).where(Draft.user_id == user.id)) + owned = { + (r.account_id, r.imap_uid) + for r in await db.execute( + select(Draft.account_id, Draft.imap_uid).where( + Draft.user_id == user.id, Draft.imap_uid.isnot(None) + ) + ) + } + remote_rows = await db.execute( + select(Message.account_id, Message.uid) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where(Message.user_id == user.id, Mailbox.special_use == "drafts") + ) + remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned) + + return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote) diff --git a/backend/app/sync/actions.py b/backend/app/sync/actions.py index de14d70..c3a98ee 100644 --- a/backend/app/sync/actions.py +++ b/backend/app/sync/actions.py @@ -13,7 +13,7 @@ import uuid from collections.abc import Iterable from typing import Any -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db import session_scope @@ -358,3 +358,83 @@ async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None: await get_event_bus().publish( "thread.updated", {"user_id": str(user_id), "thread_id": str(tid)} ) + + +async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str) -> None: + """Permanently delete every message in the user's folder of this special-use. + + Backs the Trash/Spam/Archive "Empty" action: EXPUNGE on the server, drop the DB + rows, and re-denormalize (or delete) affected threads. + """ + uid = uuid.UUID(user_id) + async with session_scope() as session: + mailboxes = list( + await session.scalars( + select(Mailbox).where( + Mailbox.user_id == uid, Mailbox.special_use == special_use + ) + ) + ) + if not mailboxes: + return + mb_ids = [mb.id for mb in mailboxes] + path_by_id = {mb.id: mb.path for mb in mailboxes} + messages = list( + await session.scalars( + select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids)) + ) + ) + if not messages: + return + account_ids = list({m.account_id for m in messages}) + accounts = { + a.id: a + for a in await session.scalars(select(Account).where(Account.id.in_(account_ids))) + } + thread_ids = list({m.thread_id for m in messages if m.thread_id is not None}) + by_box: dict[tuple[uuid.UUID, str], list[int]] = {} + for m in messages: + by_box.setdefault((m.account_id, path_by_id[m.mailbox_id]), []).append(m.uid) + + for account_id in account_ids: + provider = build_provider(accounts[account_id]) + try: + for (acct, path), uids in by_box.items(): + if acct != account_id: + continue + try: + await provider.delete_messages(path, uids) + except Exception: + logger.exception("empty folder: expunge failed for %s in %s", uids, path) + finally: + await provider.close() + + async with session_scope() as session: + msgs = list( + await session.scalars( + select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids)) + ) + ) + msg_ids = [m.id for m in msgs] + if msg_ids: + await session.execute( + update(Draft) + .where(Draft.reply_to_message_id.in_(msg_ids)) + .values(reply_to_message_id=None) + ) + for m in msgs: + await session.delete(m) + await session.flush() + # Threads with no messages left get dropped (drafts un-linked first); the + # rest are re-denormalized so their counts/snippet reflect the removal. + for tid in thread_ids: + remaining = await session.scalar( + select(func.count()).select_from(Message).where(Message.thread_id == tid) + ) + if not remaining: + await session.execute( + update(Draft).where(Draft.thread_id == tid).values(thread_id=None) + ) + await denormalize_thread(session, tid) + + await get_event_bus().publish("message.deleted", {"user_id": str(uid)}) diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index ed3a10f..ae06487 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -298,6 +298,22 @@ async def test_thread_detail_hides_trashed_message_unless_viewing_trash(api: Api assert trow["message_count"] == 1 +async def test_folder_counts(api: ApiCtx) -> None: + assert api.session is not None + # All three seeded inbox messages are unread; no drafts yet. + resp = await api.client.get("/api/mailboxes/counts") + assert resp.status_code == 200 + assert resp.json() == {"inbox_unread": 3, "drafts": 0} + + # Marking one message \Seen lowers the unread count. + msg = await api.session.get(Message, api.message_ids[0]) + assert msg is not None + msg.flags = ["\\Seen"] + await api.session.commit() + body = (await api.client.get("/api/mailboxes/counts")).json() + assert body["inbox_unread"] == 2 + + async def test_message_html_is_sanitized(api: ApiCtx) -> None: # the lunch message has HTML with a script resp = await api.client.get("/api/threads") diff --git a/backend/tests/mail/test_actions.py b/backend/tests/mail/test_actions.py index 5ff00f0..11a5d66 100644 --- a/backend/tests/mail/test_actions.py +++ b/backend/tests/mail/test_actions.py @@ -245,6 +245,18 @@ async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None: assert ctx.enqueued == [("delete_thread_job", str(ctx.thread_id))] +async def test_empty_folder_enqueues_job(ctx: Ctx) -> None: + resp = await ctx.client.post("/api/folders/trash/empty", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued == [("empty_folder_job", str(ctx.user_id), "trash")] + + +async def test_empty_folder_rejects_non_removable_folder(ctx: Ctx) -> None: + resp = await ctx.client.post("/api/folders/inbox/empty", headers=_hdr()) + assert resp.status_code == 400 + assert ctx.enqueued == [] + + async def test_ignores_unknown_flags(ctx: Ctx) -> None: mid = ctx.message_ids[0] resp = await ctx.client.post( diff --git a/backend/workers/worker.py b/backend/workers/worker.py index 1c4f5e5..0724dc7 100644 --- a/backend/workers/worker.py +++ b/backend/workers/worker.py @@ -16,6 +16,7 @@ from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job from app.outbox.jobs import send_job from app.sync.actions import ( delete_thread_job, + empty_folder_job, move_job, move_thread_to_folder_job, push_flags_job, @@ -41,6 +42,7 @@ INTERACTIVE_FUNCTIONS = [ move_thread_to_folder_job, restore_thread_job, delete_thread_job, + empty_folder_job, send_job, push_draft_to_imap_job, remove_imap_draft_job, diff --git a/frontend/icons/email-illustration.svg b/frontend/icons/email-illustration.svg new file mode 100644 index 0000000..b6582ce --- /dev/null +++ b/frontend/icons/email-illustration.svg @@ -0,0 +1,1247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/icons/restore.svg b/frontend/icons/restore.svg new file mode 100644 index 0000000..dca0746 --- /dev/null +++ b/frontend/icons/restore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/EmptyIllustration.tsx b/frontend/src/components/EmptyIllustration.tsx new file mode 100644 index 0000000..fdb1422 --- /dev/null +++ b/frontend/src/components/EmptyIllustration.tsx @@ -0,0 +1,6 @@ +// Email illustration for the empty reading pane. +import illustration from "../../icons/email-illustration.svg"; + +export function EmptyIllustration({ className = "" }: { className?: string }) { + return ; +} diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index eddf2cc..05bb68f 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -15,6 +15,7 @@ import pin from "../../icons/pin.svg?raw"; import print from "../../icons/print.svg?raw"; import replyAll from "../../icons/reply-all-line.svg?raw"; import reply from "../../icons/reply.svg?raw"; +import restore from "../../icons/restore.svg?raw"; import sent from "../../icons/sent-02.svg?raw"; import settings from "../../icons/settings.svg?raw"; import show from "../../icons/show.svg?raw"; @@ -35,6 +36,7 @@ const RAW: Record = { print, reply, "reply-all": replyAll, + restore, sent, settings, show, diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 5276630..118927b 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -2,7 +2,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef } from "react"; import { useT } from "../../i18n"; -import type { AccountOut, ThreadSummary } from "../../lib/types"; +import type { Address, ThreadSummary } from "../../lib/types"; import { Icon } from "../Icon"; function timeLabel(iso: string): string { @@ -15,26 +15,71 @@ function timeLabel(iso: string): string { return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" }); } +// Stable per-sender hue so a sender's avatar keeps a consistent colour. +function hue(key: string): number { + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360; + return h; +} + +function initials(p: Address | undefined): string { + const name = (p?.name || p?.email || "?").trim(); + const words = name.split(/\s+/).filter(Boolean); + if (words.length >= 2) return (words[0][0] + words[words.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +// Monocolor paperclip (inherits currentColor) — replaces the old emoji. +function Paperclip() { + return ( + + + + ); +} + // Virtualized thread list (SPEC §2 list/MessageList.tsx). export function MessageList({ threads, - accounts, selectedId, + folder, onSelect, + onArchive, + onRestore, + onTrash, + onDeletePermanent, + onTogglePin, }: { threads: ThreadSummary[]; - accounts: AccountOut[]; selectedId?: string; + folder: string; onSelect: (id: string) => void; + onArchive: (id: string) => void; + onRestore: (id: string) => void; + onTrash: (id: string) => void; + onDeletePermanent: (id: string) => void; + onTogglePin: (id: string, pinned: boolean) => void; }) { const t = useT(); const parentRef = useRef(null); - const colorFor = (id: string) => accounts.find((a) => a.id === id)?.color ?? "#888888"; + // In Trash/Spam/Archive the middle action restores; elsewhere it archives. + const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive"; const virtualizer = useVirtualizer({ count: threads.length, getScrollElement: () => parentRef.current, - estimateSize: () => 82, + estimateSize: () => 75, // 68 row + 7px gap overscan: 8, }); @@ -49,10 +94,24 @@ export function MessageList({ const thread = threads[item.index]; const unread = thread.unread_count > 0; const selected = selectedId === thread.id; + const primary = thread.participants[0]; + const names = + thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"; + const extra = Math.max(0, thread.participants.length - 1); + const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`; + const stop = (fn: () => void) => (e: React.MouseEvent) => { + e.stopPropagation(); + fn(); + }; return ( - + {/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only, + but reserve their slots always. In Trash, delete is permanent. */} + {isRemovalFolder ? ( + + ) : ( + + )} + + {/* Date overlay, aligned to the right edge (over the archive/trash + slots); hidden on hover so the actions show through. */} + + {timeLabel(thread.last_message_at)} + + -
- {thread.subject} - {thread.has_attachments && 📎} -
-
{thread.snippet}
- + ); })} diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index ffd8cdc..e90e60c 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -423,6 +423,8 @@ export function ThreadView({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); + // Mark read/unread changes the Inbox unread badge. + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); const move = useMutation({ @@ -431,6 +433,7 @@ export function ThreadView({ onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["threads"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); }, }); @@ -518,7 +521,7 @@ export function ThreadView({ className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("mail.print")} aria-label={t("mail.print")} - onClick={() => void printMessages(subject, messages)} + onClick={() => void printMessages(subject, visual)} > diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 388e789..5938ac5 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -12,6 +12,7 @@ export const en = { "common.remove": "Remove", "common.edit": "Edit", "common.close": "Close", + "common.clear": "Clear", "common.back": "Back", "common.next": "Next", "common.signOut": "Sign out", @@ -78,6 +79,9 @@ export const en = { "mail.unpin": "Unpin", "mail.delete": "Delete", "mail.restore": "Restore", + "mail.emptyTrash": "Empty trash", + "mail.emptySpam": "Empty spam", + "mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.", "mail.loadFailed": "Couldn't load this message.", "mail.imagesBlocked": "Images from this sender are blocked.", "mail.loadImages": "Load images", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fdb7c66..cac0a03 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import type { DiscoverResponse, DraftInput, DraftOut, + FolderCounts, MailboxOut, QuotaOut, RecipientSuggestion, @@ -156,6 +157,7 @@ export const api = { patchSettings: (patch: Partial>) => apiFetch("/settings", { method: "PATCH", body: JSON.stringify(patch) }), quota: () => apiFetch("/settings/quota"), + folderCounts: () => apiFetch("/mailboxes/counts"), // --- compose --- suggestRecipients: (q: string) => @@ -196,6 +198,8 @@ export const api = { apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }), deleteThreadPermanent: (id: string) => apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }), + emptyFolder: (special: string) => + apiFetch<{ status: string }>(`/folders/${special}/empty`, { method: "POST" }), listDrafts: () => apiFetch("/drafts"), remoteDrafts: () => apiFetch("/drafts/remote"), adoptDraft: (messageId: string) => diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c62a9a3..3d684f1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -151,6 +151,11 @@ export interface QuotaOut { quota_bytes: number; } +export interface FolderCounts { + inbox_unread: number; + drafts: number; +} + // --- M1: compose ------------------------------------------------------------- export interface RecipientSuggestion { diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index bb96df1..7dc4edc 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -47,16 +47,22 @@ export function connectWs(queryClient: QueryClient): () => void { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); if (typeof payload.thread_id === "string") { queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] }); } } + if (name === "flags.changed") { + // Read/unread changes affect the Inbox unread badge. + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); + } if (name === "account.state_changed") { queryClient.invalidateQueries({ queryKey: ["accounts"] }); } if (name === "message.sent") { // The sent message's draft is deleted server-side; refresh the Drafts list. queryClient.invalidateQueries({ queryKey: ["drafts"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); } }; diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 9ea3922..bc45afc 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -8,6 +8,7 @@ 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 { ThreadView } from "../components/thread/ThreadView"; import { useT } from "../i18n"; import type { MessageKey } from "../i18n/en"; @@ -102,6 +103,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 countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts }); const allThreads: ThreadSummary[] = term.trim() ? (searchQuery.data?.threads ?? []).map((h) => h.thread) @@ -133,11 +135,19 @@ export function MailLayout() { const markRead = useMutation({ mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["threads"] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); + }, }); + // 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)) markRead.mutate(detail.id); + 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]); @@ -154,6 +164,7 @@ export function MailLayout() { 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 @@ -171,6 +182,7 @@ export function MailLayout() { const afterMove = () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["quota"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); navigate("/mail"); }; @@ -184,7 +196,7 @@ export function MailLayout() { /* ignore */ } window.setTimeout(() => { - for (const key of ["threads", "drafts", "drafts-remote", "quota"]) { + for (const key of ["threads", "drafts", "drafts-remote", "quota", "folder-counts"]) { queryClient.invalidateQueries({ queryKey: [key] }); } setRefreshing(false); @@ -227,6 +239,55 @@ export function MailLayout() { 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) => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + 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: ["thread", 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; @@ -293,7 +354,14 @@ export function MailLayout() {