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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 151 KiB |
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -151,6 +151,11 @@ export interface QuotaOut {
|
||||
quota_bytes: number;
|
||||
}
|
||||
|
||||
export interface FolderCounts {
|
||||
inbox_unread: number;
|
||||
drafts: number;
|
||||
}
|
||||
|
||||
// --- M1: compose -------------------------------------------------------------
|
||||
|
||||
export interface RecipientSuggestion {
|
||||
|
||||
@@ -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"] });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 63 KiB |