mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse

Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
  of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
  restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
  _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
  mirrored DB row; adopting a remote draft carries its real attachments into the
  compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
  hidden outside their folder, Trash/Spam show only their own messages, and the
  mail-list message count matches the view.

Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
  single expand), hide Reply/Forward on trashed messages, close the Actions menu
  on message-body (iframe) clicks, scroll to the inline composer, and prompt
  Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
  mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
  Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).

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-20 23:12:25 +02:00
co-authored by Claude Opus 4.8
parent 1a83e0fb9b
commit d650f16feb
33 changed files with 1927 additions and 105 deletions
+8
View File
@@ -8,12 +8,16 @@ import compose from "../../icons/compose.svg?raw";
import drafts from "../../icons/drafts-28-regular.svg?raw";
import expandAll from "../../icons/expand-all.svg?raw";
import forward from "../../icons/forward.svg?raw";
import hide from "../../icons/hide.svg?raw";
import inbox from "../../icons/inbox.svg?raw";
import logOut from "../../icons/log-out.svg?raw";
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 sent from "../../icons/sent-02.svg?raw";
import settings from "../../icons/settings.svg?raw";
import show from "../../icons/show.svg?raw";
import spam from "../../icons/spam.svg?raw";
import trash from "../../icons/trash.svg?raw";
@@ -24,12 +28,16 @@ const RAW: Record<string, string> = {
drafts,
"expand-all": expandAll,
forward,
hide,
inbox,
"log-out": logOut,
pin,
print,
reply,
"reply-all": replyAll,
sent,
settings,
show,
spam,
trash,
};
+117 -12
View File
@@ -137,16 +137,37 @@ export function Composer({
const [showToolbar, setShowToolbar] = useState(true);
const [fromOpen, setFromOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement>(null);
const fromRef = useRef<HTMLDivElement>(null);
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const [showClosePrompt, setShowClosePrompt] = useState(false);
const draftId = useRef<string | null>(init.draftId ?? null);
const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close
// Whether the user actually edited anything. Merely opening a draft to view it
// must not re-save/re-mirror it (that spawned duplicate IMAP copies).
const dirtyRef = useRef(false);
const markDirty = () => {
dirtyRef.current = true;
};
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<RichTextEditorHandle>(null);
// Close the From / More dropdowns on any click outside them.
useEffect(() => {
if (!moreOpen && !fromOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false);
if (fromOpen && fromRef.current && !fromRef.current.contains(target)) setFromOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [moreOpen, fromOpen]);
const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || "";
const fromEmail = account?.email ?? "";
@@ -204,6 +225,7 @@ export function Composer({
}, []);
const addFiles = async (files: FileList) => {
markDirty();
setAttachError("");
for (const file of Array.from(files)) {
if (file.size > MAX_ATTACHMENT_BYTES) {
@@ -219,8 +241,10 @@ export function Composer({
}
};
const removeAttachment = (id: string) =>
const removeAttachment = (id: string) => {
markDirty();
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// The editor's seed content. New messages get the account signature (2 blank
// lines above it); replies keep the quoted original in a marked block so the
@@ -263,6 +287,7 @@ export function Composer({
useEffect(() => {
if (undo) return;
const handle = window.setTimeout(async () => {
if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft
if (!to && !subject && !bodyHtml) return;
setStatus("saving");
try {
@@ -284,8 +309,9 @@ export function Composer({
() => () => {
if (undoTimer.current) window.clearTimeout(undoTimer.current);
// On close, mirror the draft to the IMAP Drafts folder so it can be
// continued on any mail client (skip if it was sent or discarded).
if (draftId.current && !sentRef.current) {
// continued on any mail client — but only if the user actually edited it
// (skip if it was sent, discarded, or merely opened to view).
if (draftId.current && !sentRef.current && dirtyRef.current) {
void api.publishDraft(draftId.current).catch(() => {});
}
},
@@ -333,6 +359,26 @@ export function Composer({
onClose();
};
// X button: if there's a draft worth keeping, ask before closing; otherwise
// (nothing typed, no draft) just close.
const requestClose = () => {
if (dirtyRef.current || draftId.current) setShowClosePrompt(true);
else onClose();
};
const saveDraftAndClose = async () => {
setShowClosePrompt(false);
try {
if (dirtyRef.current) {
if (draftId.current) await api.updateDraft(draftId.current, payload());
else draftId.current = (await api.createDraft(payload())).id;
}
} catch {
/* the close-time IMAP mirror is best-effort too */
}
onClose(); // the unmount effect mirrors the (dirty) draft to the IMAP Drafts folder
};
const popOut = async () => {
// Persist first so the new window can load this exact draft.
try {
@@ -367,12 +413,44 @@ export function Composer({
return (
<div
className={`flex flex-col overflow-hidden border-app-border bg-app-elevated ${
className={`relative flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow
? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
}`}
>
{showClosePrompt && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/40 p-4"
onClick={() => setShowClosePrompt(false)}
>
<div
className="w-72 rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-4 text-sm text-app-fg">{t("compose.closePrompt")}</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowClosePrompt(false);
void discard();
}}
className="rounded-md px-3 py-1.5 text-sm text-red-500 hover:bg-app-hover"
>
{t("compose.discard")}
</button>
<button
type="button"
onClick={() => void saveDraftAndClose()}
className="rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90"
>
{t("compose.saveDraft")}
</button>
</div>
</div>
</div>
)}
{/* Header: Send · Attach · formatting toggle · More · window controls */}
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
<button
@@ -410,7 +488,7 @@ export function Composer({
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative">
<div className="relative" ref={moreRef}>
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.more")}
</HeaderButton>
@@ -469,7 +547,7 @@ export function Composer({
</svg>
</HeaderButton>
)}
<HeaderButton onClick={onClose} title={t("common.close")}>
<HeaderButton onClick={requestClose} title={t("common.close")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
@@ -478,7 +556,10 @@ export function Composer({
</div>
{/* From */}
<div className="relative flex items-center gap-2 border-b border-app-border px-3 py-2">
<div
className="relative flex items-center gap-2 border-b border-app-border px-3 py-2"
ref={fromRef}
>
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<button
type="button"
@@ -500,6 +581,7 @@ export function Composer({
key={a.id}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
onClick={() => {
markDirty();
setAccountId(a.id);
setFromOpen(false);
}}
@@ -517,7 +599,10 @@ export function Composer({
<RecipientChips
label={t("compose.to")}
value={to}
onChange={setTo}
onChange={(v) => {
markDirty();
setTo(v);
}}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
@@ -533,15 +618,32 @@ export function Composer({
/>
{showCc && (
<>
<RecipientChips label={t("compose.cc")} value={cc} onChange={setCc} />
<RecipientChips label={t("compose.bcc")} value={bcc} onChange={setBcc} />
<RecipientChips
label={t("compose.cc")}
value={cc}
onChange={(v) => {
markDirty();
setCc(v);
}}
/>
<RecipientChips
label={t("compose.bcc")}
value={bcc}
onChange={(v) => {
markDirty();
setBcc(v);
}}
/>
</>
)}
<input
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
placeholder={t("compose.subject")}
value={subject}
onChange={(e) => setSubject(e.target.value)}
onChange={(e) => {
markDirty();
setSubject(e.target.value);
}}
/>
{(attachments.length > 0 || attachError) && (
@@ -575,7 +677,10 @@ export function Composer({
<RichTextEditor
ref={editorRef}
initialHtml={initialHtml}
onInput={setBodyHtml}
onInput={(html) => {
markDirty();
setBodyHtml(html);
}}
showToolbar={showToolbar}
/>
) : (
+63 -25
View File
@@ -3,6 +3,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
@@ -137,10 +138,12 @@ function ActionsMenu({
onReply,
onPrint,
onDelete,
replyDisabled = false,
}: {
onReply: (kind: string) => void;
onPrint: () => void;
onDelete: () => void;
replyDisabled?: boolean;
}) {
const t = useT();
const [open, setOpen] = useState(false);
@@ -150,13 +153,25 @@ function ActionsMenu({
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
// Clicking inside the sandboxed message-body iframe doesn't reach document
// mousedown, but it blurs the parent window — close the menu on that too.
const onBlur = () => setOpen(false);
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("mousedown", onDoc);
window.removeEventListener("blur", onBlur);
};
}, [open]);
const item = (label: string, onClick: () => void) => (
const item = (label: string, onClick: () => void, disabled = false) => (
<button
className="block w-full px-3 py-1.5 text-left text-xs text-app-fg hover:bg-app-hover"
disabled={disabled}
className={`block w-full px-3 py-1.5 text-left text-xs ${
disabled
? "cursor-not-allowed text-app-faint"
: "text-app-fg hover:bg-app-hover"
}`}
onClick={() => {
setOpen(false);
onClick();
@@ -177,10 +192,10 @@ function ActionsMenu({
</button>
{open && (
<div className="absolute right-0 z-20 mt-1 w-52 overflow-hidden rounded-md border border-app-border bg-app-elevated py-1 shadow-lg">
{item(t("mail.reply"), () => onReply("reply"))}
{item(t("mail.replyAll"), () => onReply("reply_all"))}
{item(t("mail.forward"), () => onReply("forward"))}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"))}
{item(t("mail.reply"), () => onReply("reply"), replyDisabled)}
{item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)}
{item(t("mail.forward"), () => onReply("forward"), replyDisabled)}
{item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)}
{item(t("mail.print"), onPrint)}
<div className="my-1 border-t border-app-border" />
{item(t("mail.delete"), onDelete)}
@@ -199,6 +214,7 @@ function MessageItem({
boxClassName,
zIndex,
showReplyBar,
canReply,
onToggle,
onReply,
onToggleRead,
@@ -214,6 +230,7 @@ function MessageItem({
boxClassName: string;
zIndex: number;
showReplyBar: boolean;
canReply: boolean;
onToggle: () => void;
onReply: (kind: string) => void;
onToggleRead: () => void;
@@ -321,14 +338,21 @@ function MessageItem({
}`}
/>
</button>
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button>
<ActionsMenu onReply={onReply} onPrint={onPrint} onDelete={onDelete} />
{canReply && (
<button
className="flex items-center gap-1 text-app-muted hover:text-app-fg"
onClick={() => onReply(multiRecipient ? "reply_all" : "reply")}
>
<Icon name={multiRecipient ? "reply-all" : "reply"} size={14} />
{multiRecipient ? t("mail.replyAll") : t("mail.reply")}
</button>
)}
<ActionsMenu
onReply={onReply}
onPrint={onPrint}
onDelete={onDelete}
replyDisabled={!canReply}
/>
</div>
</div>
{showDetails && (
@@ -356,7 +380,7 @@ function MessageItem({
<div className="px-4 pb-3">
<MessageBody message={message} />
</div>
{showReplyBar && (
{showReplyBar && canReply && (
<div className="flex gap-2 border-t border-app-border px-4 py-3">
{replyBtn("reply", t("mail.reply"), "reply")}
{replyBtn("reply_all", t("mail.replyAll"), "reply-all")}
@@ -440,6 +464,7 @@ export function ThreadView({
};
const openReply = (msg: MessageEnvelope, kind: string) => {
if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash
setExpanded((prev) => new Set(prev).add(msg.id));
setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) });
};
@@ -471,9 +496,15 @@ export function ThreadView({
allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)),
);
const composerRef = useRef<HTMLDivElement | null>(null);
// Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => {
if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [composing]);
const composerFor = (id: string) =>
composing?.anchorId === id ? (
<div className="px-4 py-3">
<div ref={composerRef} className="px-4 py-3">
<Composer init={composing.init} onClose={() => setComposing(null)} />
</div>
) : null;
@@ -487,7 +518,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={() => window.print()}
onClick={() => void printMessages(subject, messages)}
>
<Icon name="print" size={16} />
</button>
@@ -507,17 +538,23 @@ export function ThreadView({
const isExpanded = expanded.has(message.id);
const prev = i > 0 ? visual[i - 1] : undefined;
const prevCollapsed = prev ? !expanded.has(prev.id) : false;
const prevIsNewest = Boolean(prev && newest && prev.id === newest.id);
// Overlap onto a preceding collapsed card, and also onto the always-open
// newest message — so the newest stacks with its neighbour in both sort
// orders (bottom card in oldest-first, top card in newest-first). z-index
// keeps the newer/"current" edge on top so text stays visible.
const overlap = prevCollapsed || prevIsNewest;
// Cards overlap by a negative top margin (tuck up under the card above),
// with the newer card kept on top by z-index. The card above is OLDER in
// oldest-first but NEWER in newest-first, so the rule differs by order:
// - oldest-first: an expanded card tucks up over the older card above it
// (its own top stays clear). Only overlap a collapsed card above.
// - newest-first: the card above is newer, so tucking an expanded card up
// would hide its header under that newer card. Only collapsed cards tuck
// up; an expanded card keeps its gap, so its top stays clear of the newer
// message and the older card BELOW tucks under it instead.
const overlap = order === "desc" ? !isExpanded : prevCollapsed;
const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2";
const z = order === "asc" ? i + 1 : visual.length - i;
const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${
isExpanded ? "" : "shadow-sm"
}`;
// Reply/Reply-all/Forward make no sense on a trashed message.
const canReply = specialUse.get(message.mailbox_id) !== "trash";
return (
<Fragment key={message.id}>
<MessageItem
@@ -529,10 +566,11 @@ export function ThreadView({
boxClassName={boxClassName}
zIndex={z}
showReplyBar={isNewest && composing?.anchorId !== message.id}
canReply={canReply}
onToggle={() => toggle(message.id)}
onReply={(kind) => openReply(message, kind)}
onToggleRead={() => toggleRead(message)}
onPrint={() => window.print()}
onPrint={() => void printMessages(subject, [message])}
onDelete={() => deleteMessage(message)}
innerRef={isNewest ? (el) => (newestRef.current = el) : undefined}
/>