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"} 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") @router.post("/messages/{message_id}/move")
async def move_message( async def move_message(
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession 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 fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentUser from app.identity.deps import CurrentUser
from app.models import Mailbox from app.models import Draft, Mailbox, Message
router = APIRouter(prefix="/mailboxes", tags=["mail"]) router = APIRouter(prefix="/mailboxes", tags=["mail"])
@@ -26,6 +26,11 @@ class MailboxOut(BaseModel):
special_use: str | None 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]) @router.get("", response_model=list[MailboxOut])
async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]: async def list_mailboxes(user: CurrentUser, db: DbSession) -> list[MailboxOut]:
rows = await db.scalars( 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) MailboxOut(id=m.id, account_id=m.account_id, path=m.path, special_use=m.special_use)
for m in rows 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 collections.abc import Iterable
from typing import Any from typing import Any
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db import session_scope 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( await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)} "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 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: async def test_message_html_is_sanitized(api: ApiCtx) -> None:
# the lunch message has HTML with a script # the lunch message has HTML with a script
resp = await api.client.get("/api/threads") 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))] 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: async def test_ignores_unknown_flags(ctx: Ctx) -> None:
mid = ctx.message_ids[0] mid = ctx.message_ids[0]
resp = await ctx.client.post( 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.outbox.jobs import send_job
from app.sync.actions import ( from app.sync.actions import (
delete_thread_job, delete_thread_job,
empty_folder_job,
move_job, move_job,
move_thread_to_folder_job, move_thread_to_folder_job,
push_flags_job, push_flags_job,
@@ -41,6 +42,7 @@ INTERACTIVE_FUNCTIONS = [
move_thread_to_folder_job, move_thread_to_folder_job,
restore_thread_job, restore_thread_job,
delete_thread_job, delete_thread_job,
empty_folder_job,
send_job, send_job,
push_draft_to_imap_job, push_draft_to_imap_job,
remove_imap_draft_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 print from "../../icons/print.svg?raw";
import replyAll from "../../icons/reply-all-line.svg?raw"; import replyAll from "../../icons/reply-all-line.svg?raw";
import reply from "../../icons/reply.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 sent from "../../icons/sent-02.svg?raw";
import settings from "../../icons/settings.svg?raw"; import settings from "../../icons/settings.svg?raw";
import show from "../../icons/show.svg?raw"; import show from "../../icons/show.svg?raw";
@@ -35,6 +36,7 @@ const RAW: Record<string, string> = {
print, print,
reply, reply,
"reply-all": replyAll, "reply-all": replyAll,
restore,
sent, sent,
settings, settings,
show, show,
+175 -38
View File
@@ -2,7 +2,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react"; import { useRef } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import type { AccountOut, ThreadSummary } from "../../lib/types"; import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
function timeLabel(iso: string): string { function timeLabel(iso: string): string {
@@ -15,26 +15,71 @@ function timeLabel(iso: string): string {
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" }); 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). // Virtualized thread list (SPEC §2 list/MessageList.tsx).
export function MessageList({ export function MessageList({
threads, threads,
accounts,
selectedId, selectedId,
folder,
onSelect, onSelect,
onArchive,
onRestore,
onTrash,
onDeletePermanent,
onTogglePin,
}: { }: {
threads: ThreadSummary[]; threads: ThreadSummary[];
accounts: AccountOut[];
selectedId?: string; selectedId?: string;
folder: string;
onSelect: (id: string) => void; 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 t = useT();
const parentRef = useRef<HTMLDivElement>(null); 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({ const virtualizer = useVirtualizer({
count: threads.length, count: threads.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 82, estimateSize: () => 75, // 68 row + 7px gap
overscan: 8, overscan: 8,
}); });
@@ -49,10 +94,24 @@ export function MessageList({
const thread = threads[item.index]; const thread = threads[item.index];
const unread = thread.unread_count > 0; const unread = thread.unread_count > 0;
const selected = selectedId === thread.id; 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 ( return (
<button <div
key={thread.id} key={thread.id}
role="button"
tabIndex={0}
onClick={() => onSelect(thread.id)} onClick={() => onSelect(thread.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect(thread.id);
}}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
@@ -61,41 +120,119 @@ export function MessageList({
height: item.size, height: item.size,
transform: `translateY(${item.start}px)`, transform: `translateY(${item.start}px)`,
}} }}
className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${ className="group absolute inset-x-0 cursor-pointer px-2 pt-[7px]"
selected
? "bg-app-active"
: "hover:bg-app-hover"
}`}
> >
<div className="flex items-center gap-2"> <div
<span className="flex shrink-0 gap-0.5"> className={`flex h-full items-center gap-3 rounded-lg px-3 ${
{thread.account_ids.map((id) => ( selected ? "bg-app-active" : "group-hover:bg-app-hover"
<span }`}
key={id} >
className="h-2.5 w-2.5 rounded-full" {/* Avatar + extra-participants badge */}
style={{ backgroundColor: colorFor(id) }} <div className="relative shrink-0">
/> <span
))} className="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold text-white"
</span> style={{ backgroundColor: avatarBg }}
<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)"} {initials(primary)}
</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> </span>
)} {extra > 0 && (
{thread.pinned && ( <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">
<Icon name="pin" size={13} className="text-[rgb(220_129_143)]" /> +{extra}
)} </span>
<span className="shrink-0 text-[11px] text-app-faint">{timeLabel(thread.last_message_at)}</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>
<div className={`truncate text-[13px] ${unread ? "font-medium text-app-fg" : "text-app-muted"}`}> </div>
{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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
// Mark read/unread changes the Inbox unread badge.
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}, },
}); });
const move = useMutation({ const move = useMutation({
@@ -431,6 +433,7 @@ export function ThreadView({
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["thread"] }); queryClient.invalidateQueries({ queryKey: ["thread"] });
queryClient.invalidateQueries({ queryKey: ["threads"] }); 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" className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.print")} title={t("mail.print")}
aria-label={t("mail.print")} aria-label={t("mail.print")}
onClick={() => void printMessages(subject, messages)} onClick={() => void printMessages(subject, visual)}
> >
<Icon name="print" size={16} /> <Icon name="print" size={16} />
</button> </button>
+4
View File
@@ -12,6 +12,7 @@ export const en = {
"common.remove": "Remove", "common.remove": "Remove",
"common.edit": "Edit", "common.edit": "Edit",
"common.close": "Close", "common.close": "Close",
"common.clear": "Clear",
"common.back": "Back", "common.back": "Back",
"common.next": "Next", "common.next": "Next",
"common.signOut": "Sign out", "common.signOut": "Sign out",
@@ -78,6 +79,9 @@ export const en = {
"mail.unpin": "Unpin", "mail.unpin": "Unpin",
"mail.delete": "Delete", "mail.delete": "Delete",
"mail.restore": "Restore", "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.loadFailed": "Couldn't load this message.",
"mail.imagesBlocked": "Images from this sender are blocked.", "mail.imagesBlocked": "Images from this sender are blocked.",
"mail.loadImages": "Load images", "mail.loadImages": "Load images",
+4
View File
@@ -8,6 +8,7 @@ import type {
DiscoverResponse, DiscoverResponse,
DraftInput, DraftInput,
DraftOut, DraftOut,
FolderCounts,
MailboxOut, MailboxOut,
QuotaOut, QuotaOut,
RecipientSuggestion, RecipientSuggestion,
@@ -156,6 +157,7 @@ export const api = {
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) => patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }), apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"), quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
// --- compose --- // --- compose ---
suggestRecipients: (q: string) => suggestRecipients: (q: string) =>
@@ -196,6 +198,8 @@ export const api = {
apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }), apiFetch<{ status: string }>(`/threads/${id}/restore`, { method: "POST" }),
deleteThreadPermanent: (id: string) => deleteThreadPermanent: (id: string) =>
apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }), apiFetch<{ status: string }>(`/threads/${id}/delete`, { method: "POST" }),
emptyFolder: (special: string) =>
apiFetch<{ status: string }>(`/folders/${special}/empty`, { method: "POST" }),
listDrafts: () => apiFetch<DraftOut[]>("/drafts"), listDrafts: () => apiFetch<DraftOut[]>("/drafts"),
remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"), remoteDrafts: () => apiFetch<RemoteDraftOut[]>("/drafts/remote"),
adoptDraft: (messageId: string) => adoptDraft: (messageId: string) =>
+5
View File
@@ -151,6 +151,11 @@ export interface QuotaOut {
quota_bytes: number; quota_bytes: number;
} }
export interface FolderCounts {
inbox_unread: number;
drafts: number;
}
// --- M1: compose ------------------------------------------------------------- // --- M1: compose -------------------------------------------------------------
export interface RecipientSuggestion { export interface RecipientSuggestion {
+6
View File
@@ -47,16 +47,22 @@ export function connectWs(queryClient: QueryClient): () => void {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (typeof payload.thread_id === "string") { if (typeof payload.thread_id === "string") {
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] }); 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") { if (name === "account.state_changed") {
queryClient.invalidateQueries({ queryKey: ["accounts"] }); queryClient.invalidateQueries({ queryKey: ["accounts"] });
} }
if (name === "message.sent") { if (name === "message.sent") {
// The sent message's draft is deleted server-side; refresh the Drafts list. // The sent message's draft is deleted server-side; refresh the Drafts list.
queryClient.invalidateQueries({ queryKey: ["drafts"] }); 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 { MessageList } from "../components/list/MessageList";
import { Resizer } from "../components/Resizer"; import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { ThreadView } from "../components/thread/ThreadView"; import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n"; import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en"; import type { MessageKey } from "../i18n/en";
@@ -102,6 +103,7 @@ export function MailLayout() {
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings }); const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc"; const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota }); const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
const allThreads: ThreadSummary[] = term.trim() const allThreads: ThreadSummary[] = term.trim()
? (searchQuery.data?.threads ?? []).map((h) => h.thread) ? (searchQuery.data?.threads ?? []).map((h) => h.thread)
@@ -133,11 +135,19 @@ export function MailLayout() {
const markRead = useMutation({ const markRead = useMutation({
mutationFn: (id: string) => api.threadFlags(id, ["\\Seen"], []), 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(() => { useEffect(() => {
const detail = detailQuery.data; 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailQuery.data?.id]); }, [detailQuery.data?.id]);
@@ -154,6 +164,7 @@ export function MailLayout() {
setComposerExpanded(false); setComposerExpanded(false);
queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts"] });
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
}; };
// Leaving a folder closes any open composer/draft editor: the reading pane // Leaving a folder closes any open composer/draft editor: the reading pane
@@ -171,6 +182,7 @@ export function MailLayout() {
const afterMove = () => { const afterMove = () => {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["quota"] }); queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
navigate("/mail"); navigate("/mail");
}; };
@@ -184,7 +196,7 @@ export function MailLayout() {
/* ignore */ /* ignore */
} }
window.setTimeout(() => { 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] }); queryClient.invalidateQueries({ queryKey: [key] });
} }
setRefreshing(false); setRefreshing(false);
@@ -227,6 +239,55 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] }); 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 openRelative = (delta: number) => {
const list = threadsRef.current; const list = threadsRef.current;
if (list.length === 0) return; if (list.length === 0) return;
@@ -293,7 +354,14 @@ export function MailLayout() {
</button> </button>
</div> </div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-1 text-sm"> <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 <SidebarItem
key={f.view} key={f.view}
active={view === f.view} active={view === f.view}
@@ -304,10 +372,14 @@ export function MailLayout() {
navigate("/mail"); navigate("/mail");
}} }}
> >
<Icon name={f.icon} size={18} /> <Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && t(f.label)} {!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> </SidebarItem>
))} );
})}
{/* Per-account views are hidden entirely when collapsed. */} {/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && ( {!collapsed && (
<> <>
@@ -335,7 +407,7 @@ export function MailLayout() {
title={collapsed ? t("nav.settings") : undefined} title={collapsed ? t("nav.settings") : undefined}
to="/settings" to="/settings"
> >
<Icon name="settings" size={16} /> <Icon name="settings" size={22} />
{!collapsed && t("nav.settings")} {!collapsed && t("nav.settings")}
</Link> </Link>
<button <button
@@ -371,13 +443,31 @@ export function MailLayout() {
className="flex shrink-0 flex-col border-r border-app-border bg-app-surface" 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="flex h-[52px] shrink-0 items-center border-b border-app-border px-6">
<input <div className="relative w-full">
ref={searchRef} <input
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" ref={searchRef}
placeholder={t("nav.search")} 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"
value={term} placeholder={t("nav.search")}
onChange={(e) => setTerm(e.target.value)} 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> </div>
{!isDrafts && ( {!isDrafts && (
<div className="flex h-[44px] shrink-0 items-center gap-5 border-b border-app-border bg-app-bg px-6 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">
@@ -390,14 +480,25 @@ export function MailLayout() {
<FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}> <FilterTab active={filter === "pinned"} onClick={() => setFilter("pinned")}>
{t("filter.pinned")} {t("filter.pinned")}
</FilterTab> </FilterTab>
<button <div className="-mr-1.5 ml-auto flex items-center gap-1">
className="-mr-1.5 ml-auto rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" {(currentFolder === "trash" || currentFolder === "junk") && (
title={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")} <button
aria-label={listOrder === "newest" ? t("filter.sortOldest") : t("filter.sortNewest")} className="rounded px-2 py-1 text-xs font-medium text-red-500 hover:bg-app-hover disabled:opacity-50"
onClick={() => setListOrder((o) => (o === "newest" ? "oldest" : "newest"))} onClick={emptyCurrentFolder}
> disabled={emptying}
<SortIcon /> >
</button> {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>
)} )}
<div className="min-h-0 flex-1"> <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>
<div className="flex items-center justify-between border-t border-app-border px-6 py-1.5 text-xs text-app-muted"> <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> </div>
) : ( ) : (
<div className="flex h-full items-center justify-center text-app-faint"> <div className="flex h-full flex-col items-center justify-center gap-6 text-app-faint">
{t("mail.selectConversation")} <EmptyIllustration className="w-56 max-w-[55%]" />
<span className="text-3xl">{t("mail.selectConversation")}</span>
</div> </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