diff --git a/server/internal/smtp/sender.go b/server/internal/smtp/sender.go index e25aa5d..b5851f7 100644 --- a/server/internal/smtp/sender.go +++ b/server/internal/smtp/sender.go @@ -63,10 +63,18 @@ func (s *Sender) Send(ctx context.Context, creds hmail.Credentials, msg hmail.Ou } m.Subject(msg.Subject) if msg.InReplyTo != "" { - m.SetGenHeader(mail.HeaderInReplyTo, msg.InReplyTo) + m.SetGenHeader(mail.HeaderInReplyTo, ensureMsgID(msg.InReplyTo)) } if len(msg.References) > 0 { - m.SetGenHeader(mail.HeaderReferences, strings.Join(msg.References, " ")) + refs := make([]string, 0, len(msg.References)) + for _, ref := range msg.References { + if r := ensureMsgID(ref); r != "" { + refs = append(refs, r) + } + } + if len(refs) > 0 { + m.SetGenHeader(mail.HeaderReferences, strings.Join(refs, " ")) + } } if msg.BodyText != "" { m.SetBodyString(mail.TypeTextPlain, msg.BodyText) @@ -114,6 +122,24 @@ func (s *Sender) Send(ctx context.Context, creds hmail.Credentials, msg hmail.Ou return nil } +// ensureMsgID wraps a bare message-id in the angle brackets RFC 5322 requires. +// Clients send ids without brackets (the IMAP fetch path strips them), but a +// bracket-less In-Reply-To/References header is malformed — strict parsers +// (including our own read path) drop it, which breaks reply threading. +func ensureMsgID(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return id + } + if !strings.HasPrefix(id, "<") { + id = "<" + id + } + if !strings.HasSuffix(id, ">") { + id += ">" + } + return id +} + func setFrom(m *mail.Msg, a hmail.Address) error { if a.Email == "" { return errors.New("missing from address") diff --git a/src/nonview/core/DataContext.tsx b/src/nonview/core/DataContext.tsx index 208dbb6..185ba60 100644 --- a/src/nonview/core/DataContext.tsx +++ b/src/nonview/core/DataContext.tsx @@ -68,6 +68,10 @@ export interface ThreadMessage { // IMAP for received mail, so only To/Cc are available. to?: Participant[]; cc?: Participant[]; + // Message-ID of the message this one replies to (In-Reply-To header). Used + // to render the WhatsApp-style quoted preview and to strip the quoted chain + // from the reply body (issue #43). Absent on forwards and fresh mail. + inReplyTo?: string; timestamp: string; isRead: boolean; attachments?: AttachmentMeta[]; // downloadable attachment metadata (no bytes) @@ -306,6 +310,7 @@ function messageToThreadMessage( }, to: addressesToParticipants(m.to), cc: addressesToParticipants(m.cc), + inReplyTo: m.in_reply_to || undefined, timestamp: m.date, isRead: (m.flags || []).includes("\\Seen"), attachments: m.attachments && m.attachments.length ? m.attachments : undefined, diff --git a/src/nonview/email/quotedHtml.ts b/src/nonview/email/quotedHtml.ts new file mode 100644 index 0000000..0955a54 --- /dev/null +++ b/src/nonview/email/quotedHtml.ts @@ -0,0 +1,71 @@ +// Strips the quoted-original chain from a reply's HTML body so the chat +// bubble shows only the newly written content — the WhatsApp model where the +// original appears as a compact quoted preview instead (issue #43). +// +// Only KNOWN reply-quote containers are removed (Gmail, Apple Mail, Outlook, +// and this app's own composer, see replyContext.ts). Anything unrecognised is +// left untouched, so the worst case is today's behaviour. Callers must only +// invoke this for messages that are replies (In-Reply-To present) — forwards +// keep their quoted content, which *is* the message. +// +// Parsing happens in an inert DOMParser document (no script execution, no +// resource loading); DOMPurify sanitisation still runs downstream in +// MessageContent before anything is rendered. +export function stripQuotedHtml(html: string): string { + if (!html || typeof DOMParser === "undefined") return html; + + let doc: Document; + try { + doc = new DOMParser().parseFromString(html, "text/html"); + } catch { + return html; + } + const body = doc.body; + if (!body) return html; + + const removals = new Set(); + + // Gmail wraps attribution + original in a dedicated container. + body + .querySelectorAll(".gmail_quote, .gmail_quote_container") + .forEach((el) => removals.add(el)); + + // Apple Mail (and other standards-ish clients) cite with a typed blockquote. + body + .querySelectorAll('blockquote[type="cite"]') + .forEach((el) => removals.add(el)); + + // Outlook inserts a reply header div; the original message is everything + // after it (usually preceded by an
separator). + const outlook = body.querySelector("#divRplyFwdMsg, #appendonsend"); + if (outlook && outlook.parentNode) { + const prev = outlook.previousElementSibling; + if (prev && prev.tagName === "HR") removals.add(prev); + let node: Element | null = outlook; + while (node) { + removals.add(node); + node = node.nextElementSibling; + } + } + + // This app's composer: an "On , wrote:" attribution paragraph + // immediately followed by a blockquote (replyContext.ts). + body.querySelectorAll("blockquote").forEach((bq) => { + const prev = bq.previousElementSibling; + if (prev && /^On .+ wrote:\s*$/.test((prev.textContent || "").trim())) { + removals.add(prev); + removals.add(bq); + } + }); + + if (removals.size === 0) return html; + removals.forEach((el) => el.remove()); + + // Never blank a message: if stripping removed all visible content (e.g. a + // quote-only reply), fall back to the original body. + const remainingText = (body.textContent || "").trim(); + const remainingMedia = body.querySelector("img, table"); + if (!remainingText && !remainingMedia) return html; + + return body.innerHTML; +} diff --git a/src/view/moles/MessageBubble.tsx b/src/view/moles/MessageBubble.tsx index 1c4a096..b6b8d6a 100644 --- a/src/view/moles/MessageBubble.tsx +++ b/src/view/moles/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, Divider, @@ -25,6 +25,7 @@ import AttachmentList from "./AttachmentList"; import AttachmentViewer from "./AttachmentViewer"; import { isRichHtml } from "../../nonview/email/htmlKind"; import { stripQuotedText } from "../../nonview/email/quotedText"; +import { stripQuotedHtml } from "../../nonview/email/quotedHtml"; // Corner radii for bubble stacks: the sender-side corners of grouped bubbles // shrink so consecutive messages visually "fuse" (DESIGN.md, Shapes). @@ -42,14 +43,49 @@ const MessageBubble = ({ // kebab and a right-click context menu; called as onAction(action, message) // with action ∈ reply | replyAll | forward | copy | unread | archive | delete. onAction = undefined, + // WhatsApp-style quoted preview (issue #43): { targetId, name, snippet } of + // the message this one replies to, resolved by ThreadView. Null when the + // message isn't a reply or the original isn't in the loaded conversation. + replyTo = null, }) => { + // Replies hide their quoted chain (issue #43): plain text via + // stripQuotedText, HTML via stripQuotedHtml. Richness is judged on the + // stripped body — a two-line reply quoting a newsletter is a chat bubble, + // not a full-width document. + const isReply = !!message.inReplyTo; + const displayHtml = useMemo( + () => + isReply && message.contentHtml + ? stripQuotedHtml(message.contentHtml) + : message.contentHtml, + [isReply, message.contentHtml], + ); // Full HTML email bodies are documents, not chat messages — they need the // whole column width. Plain-text replies stay in the narrow chat-bubble look. - const rich = isRichHtml(message.contentHtml); + const rich = isRichHtml(displayHtml); // Chat view shows only the new text a reply added, not the quoted original // that replyContext.ts appends before sending. const displayText = rich ? message.content : stripQuotedText(message.content); + // Scroll the conversation to the message this one replies to, with a brief + // highlight pulse. The original may have left the DOM (virtual scrolling is + // not used here, but it can be missing after a permanent delete) — no-op then. + const jumpToOriginal = () => { + if (!replyTo?.targetId || typeof document === "undefined") return; + const el = document.querySelector( + `[data-message-key="${CSS.escape(replyTo.targetId)}"]`, + ); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.animate?.( + [ + { boxShadow: "0 0 0 4px rgba(0, 242, 255, 0.55)" }, + { boxShadow: "0 0 0 4px rgba(0, 242, 255, 0)" }, + ], + { duration: 1400, easing: "ease-out" }, + ); + }; + // Map the server attachment DTO (id/filename/mime_type/size) onto the shape // AttachmentPreview/AttachmentInfo expect (id/name/size). const attachments = (message.attachments || []).map((a) => ({ @@ -143,6 +179,7 @@ const MessageBubble = ({ return ( + {replyTo && ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + jumpToOriginal(); + } + }} + sx={(theme) => ({ + display: "flex", + flexDirection: "column", + gap: 0.25, + borderLeft: "3px solid", + borderLeftColor: "primary.main", + borderRadius: "6px", + px: 1.25, + py: 0.75, + mb: 1, + cursor: "pointer", + backgroundColor: "rgba(0, 105, 111, 0.07)", + transition: "background-color 0.15s", + "&:hover": { backgroundColor: "rgba(0, 105, 111, 0.13)" }, + ...theme.applyStyles("dark", { + backgroundColor: "rgba(255, 255, 255, 0.06)", + "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.10)" }, + }), + })} + > + + {replyTo.name} + + {replyTo.snippet && ( + + {replyTo.snippet} + + )} + + )} { const { activeAccount } = useAccount(); @@ -97,6 +98,7 @@ const MessageGroup = ({ onDownloadAttachment={onDownloadAttachment} onFetchAttachment={onFetchAttachment} onAction={onMessageAction} + replyTo={resolveReplyTo ? resolveReplyTo(message) : null} /> ))} diff --git a/src/view/moles/ThreadView.tsx b/src/view/moles/ThreadView.tsx index 9dcc6d0..a447f9b 100644 --- a/src/view/moles/ThreadView.tsx +++ b/src/view/moles/ThreadView.tsx @@ -1,7 +1,8 @@ -import React from "react"; +import React, { useCallback, useMemo } from "react"; import { Box } from "@mui/material"; import MessageGroup from "./MessageGroup"; import MessageSkeleton from "../atoms/MessageSkeleton"; +import { stripQuotedText } from "../../nonview/email/quotedText"; const groupMessages = (messages = []) => { const groups = []; @@ -20,6 +21,10 @@ const groupMessages = (messages = []) => { return groups; }; +// Message-IDs appear with and without their angle brackets depending on the +// header/DTO they came from; compare them normalised. +const normalizeMessageId = (id) => (id || "").trim().replace(/^<|>$/g, ""); + const ThreadView = ({ thread, messages = [], @@ -28,6 +33,31 @@ const ThreadView = ({ onFetchAttachment, onMessageAction = undefined, }) => { + // Reply → original lookup for the WhatsApp-style quoted preview (issue + // #43): match a reply's In-Reply-To against the thread's Message-IDs. + const byMessageId = useMemo(() => { + const map = new Map(); + messages.forEach((m) => { + const key = normalizeMessageId(m.id); + if (key) map.set(key, m); + }); + return map; + }, [messages]); + + const resolveReplyTo = useCallback( + (message) => { + if (!message?.inReplyTo) return null; + const parent = byMessageId.get(normalizeMessageId(message.inReplyTo)); + if (!parent || parent.id === message.id) return null; + return { + targetId: parent.id, + name: parent.sender?.name || parent.sender?.email || "Unknown", + snippet: stripQuotedText(parent.content || "").slice(0, 160), + }; + }, + [byMessageId], + ); + if (loading) { return ; } @@ -62,6 +92,7 @@ const ThreadView = ({ onDownloadAttachment={onDownloadAttachment} onFetchAttachment={onFetchAttachment} onMessageAction={onMessageAction} + resolveReplyTo={resolveReplyTo} /> ))} diff --git a/src/view/pages/ThreadPage.tsx b/src/view/pages/ThreadPage.tsx index bd07666..ebb6046 100644 --- a/src/view/pages/ThreadPage.tsx +++ b/src/view/pages/ThreadPage.tsx @@ -84,13 +84,14 @@ function ThreadPage() { return ctx; }, [replyMode, thread, messages, activeAccount, draftText]); - const appendLocalMessage = (content: string, contentHtml?: string) => { + const appendLocalMessage = (content: string, contentHtml?: string, inReplyTo?: string) => { setMessages((prev) => [ ...prev, { id: `local-${Date.now()}`, content, contentHtml, + inReplyTo, sender: { id: "current", name: activeAccount?.name || "You", @@ -120,7 +121,7 @@ function ThreadPage() { inReplyTo: ctx.threadContext.inReplyTo, references: ctx.threadContext.references, }); - appendLocalMessage(text); + appendLocalMessage(text, undefined, ctx.threadContext.inReplyTo); setDraftText(""); setToast("Message sent"); } catch (e) { @@ -368,7 +369,11 @@ function ThreadPage() { // Optimistically show the sent reply in the open conversation. // The real copy lives in the Sent mailbox, so a refetch of this // thread wouldn't surface it; append it locally instead. - appendLocalMessage(sent.content || "", sent.contentHtml); + appendLocalMessage( + sent.content || "", + sent.contentHtml, + replyCtx.threadContext.inReplyTo, + ); }} initial={replyCtx.initial} threadContext={replyCtx.threadContext}