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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-22 07:40:29 +02:00
co-authored by Claude Opus 4.8
parent d650f16feb
commit fdb6ee002e
22 changed files with 1758 additions and 68 deletions
+13
View File
@@ -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
+42 -2
View File
@@ -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)
+81 -1
View File
@@ -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)})
+16
View File
@@ -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")
+12
View File
@@ -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(
+2
View File
@@ -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,
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 151 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="64" height="64" viewBox="0 0 48 48" style="color: rgb(74, 85, 101); opacity: 1; transform: rotate(0deg);"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"><path d="m13 8l-7 6l7 7"></path><path d="M6 14h22.994c6.883 0 12.728 5.62 12.996 12.5c.284 7.27-5.723 13.5-12.996 13.5H11.998"></path></g></svg>

After

Width:  |  Height:  |  Size: 471 B

@@ -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 <img src={illustration} alt="" aria-hidden="true" className={className} />;
}
+2
View File
@@ -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<string, string> = {
print,
reply,
"reply-all": replyAll,
restore,
sent,
settings,
show,
+175 -38
View File
@@ -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 (
<svg
width="17"
height="17"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0"
>
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
);
}
// 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<HTMLDivElement>(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 (
<button
<div
key={thread.id}
role="button"
tabIndex={0}
onClick={() => onSelect(thread.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
}}
style={{
position: "absolute",
top: 0,
@@ -61,41 +120,119 @@ export function MessageList({
height: item.size,
transform: `translateY(${item.start}px)`,
}}
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${
selected
? "bg-app-active"
: "hover:bg-app-hover"
}`}
className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
>
<div className="flex items-center gap-2">
<span className="flex shrink-0 gap-0.5">
{thread.account_ids.map((id) => (
<span
key={id}
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: colorFor(id) }}
/>
))}
</span>
<span className={`flex-1 truncate text-sm ${unread ? "font-semibold text-app-fg" : "text-app-fg"}`}>
{thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
{thread.message_count}
<div
className={`flex h-full items-center gap-3 rounded-lg px-3 ${
selected ? "bg-app-active" : "group-hover:bg-app-hover"
}`}
>
{/* Avatar + extra-participants badge */}
<div className="relative shrink-0">
<span
className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
</span>
)}
{thread.pinned && (
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" />
)}
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[9px] font-medium text-app-muted ring-2 ring-app-surface">
+{extra}
</span>
)}
</div>
{/* Sender column */}
<div className="flex w-40 min-w-0 shrink items-center gap-1.5">
<span
className={`truncate text-sm ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
}`}
>
{names}
</span>
{thread.message_count > 1 && (
<span className="shrink-0 rounded-full bg-app-hover px-1.5 text-[11px] text-app-muted">
{thread.message_count}
</span>
)}
</div>
{/* Subject + snippet (extra left margin -> 3x the gap to the sender). */}
<div className="ml-6 flex min-w-0 flex-1 flex-col justify-center">
<div
className={`truncate text-[13px] ${
unread ? "font-semibold text-app-fg" : "text-app-fg"
}`}
>
{thread.subject}
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</div>
{/* Right rail: four evenly-spaced icons, vertically centred. The
paperclip and pin keep fixed slots (archive/trash reserve their
space while hidden), and the date is an overlay on the right that
is simply hidden on hover to reveal archive + trash — so nothing
ever shifts between the normal and hover states. */}
<div className="relative flex shrink-0 items-center justify-end gap-4">
{/* Attachment: fixed slot (reserved even when empty) so the rail
width — and thus the excerpt width — is the same in every row. */}
<span className="flex w-[17px] shrink-0 justify-center text-app-faint">
{thread.has_attachments && <Paperclip />}
</span>
{/* Pin: pink and always shown when pinned; otherwise revealed on hover. */}
<button
type="button"
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
className={
thread.pinned
? "text-[rgb(220_129_143)]"
: "invisible text-app-muted hover:text-app-fg group-hover:visible"
}
>
<Icon name="pin" size={17} />
</button>
{/* Archive (or Restore in Trash/Spam/Archive) + delete: hover-only,
but reserve their slots always. In Trash, delete is permanent. */}
{isRemovalFolder ? (
<button
type="button"
title={t("mail.restore")}
onClick={stop(() => onRestore(thread.id))}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="restore" size={17} />
</button>
) : (
<button
type="button"
title={t("mail.archive")}
onClick={stop(() => onArchive(thread.id))}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="archive" size={15} />
</button>
)}
<button
type="button"
title={t("mail.delete")}
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="invisible text-app-muted hover:text-app-fg group-hover:visible"
>
<Icon name="trash" size={17} />
</button>
{/* Date overlay, aligned to the right edge (over the archive/trash
slots); hidden on hover so the actions show through. */}
<span className="absolute inset-y-0 right-0 flex items-center whitespace-nowrap text-[13px] tabular-nums text-app-faint group-hover:hidden">
{timeLabel(thread.last_message_at)}
</span>
</div>
</div>
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}>
{thread.subject}
{thread.has_attachments && <span className="ml-1 text-app-faint">📎</span>}
</div>
<div className="truncate text-xs text-app-faint">{thread.snippet}</div>
</button>
</div>
);
})}
</div>
@@ -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)}
>
<Icon name="print" size={16} />
</button>
+4
View File
@@ -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",
+4
View File
@@ -8,6 +8,7 @@ import type {
DiscoverResponse,
DraftInput,
DraftOut,
FolderCounts,
MailboxOut,
QuotaOut,
RecipientSuggestion,
@@ -156,6 +157,7 @@ export const api = {
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/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<DraftOut[]>("/drafts"),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) =>
+5
View File
@@ -151,6 +151,11 @@ export interface QuotaOut {
quota_bytes: number;
}
export interface FolderCounts {
inbox_unread: number;
drafts: number;
}
// --- M1: compose -------------------------------------------------------------
export interface RecipientSuggestion {
+6
View File
@@ -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"] });
}
};
+138 -26
View File
@@ -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() {
</button>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm">
{FOLDERS.map((f) => (
{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}
@@ -304,10 +372,14 @@ export function MailLayout() {
navigate("/mail");
}}
>
<Icon name={f.icon} size={18} />
{!collapsed && t(f.label)}
<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-xs text-app-muted">{badge}</span>
) : null}
</SidebarItem>
))}
);
})}
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
@@ -335,7 +407,7 @@ export function MailLayout() {
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
<Icon name="settings" size={16} />
<Icon name="settings" size={22} />
{!collapsed && t("nav.settings")}
</Link>
<button
@@ -371,13 +443,31 @@ export function MailLayout() {
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">
<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"
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<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 pr-8 text-sm text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{term && (
<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-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 text-sm">
@@ -390,14 +480,25 @@ export function MailLayout() {
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")}
</FilterTab>
<button
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"))}
>
<SortIcon />
</button>
<div className="-mr-1.5 ml-auto flex items-center gap-1">
{(currentFolder === "trash" || currentFolder === "junk") && (
<button
className="rounded px-2 py-1 text-xs 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">
@@ -415,7 +516,17 @@ export function MailLayout() {
}}
/>
) : (
<MessageList threads={pageThreads} accounts={accounts} selectedId={threadId} onSelect={(id) => navigate(`/mail/t/${id}`)} />
<MessageList
threads={pageThreads}
selectedId={threadId}
folder={currentFolder}
onSelect={(id) => navigate(`/mail/t/${id}`)}
onArchive={listArchive}
onRestore={listRestore}
onTrash={listTrash}
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
/>
)}
</div>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted">
@@ -519,8 +630,9 @@ export function MailLayout() {
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-app-faint">
{t("mail.selectConversation")}
<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>
)}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB