Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions server/internal/smtp/sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions src/nonview/core/DataContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/nonview/email/quotedHtml.ts
Original file line number Diff line number Diff line change
@@ -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<Element>();

// 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 <hr> 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 <date>, <who> 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;
}
98 changes: 95 additions & 3 deletions src/view/moles/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Divider,
Expand All @@ -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).
Expand All @@ -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) => ({
Expand Down Expand Up @@ -143,6 +179,7 @@ const MessageBubble = ({

return (
<Box
data-message-key={message.id}
sx={{
position: "relative",
minWidth: 0,
Expand Down Expand Up @@ -257,9 +294,64 @@ const MessageBubble = ({
}
}
>
{replyTo && (
<Box
role="button"
tabIndex={0}
aria-label={`Show original message from ${replyTo.name}`}
onClick={jumpToOriginal}
onKeyDown={(e) => {
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)" },
}),
})}
>
<Typography
variant="caption"
sx={{ fontWeight: 700, color: "primary.main", lineHeight: 1.2 }}
>
{replyTo.name}
</Typography>
{replyTo.snippet && (
<Typography
variant="caption"
sx={{
opacity: 0.75,
lineHeight: 1.35,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{replyTo.snippet}
</Typography>
)}
</Box>
)}
<MessageContent
content={displayText}
contentHtml={rich ? message.contentHtml : undefined}
contentHtml={rich ? displayHtml : undefined}
/>
<AttachmentList
attachments={attachments}
Expand Down
2 changes: 2 additions & 0 deletions src/view/moles/MessageGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const MessageGroup = ({
onDownloadAttachment,
onFetchAttachment,
onMessageAction = undefined,
resolveReplyTo = undefined,
}) => {
const { activeAccount } = useAccount();

Expand Down Expand Up @@ -97,6 +98,7 @@ const MessageGroup = ({
onDownloadAttachment={onDownloadAttachment}
onFetchAttachment={onFetchAttachment}
onAction={onMessageAction}
replyTo={resolveReplyTo ? resolveReplyTo(message) : null}
/>
))}
</Box>
Expand Down
33 changes: 32 additions & 1 deletion src/view/moles/ThreadView.tsx
Original file line number Diff line number Diff line change
@@ -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 = [];
Expand All @@ -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 = [],
Expand All @@ -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 <MessageSkeleton />;
}
Expand Down Expand Up @@ -62,6 +92,7 @@ const ThreadView = ({
onDownloadAttachment={onDownloadAttachment}
onFetchAttachment={onFetchAttachment}
onMessageAction={onMessageAction}
resolveReplyTo={resolveReplyTo}
/>
))}
</Box>
Expand Down
11 changes: 8 additions & 3 deletions src/view/pages/ThreadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
Expand Down
Loading