diff --git a/.wolf/anatomy-index.json b/.wolf/anatomy-index.json index 3e83f4e..6e26c52 100644 --- a/.wolf/anatomy-index.json +++ b/.wolf/anatomy-index.json @@ -1,13 +1,13 @@ { "version": 1, "meta": { - "lastScanned": "2026-10-22T17:41:17.634Z", - "fileCount": 439, + "lastScanned": "2026-10-22T19:44:13.602Z", + "fileCount": 440, "hits": 0, "misses": 0, - "renderedHash": "6ed8dc6b806503f41452e51121e5f78379f1c88655d4934d082d2aa065428b8c", - "storeUpdatedAt": "2026-10-22T17:41:17.650Z", - "rootHash": "098b934a6205dce4" + "renderedHash": "4c74546f856ca02c0dd5eeb8a90c610c161275126dc91d9fc7f169a6424d61d6", + "storeUpdatedAt": "2026-10-22T19:44:13.627Z", + "rootHash": "d8862ac30fea5b61" }, "files": { ".github/workflows/ci.yml": { @@ -19624,6 +19624,66 @@ "tokens": 36 } ] + }, + "services/webmail/src/components/ThreadReader.tsx": { + "description": "One message as the mail API returns it with its body.", + "tokens": 7350, + "hash": "54dad9430ffc2edb", + "size": 25722, + "mtimeMs": 1792698253418.4153, + "updatedAt": "2026-10-22T19:44:13.601Z", + "source": "hook", + "symbols": [ + { + "name": "FullMessage", + "kind": "section", + "startLine": 39, + "endLine": 53, + "tokens": 74 + }, + { + "name": "MoveTarget", + "kind": "section", + "startLine": 54, + "endLine": 58, + "tokens": 17 + }, + { + "name": "Trusted", + "kind": "section", + "startLine": 59, + "endLine": 64, + "tokens": 44 + }, + { + "name": "Props", + "kind": "section", + "startLine": 65, + "endLine": 84, + "tokens": 314 + }, + { + "name": "splitSender", + "kind": "fn", + "startLine": 85, + "endLine": 90, + "tokens": 74 + }, + { + "name": "initialsOf", + "kind": "fn", + "startLine": 91, + "endLine": 96, + "tokens": 78 + }, + { + "name": "ThreadReader", + "kind": "fn", + "startLine": 97, + "endLine": 591, + "tokens": 6441 + } + ] } }, "preamble": [ diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index 30de6cb..9ebeef2 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,7 +1,7 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T17:41:17.634Z -> Files: 439 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T19:44:13.602Z +> Files: 440 tracked | Anatomy hits: 0 | Misses: 0 > Project structure index. Auto-maintained by OpenWolf hooks and daemon. > Run `openwolf scan` to generate, or wait for the first Claude Code session. @@ -610,6 +610,7 @@ - `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok) - `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok) - `RecipientInput.tsx` — Entries as they will be sent: `Name ` or a bare address. (~1486 tok) +- `ThreadReader.tsx` — One message as the mail API returns it with its body. (~7350 tok) - `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok) ## services/webmail/src/components/ui/ diff --git a/.wolf/memory.md b/.wolf/memory.md index ad75938..964021f 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -31,3 +31,4 @@ description: chronological action log per session, consolidated weekly | Time | Action | File(s) | Outcome | ~Tokens | |------|--------|---------|---------|--------| | 14:41 | Created services/auth/src/accounts.ts | — | ~715 | +| 16:44 | Created services/webmail/src/components/ThreadReader.tsx | — | ~7350 | diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 42b9f42..ebf1590 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -548,5 +548,21 @@ "signOutAll": "Sign out of all accounts", "active": "Active", "settings": "Account settings" + }, + "conversations": { + "conversationView": "Group mail into conversations", + "conversationViewHint": "Show a message and its replies together, and whether you have answered.", + "messagesInConversation": "{count} messages", + "youReplied": "You replied", + "notReplied": "Not answered yet", + "expandAll": "Expand all", + "collapseOlder": "Collapse older", + "loadingMessage": "Loading message…", + "replyBelow": "Reply", + "conversationEmpty": "This conversation is empty.", + "me": "me", + "draftDiscarded": "Reply discarded", + "sentReply": "Reply sent", + "moreMessages": "Show {count} earlier messages" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 6e66db1..3adcd61 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -548,5 +548,21 @@ "signOutAll": "Cerrar sesión en todas las cuentas", "active": "Activa", "settings": "Ajustes de la cuenta" + }, + "conversations": { + "conversationView": "Agrupar el correo en conversaciones", + "conversationViewHint": "Muestra un mensaje y sus respuestas juntos, y si ya respondiste.", + "messagesInConversation": "{count} mensajes", + "youReplied": "Respondiste", + "notReplied": "Aún sin responder", + "expandAll": "Expandir todo", + "collapseOlder": "Contraer anteriores", + "loadingMessage": "Cargando mensaje…", + "replyBelow": "Responder", + "conversationEmpty": "Esta conversación está vacía.", + "me": "yo", + "draftDiscarded": "Respuesta descartada", + "sentReply": "Respuesta enviada", + "moreMessages": "Mostrar {count} mensajes anteriores" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index c03ef86..9b35718 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -548,5 +548,21 @@ "signOutAll": "Sair de todas as contas", "active": "Ativa", "settings": "Configurações da conta" + }, + "conversations": { + "conversationView": "Agrupar e-mails em conversas", + "conversationViewHint": "Mostra uma mensagem e as respostas juntas, e se você já respondeu.", + "messagesInConversation": "{count} mensagens", + "youReplied": "Você respondeu", + "notReplied": "Ainda não respondida", + "expandAll": "Expandir tudo", + "collapseOlder": "Recolher anteriores", + "loadingMessage": "Carregando mensagem…", + "replyBelow": "Responder", + "conversationEmpty": "Esta conversa está vazia.", + "me": "eu", + "draftDiscarded": "Resposta descartada", + "sentReply": "Resposta enviada", + "moreMessages": "Mostrar {count} mensagens anteriores" } } diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index 3fc8d20..81bd696 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -12,6 +12,7 @@ import { Clock, RefreshCw, Trash2, + CornerUpLeft, } from "lucide-react"; import { useI18n } from "../../../../../i18n/provider"; import { useMailEvents } from "../../../../../lib/useMailEvents"; @@ -27,10 +28,16 @@ import { moveTargets, type ListFilter, } from "../../../../../lib/mailCounts"; -import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; import { plainSnippet } from "../../../../../lib/snippet"; import { useTrustedSenders } from "../../../../../lib/trustedSenders"; -import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; +import type { InlineImages } from "../../../../../lib/emailBody"; +import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage"; +import { + participantsLine, + useConversationView, + type ThreadParticipant, + type ThreadSummary, +} from "../../../../../lib/conversations"; import { Button } from "../../../../../components/ui/Button"; import { clampListWidth, @@ -40,6 +47,7 @@ import { } from "../../../../../lib/paneWidth"; import { useAccount } from "../../../../../lib/useAccount"; import { MessageReader } from "../../../../../components/MessageReader"; +import { ThreadReader } from "../../../../../components/ThreadReader"; import { Avatar } from "../../../../../components/Avatar"; interface MessageSummary { @@ -55,6 +63,34 @@ interface MessageSummary { flagged: boolean; spam_verdict: string; dmarc_result: string; + // Present when the list is showing conversations: the row is then a whole + // conversation, and uid is its newest message in this folder. + thread_id?: string; + message_count?: number; + answered?: boolean; + participants?: ThreadParticipant[]; +} + +/** A conversation row in the shape the list already knows how to draw. */ +function rowFromThread(thread: ThreadSummary): MessageSummary { + return { + uid: thread.uid, + subject: thread.subject, + sender_address: thread.sender_address, + from_display_name: thread.from_display_name, + snippet: thread.snippet, + size_bytes: 0, + received_at: thread.received_at, + has_attachments: thread.has_attachments, + seen: thread.seen, + flagged: thread.flagged, + spam_verdict: "", + dmarc_result: "", + thread_id: thread.thread_id, + message_count: thread.message_count, + answered: thread.answered, + participants: thread.participants, + }; } interface RenderedMessage { @@ -121,6 +157,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const [messages, setMessages] = useState([]); const [selected, setSelected] = useState(null); + // The open conversation, when the list is showing conversations. + const [selectedThread, setSelectedThread] = useState(null); + const conversations = useConversationView(); + const conversationMode = conversations.enabled; const [loadRemoteImages, setLoadRemoteImages] = useState(false); // The senders this account trusts, kept on the server so the decision // follows the account to every browser and also reaches the spam filter. @@ -163,25 +203,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const actions = readerActions(folder); const loadMessages = useCallback( - async (search: string) => { - setLoading(true); + async (search: string, { quiet = false }: { quiet?: boolean } = {}) => { + // A quiet reload refreshes what is shown without the spinner, for the + // changes the reader itself made. + if (!quiet) setLoading(true); setError(null); try { const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) }); - const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" }); + const res = await fetch(`/api/v1/mail/${conversationMode ? "threads" : "messages"}?${query}`, { + cache: "no-store", + }); if (res.status === 401) { window.location.href = "/user/login"; return; } + // A server that has no conversations falls back to the plain list + // rather than showing an empty folder. + if (res.status === 501 && conversationMode) { + void conversations.setEnabled(false); + return; + } if (!res.ok) throw new Error(t("errors.serverError")); - setMessages(await res.json()); + const data = await res.json(); + setMessages(conversationMode ? (data as ThreadSummary[]).map(rowFromThread) : data); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } finally { setLoading(false); } }, - [folder, t] + // conversations.setEnabled is stable enough for this one call + // eslint-disable-next-line react-hooks/exhaustive-deps + [folder, t, conversationMode] ); useMailEvents( @@ -199,9 +252,18 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s // separate mount effect as well meant every folder change fired two requests // and the later one could be the stale answer. useEffect(() => { + // Waits for the account's answer on how to list, or the folder would load + // one way and immediately reload the other. + if (!conversations.ready) return; const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0); return () => clearTimeout(handle); - }, [searchQuery, loadMessages]); + }, [searchQuery, loadMessages, conversations.ready]); + + // Changing between the two lists closes whatever was open in the other. + useEffect(() => { + setSelected(null); + setSelectedThread(null); + }, [conversationMode, folder]); // The reader frame reports its own height; nothing outside a sandboxed frame // can measure it. @@ -302,6 +364,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s [folder, t, loadMessages, searchQuery] ); + /** Deletes every message a conversation row stands for in this folder. */ + const deleteConversationRow = useCallback( + async (row: MessageSummary) => { + setMessages((current) => current.filter((m) => m.thread_id !== row.thread_id)); + setSelectedThread((current) => (current?.thread_id === row.thread_id ? null : current)); + try { + const res = await fetch( + `/api/v1/mail/thread/${encodeURIComponent(row.thread_id ?? "")}?folder=${encodeURIComponent(folder)}`, + { cache: "no-store" }, + ); + if (!res.ok) throw new Error(t("errors.serverError")); + const infos: { uid: number; folder: string }[] = await res.json(); + await Promise.all( + infos + .filter((m) => m.folder.toLowerCase() === folder.toLowerCase()) + .map((m) => + fetch("/api/v1/mail/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder, uid: m.uid }), + }), + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : t("errors.serverError")); + void loadMessages(searchQuery); + } finally { + notifyMailStateChanged(); + } + }, + [folder, t, loadMessages, searchQuery], + ); + /** Files one message into another folder. */ const moveMessage = useCallback( async (uid: number, target: string) => { @@ -332,6 +427,14 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s router.push(`/user/compose?draft=${uid}`); return; } + if (conversationMode) { + const row = messages.find((m) => m.uid === uid); + if (row?.thread_id) { + setSelected(null); + setSelectedThread(row); + return; + } + } setLoadRemoteImages(false); setReaderHeight(420); try { @@ -357,6 +460,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s searching: searchQuery.trim().length > 0, }); const selectedRow = messages.find((m) => m.uid === selected?.uid); + const reading = Boolean(selected || selectedThread); const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false; /** @@ -368,27 +472,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s */ const readerDocument = useMemo(() => { if (!selected) return null; - if (!selected.html) return null; - const sanitized = sanitizeEmailHtml(selected.html); - if (!sanitized) return null; - const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {}); // A trusted sender's images load without asking; anyone else's wait for // the reader's choice, one message at a time. const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? ""); - const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); - return buildReaderDocument(body); + return messageDocument(selected, showImages); }, [selected, loadRemoteImages, trustedSenders]); // Whether anything was actually held back, so the privacy banner is not shown // over a message that has no remote content at all. - const hasBlockedContent = useMemo(() => { - if (!selected?.html) return false; - const sanitized = resolveInlineImages( - sanitizeEmailHtml(selected.html), - selected.inline_images ?? {} - ); - return blockRemoteImages(sanitized) !== sanitized; - }, [selected]); + const hasBlockedContent = useMemo(() => (selected ? hasBlockedRemoteContent(selected) : false), [selected]); const chips: { key: ListFilter; label: string; count: number }[] = [ { key: null, label: t("mail.allMessages"), count: header.count }, @@ -416,7 +508,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {/* Messages List Column */}
{/* Folder heading, with the counts that belong to the folder rather than to the page of it that happens to be loaded. */} @@ -494,7 +586,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
) : ( filteredMessages.map((msg) => { - const isSelected = selected?.uid === msg.uid; + const isSelected = msg.thread_id ? selectedThread?.thread_id === msg.thread_id : selected?.uid === msg.uid; return ( /* The row and its delete control are siblings inside a positioned wrapper rather than nested: a button inside a @@ -524,8 +616,24 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s msg.seen ? "text-slate-300" : "text-slate-100" }`} > - {msg.from_display_name || msg.sender_address} + {msg.participants && msg.participants.length > 0 + ? participantsLine(msg.participants, t("conversations.me")) + : msg.from_display_name || msg.sender_address} + {msg.message_count && msg.message_count > 1 ? ( + + {msg.message_count} + + ) : null} + {msg.answered && ( + + )} {listTimestamp(msg.received_at, locale)} @@ -578,7 +686,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s reading in it, and no other way to get rid of it. */}