diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 43b6fe7..82b11d4 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -54,6 +54,7 @@ class MessageEnvelope(BaseModel): is_starred: bool has_attachments: bool has_remote_images: bool + size_bytes: int snippet: str diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index 684d929..a2da8ed 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -63,6 +63,7 @@ def _envelope(msg: Message) -> MessageEnvelope: is_starred="\\Flagged" in msg.flags, has_attachments=msg.has_attachments, has_remote_images=msg.has_remote_images, + size_bytes=msg.size_bytes, snippet=msg.snippet, ) diff --git a/frontend/icons/collapse-all.svg b/frontend/icons/collapse-all.svg new file mode 100644 index 0000000..8207b08 --- /dev/null +++ b/frontend/icons/collapse-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/expand-all.svg b/frontend/icons/expand-all.svg new file mode 100644 index 0000000..96fec28 --- /dev/null +++ b/frontend/icons/expand-all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/print.svg b/frontend/icons/print.svg new file mode 100644 index 0000000..8d60b56 --- /dev/null +++ b/frontend/icons/print.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index d0dd67d..30df719 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -3,11 +3,14 @@ // let the size come from CSS so each glyph inherits the surrounding text color // and adapts to light/dark themes. import archive from "../../icons/archive.svg?raw"; +import collapseAll from "../../icons/collapse-all.svg?raw"; 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 inbox from "../../icons/inbox.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"; @@ -16,11 +19,14 @@ import trash from "../../icons/trash.svg?raw"; const RAW: Record = { archive, + "collapse-all": collapseAll, compose, drafts, + "expand-all": expandAll, forward, inbox, pin, + print, reply, "reply-all": replyAll, sent, diff --git a/frontend/src/components/thread/MessageBody.tsx b/frontend/src/components/thread/MessageBody.tsx index d224526..466b221 100644 --- a/frontend/src/components/thread/MessageBody.tsx +++ b/frontend/src/components/thread/MessageBody.tsx @@ -30,13 +30,48 @@ export function MessageBody({ message }: { message: MessageEnvelope }) { }); const resize = useCallback(() => { - const doc = frameRef.current?.contentDocument; - if (doc?.documentElement) setHeight(doc.documentElement.scrollHeight); + const frame = frameRef.current; + const doc = frame?.contentDocument; + if (!frame || !doc?.documentElement) return; + // Collapse first so scrollHeight reflects the content, not the current frame + // height — otherwise the frame can grow but never shrink (e.g. hiding a quote). + frame.style.height = "0px"; + const h = doc.documentElement.scrollHeight; + frame.style.height = `${h}px`; + setHeight(h); }, []); + // Hide the quoted original by default and drop a "•••" toggle right after the + // "On , wrote:" attribution (inside the white body), so the reader + // sees only the new content until they choose to reveal the quote. + const injectQuoteToggle = useCallback( + (doc: Document) => { + if (doc.getElementById("__quote_toggle")) return; + const quote = doc.querySelector("blockquote"); + if (!quote?.parentNode) return; + quote.style.display = "none"; + const btn = doc.createElement("button"); + btn.id = "__quote_toggle"; + btn.type = "button"; + btn.textContent = "•••"; + btn.setAttribute( + "style", + "display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" + + "background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;", + ); + btn.addEventListener("click", () => { + quote.style.display = quote.style.display === "none" ? "" : "none"; + resize(); + }); + quote.parentNode.insertBefore(btn, quote); + }, + [resize], + ); + const onLoad = () => { const doc = frameRef.current?.contentDocument; if (!doc) return; + injectQuoteToggle(doc); resize(); // Re-measure as images finish loading (they change the content height). doc.querySelectorAll("img").forEach((img) => { diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index dcfc986..5165d79 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -1,19 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n"; -import type { MessageEnvelope } from "../../lib/types"; +import { api } from "../../lib/api"; +import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import { Composer, type ComposerInit } from "../compose/Composer"; import { Icon } from "../Icon"; import { MessageBody } from "./MessageBody"; // Direction-agnostic thread view (invariant I-5 / SPEC §9.4). // `messages` is ALWAYS ascending (oldest first); the ONLY branch on `order` is the -// visual reversal below. The composer anchor is attached to the newest message; -// any expanded message can additionally start a reply of its own. +// visual reversal below. The composer anchor is attached to the newest message. export interface ThreadViewProps { subject: string; messages: MessageEnvelope[]; order: "asc" | "desc"; + mailboxes?: MailboxOut[]; + accounts?: AccountOut[]; replyKindSignal?: { kind: string; seq: number }; } @@ -26,8 +29,12 @@ function formatList(addrs: { name: string; email: string }[]): string { return addrs.length > 0 ? addrs.map(formatAddress).join(", ") : "(no recipient)"; } -// Attribution line prefixed to a quoted original, e.g. -// "On Fri, Jul 17, 2026, at 2:04 PM, Lukas Remis wrote:". +function recipientSummary(addrs: { name: string; email: string }[]): string { + if (addrs.length === 0) return "(no recipient)"; + const first = addrs[0].name || addrs[0].email; + return addrs.length > 1 ? `${first} +${addrs.length - 1}` : first; +} + function attributionFor(msg: MessageEnvelope): string { const d = new Date(msg.date); const date = d.toLocaleDateString("en-US", { @@ -71,69 +78,286 @@ function buildReplyInit(subject: string, msg: MessageEnvelope, kind: string): Co } function initials(msg: MessageEnvelope): string { - const name = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?"; - return name.trim().slice(0, 1).toUpperCase(); + const name = (msg.from_addr[0]?.name || msg.from_addr[0]?.email || "?").trim(); + const words = name.split(/\s+/).filter(Boolean); + if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); + return name.slice(0, 1).toUpperCase(); +} + +// Stable per-sender hue so a sender's avatar + name share a colour (like the mockup). +function hue(msg: MessageEnvelope): number { + const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase(); + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360; + return h; +} + +function fmtRelative(iso: string): string { + const d = new Date(iso); + const base = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const diff = Date.now() - d.getTime(); + const mins = Math.round(diff / 60000); + const hrs = Math.round(diff / 3600000); + const days = Math.round(diff / 86400000); + let rel = ""; + if (mins < 1) rel = "just now"; + else if (mins < 60) rel = `${mins} min ago`; + else if (hrs < 24) rel = `${hrs} hour${hrs === 1 ? "" : "s"} ago`; + else if (days < 30) rel = `${days} day${days === 1 ? "" : "s"} ago`; + return rel ? `${base} (${rel})` : `${base} ${d.getFullYear()}`; +} + +function fmtFull(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function fmtSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const LOCATION_LABEL: Record = { + inbox: "Inbox", + sent: "Sent", + archive: "Archive", + drafts: "Drafts", + trash: "Trash", + junk: "Spam", +}; + +function ActionsMenu({ + onReply, + onPrint, + onDelete, +}: { + onReply: (kind: string) => void; + onPrint: () => void; + onDelete: () => void; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const item = (label: string, onClick: () => void) => ( + + ); + + return ( +
+ + {open && ( +
+ {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.print"), onPrint)} +
+ {item(t("mail.delete"), onDelete)} +
+ )} +
+ ); } function MessageItem({ message, expanded, - showReply, + location, + align, + accentColor, + boxClassName, + zIndex, + showReplyBar, onToggle, onReply, + onToggleRead, + onPrint, + onDelete, innerRef, }: { message: MessageEnvelope; expanded: boolean; - showReply: boolean; + location: string; + align: "start" | "end"; + accentColor: string | null; + boxClassName: string; + zIndex: number; + showReplyBar: boolean; onToggle: () => void; onReply: (kind: string) => void; + onToggleRead: () => void; + onPrint: () => void; + onDelete: () => void; innerRef?: (el: HTMLDivElement | null) => void; }) { const t = useT(); + const [showDetails, setShowDetails] = useState(false); const replyBtn = (kind: string, label: string, icon: string) => ( ); + const h = hue(message); + // Our own IMAP accounts use their assigned colour; everyone else a stable hue. + const avatarBg = accentColor ?? `hsl(${h} 45% 45%)`; + const nameColor = accentColor ?? `hsl(${h} 55% 62%)`; + const avatar = () => ( + + {initials(message)} + + ); + const senderName = ( + + {message.from_addr[0]?.name || message.from_addr[0]?.email || "(unknown)"} + + ); + const multiRecipient = message.to_addr.length + message.cc_addr.length > 1; + return (
- - {expanded && ( -
- - {showReply && ( -
+ {fmtRelative(message.date)} + + ) : ( +
+
+ {avatar()} +
+ {/* line 1: sender + email ············· date (whole row collapses) */} + + {/* line 2: to … ▼ ············· location ○ reply actions */} +
+ +
+ {location && {location}} + + + +
+
+ {showDetails && ( +
+
{t("mail.from")}
+
{formatList(message.from_addr)}
+
{t("mail.to")}
+
{formatList(message.to_addr)}
+ {message.cc_addr.length > 0 && ( + <> +
Cc
+
{formatList(message.cc_addr)}
+ + )} +
{t("mail.subjectLabel")}
+
{message.subject || "(no subject)"}
+
{t("mail.dateLabel")}
+
{fmtFull(message.date)}
+
{t("mail.sizeLabel")}
+
{fmtSize(message.size_bytes)}
+
+ )} +
+
+
+ +
+ {showReplyBar && ( +
{replyBtn("reply", t("mail.reply"), "reply")} {replyBtn("reply_all", t("mail.replyAll"), "reply-all")} {replyBtn("forward", t("mail.forward"), "forward")} @@ -145,8 +369,16 @@ function MessageItem({ ); } -export function ThreadView({ subject, messages, order, replyKindSignal }: ThreadViewProps) { +export function ThreadView({ + subject, + messages, + order, + mailboxes, + accounts, + replyKindSignal, +}: ThreadViewProps) { const t = useT(); + const queryClient = useQueryClient(); const newest = messages.length > 0 ? messages[messages.length - 1] : undefined; const defaultExpanded = useMemo(() => { @@ -159,9 +391,54 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread const [expanded, setExpanded] = useState>(defaultExpanded); useEffect(() => setExpanded(defaultExpanded), [defaultExpanded]); - // The reply currently open: which message it targets, and its prefilled draft. const [composing, setComposing] = useState<{ anchorId: string; init: ComposerInit } | null>(null); + const flags = useMutation({ + mutationFn: ({ id, add, remove }: { id: string; add: string[]; remove: string[] }) => + api.messageFlags(id, add, remove), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["thread"] }); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); + const move = useMutation({ + mutationFn: ({ id, mailboxId }: { id: string; mailboxId: string }) => + api.moveMessage(id, mailboxId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["thread"] }); + queryClient.invalidateQueries({ queryKey: ["threads"] }); + }, + }); + + const specialUse = useMemo(() => { + const m = new Map(); + for (const mb of mailboxes ?? []) m.set(mb.id, mb.special_use); + return m; + }, [mailboxes]); + const locationFor = (msg: MessageEnvelope) => { + const su = specialUse.get(msg.mailbox_id); + return su ? (LOCATION_LABEL[su] ?? su.charAt(0).toUpperCase() + su.slice(1)) : ""; + }; + const trashFor = (accountId: string) => + (mailboxes ?? []).find((m) => m.account_id === accountId && m.special_use === "trash")?.id; + + const accountColor = useMemo(() => { + const m = new Map(); + for (const a of accounts ?? []) m.set(a.email.toLowerCase(), a.color); + return m; + }, [accounts]); + const accentFor = (msg: MessageEnvelope) => + accountColor.get((msg.from_addr[0]?.email ?? "").toLowerCase()) ?? null; + + const toggleRead = (msg: MessageEnvelope) => + flags.mutate( + msg.is_unread ? { id: msg.id, add: ["\\Seen"], remove: [] } : { id: msg.id, add: [], remove: ["\\Seen"] }, + ); + const deleteMessage = (msg: MessageEnvelope) => { + const trash = trashFor(msg.account_id); + if (trash) move.mutate({ id: msg.id, mailboxId: trash }); + }; + const openReply = (msg: MessageEnvelope, kind: string) => { setExpanded((prev) => new Set(prev).add(msg.id)); setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) }); @@ -178,6 +455,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread }, [newest?.id]); const visual = order === "asc" ? messages : [...messages].reverse(); + const allExpanded = messages.length > 0 && messages.every((m) => expanded.has(m.id)); const toggle = (id: string) => setExpanded((prev) => { @@ -187,43 +465,83 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread return next; }); + // Default action expands everything; collapsing leaves only the latest message open. + const toggleAll = () => + setExpanded( + allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)), + ); + const composerFor = (id: string) => composing?.anchorId === id ? ( -
+
setComposing(null)} />
) : null; return (
-
+

{subject || "(no subject)"}

- +
+ + +
-
- {visual.map((message) => { - const isNewest = Boolean(newest && message.id === newest.id); - const isComposing = composing?.anchorId === message.id; +
+ {visual.map((message, i) => { + const isNewest = Boolean(newest && newest.id === message.id); + 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; + 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" + }`; return ( toggle(message.id)} onReply={(kind) => openReply(message, kind)} + onToggleRead={() => toggleRead(message)} + onPrint={() => window.print()} + onDelete={() => deleteMessage(message)} innerRef={isNewest ? (el) => (newestRef.current = el) : undefined} /> - {isNewest ? ( -
{composerFor(message.id)}
- ) : ( - composerFor(message.id) + {isNewest && ( +
+ {composing?.anchorId === message.id ? composerFor(message.id) : null} +
)} + {composing?.anchorId === message.id && !isNewest && composerFor(message.id)}
); })} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index b5fdd09..265afc5 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -53,6 +53,8 @@ export const en = { "mail.selectConversation": "Select a conversation.", "mail.loadingThread": "Loading thread…", "mail.expandAll": "Expand all", + "mail.collapseAll": "Collapse all", + "mail.print": "Print", "mail.orderOldest": "Oldest first", "mail.orderNewest": "Newest first", "filter.all": "All mail", @@ -79,6 +81,15 @@ export const en = { "mail.loadImages": "Load images", "mail.alwaysFromSender": "Always from this sender", "mail.to": "to", + "mail.from": "From", + "mail.subjectLabel": "Subject", + "mail.dateLabel": "Date", + "mail.sizeLabel": "Size", + "mail.actions": "Actions", + "mail.forwardAsAttachment": "Forward as attachment", + "mail.showQuoted": "Show quoted text", + "mail.hideQuoted": "Hide quoted text", + "mail.markReadUnread": "Mark read / unread", "mail.messageCount": "{count} messages", "mail.downloadAll": "Download all {count} attachments", "mail.downloadZip": "Download zip", diff --git a/frontend/src/index.css b/frontend/src/index.css index f8effba..9d082d6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -23,7 +23,7 @@ --app-bg: 27 30 32; /* main background */ --app-surface: 33 36 39; /* panels (slightly elevated) */ --app-elevated: 39 42 46; /* popovers, composer */ - --app-border: 38 40 42; /* subtle dark dividers */ + --app-border: 58 62 68; /* dark dividers (brighter) */ --app-fg: 226 228 231; --app-muted: 150 156 164; --app-faint: 108 114 122; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a458c7a..c62a9a3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -55,6 +55,7 @@ export interface MessageEnvelope { is_starred: boolean; has_attachments: boolean; has_remote_images: boolean; + size_bytes: number; snippet: string; } diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 852cb2a..f606dac 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -69,6 +69,7 @@ export function MailLayout() { // Which special-use folder the reading pane is showing (drives the toolbar). const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox"; const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); + const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes }); const threadsQuery = useQuery({ queryKey: ["threads", view], queryFn: () => api.threads(view), @@ -444,6 +445,8 @@ export function MailLayout() { subject={detailQuery.data.subject} messages={detailQuery.data.messages} order={detailQuery.data.order} + mailboxes={mailboxesQuery.data ?? []} + accounts={accounts} replyKindSignal={replySignal} />
diff --git a/frontend/src/test/ThreadView.test.tsx b/frontend/src/test/ThreadView.test.tsx index d50963a..a24a0ac 100644 --- a/frontend/src/test/ThreadView.test.tsx +++ b/frontend/src/test/ThreadView.test.tsx @@ -24,6 +24,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope { is_starred: false, has_attachments: false, has_remote_images: false, + size_bytes: 1024, snippet: `snippet ${id}`, }; } @@ -89,8 +90,8 @@ describe("ThreadView direction-agnostic layout (SPEC §9.4)", () => { const messages = [msg("a", 0), msg("b", 1, true), msg("c", 2)]; const { container } = render(wrap()); // An expanded message renders a MessageBody (iframe/loading/error); collapsed does not. - const aNode = screen.getByText("snippet a").closest('[data-testid="message"]') as HTMLElement; - const bNode = screen.getByText("snippet b").closest('[data-testid="message"]') as HTMLElement; + const aNode = container.querySelector('[data-message-id="a"]') as HTMLElement; + const bNode = container.querySelector('[data-message-id="b"]') as HTMLElement; const hasBody = (el: HTMLElement) => el.querySelector("iframe") !== null || /Loading thread|Couldn't load/i.test(el.textContent ?? "");