From 1a1e10a690a10cfa9319932a4392ec5146a5e1c1 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 6 Aug 2026 11:19:29 -0400 Subject: [PATCH 1/2] feat(mail): elide quoted reply history Closes #2 --- mailcmd/handlers_test.go | 94 ++++++ mailcmd/output.go | 15 +- mailcmd/output_test.go | 3 + mailcmd/quoted_reply.go | 564 +++++++++++++++++++++++++++++++++++ mailcmd/quoted_reply_test.go | 213 +++++++++++++ mailcmd/read.go | 9 +- mailcmd/thread.go | 9 +- 7 files changed, 898 insertions(+), 9 deletions(-) create mode 100644 mailcmd/quoted_reply.go create mode 100644 mailcmd/quoted_reply_test.go diff --git a/mailcmd/handlers_test.go b/mailcmd/handlers_test.go index 50f2c88..5984fd9 100644 --- a/mailcmd/handlers_test.go +++ b/mailcmd/handlers_test.go @@ -165,6 +165,58 @@ func TestReadCommand_Success(t *testing.T) { }) } +func syntheticQuotedReplyMessage(id string) *gmailapi.Message { + return &gmailapi.Message{ + ID: id, + Subject: "Synthetic subject", + Body: "Authored reply.\n\nOn [date], [author] wrote:\n> quoted history", + } +} + +func TestReadCommand_ElidesQuotedReplyByDefault(t *testing.T) { + mock := &MockGmailClient{ + GetMessageFunc: func(_ context.Context, messageID string, includeBody bool) (*gmailapi.Message, error) { + testutil.Equal(t, messageID, "synthetic-message") + testutil.True(t, includeBody) + return syntheticQuotedReplyMessage(messageID), nil + }, + } + + cmd := newReadCommand() + cmd.SetArgs([]string{"synthetic-message"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + testutil.NoError(t, cmd.Execute()) + }) + + testutil.Contains(t, output, "Authored reply.") + testutil.Contains(t, output, quotedReplyMarkerForTest) + testutil.NotContains(t, output, "quoted history") + }) +} + +func TestReadCommand_IncludesQuotedReplyBodies(t *testing.T) { + mock := &MockGmailClient{ + GetMessageFunc: func(_ context.Context, messageID string, includeBody bool) (*gmailapi.Message, error) { + testutil.True(t, includeBody) + return syntheticQuotedReplyMessage(messageID), nil + }, + } + + cmd := newReadCommand() + cmd.SetArgs([]string{"synthetic-message", "--include-quoted-reply-bodies"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + testutil.NoError(t, cmd.Execute()) + }) + + testutil.Contains(t, output, "Authored reply.\n\nOn [date], [author] wrote:\n> quoted history") + testutil.NotContains(t, output, quotedReplyMarkerForTest) + }) +} + func TestReadCommand_NotFound(t *testing.T) { mock := &MockGmailClient{ GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) { @@ -206,6 +258,48 @@ func TestThreadCommand_Success(t *testing.T) { }) } +func TestThreadCommand_ElidesQuotedReplyByDefault(t *testing.T) { + mock := &MockGmailClient{ + GetThreadFunc: func(_ context.Context, id string) ([]*gmailapi.Message, error) { + testutil.Equal(t, id, "synthetic-thread") + return []*gmailapi.Message{syntheticQuotedReplyMessage("synthetic-message")}, nil + }, + } + + cmd := newThreadCommand() + cmd.SetArgs([]string{"synthetic-thread"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + testutil.NoError(t, cmd.Execute()) + }) + + testutil.Contains(t, output, "Authored reply.") + testutil.Contains(t, output, quotedReplyMarkerForTest) + testutil.NotContains(t, output, "quoted history") + }) +} + +func TestThreadCommand_IncludesQuotedReplyBodies(t *testing.T) { + mock := &MockGmailClient{ + GetThreadFunc: func(_ context.Context, _ string) ([]*gmailapi.Message, error) { + return []*gmailapi.Message{syntheticQuotedReplyMessage("synthetic-message")}, nil + }, + } + + cmd := newThreadCommand() + cmd.SetArgs([]string{"synthetic-thread", "--include-quoted-reply-bodies"}) + + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + testutil.NoError(t, cmd.Execute()) + }) + + testutil.Contains(t, output, "Authored reply.\n\nOn [date], [author] wrote:\n> quoted history") + testutil.NotContains(t, output, quotedReplyMarkerForTest) + }) +} + func TestLabelsCommand_Success(t *testing.T) { mock := &MockGmailClient{ FetchLabelsFunc: func(_ context.Context) error { diff --git a/mailcmd/output.go b/mailcmd/output.go index 20b23dc..ccbbf9c 100644 --- a/mailcmd/output.go +++ b/mailcmd/output.go @@ -41,10 +41,11 @@ func newGmailClient(ctx context.Context) (MailClient, error) { // MessagePrintOptions controls which fields to include in message output type MessagePrintOptions struct { - IncludeThreadID bool - IncludeTo bool - IncludeSnippet bool - IncludeBody bool + IncludeThreadID bool + IncludeTo bool + IncludeSnippet bool + IncludeBody bool + IncludeQuotedReplyBodies bool } // printMessageHeader prints the common header fields of a message @@ -70,7 +71,11 @@ func printMessageHeader(msg *gmail.Message, opts MessagePrintOptions) { fmt.Printf("Snippet: %s\n", SanitizeOutput(msg.Snippet)) } if opts.IncludeBody { + body := msg.Body + if !opts.IncludeQuotedReplyBodies { + body, _ = elideQuotedReplyBody(body, msg.BodyIsHTML) + } fmt.Print("\n--- Body ---\n\n") - fmt.Println(SanitizeOutput(msg.Body)) + fmt.Println(SanitizeOutput(body)) } } diff --git a/mailcmd/output_test.go b/mailcmd/output_test.go index 508d4d3..72ce6ec 100644 --- a/mailcmd/output_test.go +++ b/mailcmd/output_test.go @@ -15,6 +15,7 @@ func TestMessagePrintOptions(t *testing.T) { testutil.False(t, opts.IncludeTo) testutil.False(t, opts.IncludeSnippet) testutil.False(t, opts.IncludeBody) + testutil.False(t, opts.IncludeQuotedReplyBodies) }) t.Run("options can be set individually", func(t *testing.T) { @@ -27,5 +28,7 @@ func TestMessagePrintOptions(t *testing.T) { testutil.False(t, opts.IncludeTo) testutil.False(t, opts.IncludeSnippet) testutil.True(t, opts.IncludeBody) + opts.IncludeQuotedReplyBodies = true + testutil.True(t, opts.IncludeQuotedReplyBodies) }) } diff --git a/mailcmd/quoted_reply.go b/mailcmd/quoted_reply.go new file mode 100644 index 0000000..926917a --- /dev/null +++ b/mailcmd/quoted_reply.go @@ -0,0 +1,564 @@ +package mailcmd + +import ( + "bytes" + "regexp" + "strings" + + xhtml "golang.org/x/net/html" +) + +const quotedReplyElisionMarker = "[quoted reply history elided; use --include-quoted-reply-bodies to show]" + +var ( + replyAttributionLine = regexp.MustCompile(`(?i)^on\b.*\bwrote:\s*$`) + replyAttributionStart = regexp.MustCompile(`(?i)^on\b`) + replyAttributionEnd = regexp.MustCompile(`(?i)\bwrote:\s*$`) +) + +type replyTextLine struct { + start int + text string +} + +// elideQuotedReplyBody removes only a confidently identified terminal quote. +// It returns the original body and false when the shape is ambiguous. +func elideQuotedReplyBody(body string, bodyIsHTML bool) (string, bool) { + if bodyIsHTML { + return elideQuotedReplyHTML(body) + } + return elideQuotedReplyPlain(body) +} + +func elideQuotedReplyPlain(body string) (string, bool) { + lines := splitReplyTextLines(body) + if len(lines) == 0 || hasTopLevelForwardedMessage(lines) { + return body, false + } + + for i, line := range lines { + if attributionEnd, ok := replyAttributionEndLine(lines, i); ok && hasQuotedSuffix(lines, attributionEnd) { + return body[:line.start] + quotedReplyElisionMarker, true + } + trimmed := strings.TrimSpace(line.text) + if isReplyHistoryMarker(trimmed) && hasReplyHeaderBlock(lines, i+1) { + return body[:line.start] + quotedReplyElisionMarker, true + } + if isOutlookHeaderStart(lines, i) && hasReplyHeaderBlock(lines, i) { + return body[:line.start] + quotedReplyElisionMarker, true + } + } + + return body, false +} + +func splitReplyTextLines(body string) []replyTextLine { + if body == "" { + return nil + } + + var lines []replyTextLine + for start := 0; start < len(body); { + end := strings.IndexByte(body[start:], '\n') + if end < 0 { + end = len(body) + } else { + end += start + 1 + } + text := body[start:end] + text = strings.TrimSuffix(text, "\n") + text = strings.TrimSuffix(text, "\r") + lines = append(lines, replyTextLine{start: start, text: text}) + start = end + } + return lines +} + +func hasTopLevelForwardedMessage(lines []replyTextLine) bool { + for _, line := range lines { + if isPlainQuoteLine(line.text) { + continue + } + text := strings.ToLower(strings.TrimSpace(line.text)) + if strings.Contains(text, "forwarded message") && + (strings.Contains(text, "-") || strings.HasPrefix(text, "begin ")) { + return true + } + } + return false +} + +func replyAttributionEndLine(lines []replyTextLine, start int) (int, bool) { + line := strings.TrimSpace(lines[start].text) + if replyAttributionLine.MatchString(line) { + return start, true + } + if !replyAttributionStart.MatchString(line) { + return 0, false + } + + last := start + 2 + if last >= len(lines) { + last = len(lines) - 1 + } + for end := start + 1; end <= last; end++ { + continuation := strings.TrimSpace(lines[end].text) + if continuation == "" || isPlainQuoteLine(continuation) { + return 0, false + } + if replyAttributionEnd.MatchString(continuation) { + return end, true + } + } + return 0, false +} + +func hasQuotedSuffix(lines []replyTextLine, attribution int) bool { + hasContent := false + for _, line := range lines[attribution+1:] { + text := strings.TrimSpace(line.text) + if text == "" { + continue + } + if !isPlainQuoteLine(text) { + return false + } + if plainQuoteContent(text) != "" { + hasContent = true + } + } + return hasContent +} + +func isPlainQuoteLine(line string) bool { + return strings.HasPrefix(strings.TrimLeft(line, " \t"), ">") +} + +func plainQuoteContent(line string) string { + line = strings.TrimLeft(line, " \t") + for strings.HasPrefix(line, ">") { + line = strings.TrimLeft(line[1:], " \t") + } + return strings.TrimSpace(line) +} + +func isReplyHistoryMarker(line string) bool { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") || !strings.HasSuffix(line, "-") { + return false + } + lower := strings.ToLower(line) + return strings.Contains(lower, "original message") || strings.Contains(lower, "reply message") +} + +func isOutlookHeaderStart(lines []replyTextLine, index int) bool { + if replyHeaderName(lines[index].text) != "from" { + return false + } + return index == 0 || strings.TrimSpace(lines[index-1].text) == "" +} + +func hasReplyHeaderBlock(lines []replyTextLine, start int) bool { + for start < len(lines) && strings.TrimSpace(lines[start].text) == "" { + start++ + } + + seen := make(map[string]bool) + for start < len(lines) { + name := replyHeaderName(lines[start].text) + if name == "" { + break + } + seen[name] = true + start++ + } + + if !seen["from"] || !seen["to"] || !seen["subject"] || (!seen["sent"] && !seen["date"]) { + return false + } + for _, line := range lines[start:] { + if strings.TrimSpace(line.text) != "" { + return true + } + } + return false +} + +func replyHeaderName(line string) string { + colon := strings.IndexByte(line, ':') + if colon < 1 { + return "" + } + switch strings.ToLower(strings.TrimSpace(line[:colon])) { + case "from", "sent", "date", "to", "cc", "subject": + return strings.ToLower(strings.TrimSpace(line[:colon])) + default: + return "" + } +} + +func elideQuotedReplyHTML(body string) (string, bool) { + doc, err := xhtml.Parse(strings.NewReader(body)) + if err != nil { + return body, false + } + bodyNode := findHTMLBody(doc) + if bodyNode == nil { + return body, false + } + candidate := terminalHTMLQuote(bodyNode) + if candidate == nil || !htmlQuoteHasExplicitClose(body, candidate) { + return body, false + } + + parent := candidate.Parent + if parent == nil { + return body, false + } + first := candidate + if previous := previousMeaningfulHTMLSibling(candidate); isHTMLQuoteAttribution(previous) { + first = previous + } + next := candidate.NextSibling + if isZimbraDivider(candidate) { + for node := candidate; node != nil; { + nextNode := node.NextSibling + parent.RemoveChild(node) + node = nextNode + } + next = nil + } else { + for node := first; ; { + nextNode := node.NextSibling + parent.RemoveChild(node) + if node == candidate { + break + } + node = nextNode + } + } + marker := &xhtml.Node{Type: xhtml.TextNode, Data: quotedReplyElisionMarker} + parent.InsertBefore(marker, next) + + var out bytes.Buffer + if isHTMLDocument(body) { + if err := xhtml.Render(&out, doc); err != nil { + return body, false + } + } else { + for node := bodyNode.FirstChild; node != nil; node = node.NextSibling { + if err := xhtml.Render(&out, node); err != nil { + return body, false + } + } + } + return out.String(), true +} + +func findHTMLBody(node *xhtml.Node) *xhtml.Node { + if node.Type == xhtml.ElementNode && node.Data == "body" { + return node + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + if body := findHTMLBody(child); body != nil { + return body + } + } + return nil +} + +func terminalHTMLQuote(parent *xhtml.Node) *xhtml.Node { + if divider := terminalHTMLDivider(parent); divider != nil { + return divider + } + last := lastMeaningfulHTMLChild(parent) + if last == nil { + return nil + } + if isHTMLQuoteCandidate(last) && htmlHasMeaningfulContent(last) { + return last + } + if last.Type == xhtml.ElementNode { + return terminalHTMLQuote(last) + } + return nil +} + +func terminalHTMLDivider(parent *xhtml.Node) *xhtml.Node { + var divider *xhtml.Node + for child := parent.FirstChild; child != nil; child = child.NextSibling { + if isZimbraDivider(child) && hasMeaningfulHTMLSibling(child) { + divider = child + } + } + return divider +} + +func hasMeaningfulHTMLSibling(node *xhtml.Node) bool { + for sibling := node.NextSibling; sibling != nil; sibling = sibling.NextSibling { + if sibling.Type == xhtml.CommentNode { + continue + } + if sibling.Type == xhtml.TextNode && strings.TrimSpace(sibling.Data) == "" { + continue + } + return true + } + return false +} + +func lastMeaningfulHTMLChild(parent *xhtml.Node) *xhtml.Node { + for child := parent.LastChild; child != nil; child = child.PrevSibling { + if child.Type == xhtml.CommentNode { + continue + } + if child.Type == xhtml.TextNode && strings.TrimSpace(child.Data) == "" { + continue + } + return child + } + return nil +} + +func htmlHasMeaningfulContent(node *xhtml.Node) bool { + if node.Type == xhtml.TextNode { + return strings.TrimSpace(node.Data) != "" + } + if node.Type == xhtml.CommentNode { + return false + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + if htmlHasMeaningfulContent(child) { + return true + } + } + return false +} + +func isHTMLQuoteContainer(node *xhtml.Node) bool { + if node.Type != xhtml.ElementNode { + return false + } + class := htmlAttribute(node, "class") + if hasHTMLClass(class, "gmail_quote") { + return true + } + id := strings.ToLower(htmlAttribute(node, "id")) + switch id { + case "divrplyfwdmsg", "olk_src_body_section", "zmail_extra": + return true + } + if isZimbraDivider(node) { + return true + } + if node.Data == "div" && strings.Contains(strings.ToLower(htmlAttribute(node, "style")), "border-top") { + return true + } + return node.Data == "blockquote" && strings.EqualFold(htmlAttribute(node, "type"), "cite") +} + +func isHTMLQuoteCandidate(node *xhtml.Node) bool { + if !isHTMLQuoteContainer(node) || isZimbraDivider(node) { + return false + } + if node.Data == "div" && strings.Contains(strings.ToLower(htmlAttribute(node, "style")), "border-top") { + return hasHTMLReplyHeaderBlock(node) + } + return true +} + +func isZimbraDivider(node *xhtml.Node) bool { + return node.Type == xhtml.ElementNode && node.Data == "hr" && + htmlAttribute(node, "data-marker") == "__DIVIDER__" +} + +func hasHTMLReplyHeaderBlock(node *xhtml.Node) bool { + return hasReplyHeaderBlock(splitReplyTextLines(htmlTextContent(node)), 0) +} + +func htmlTextContent(node *xhtml.Node) string { + var text strings.Builder + var visit func(*xhtml.Node) + visit = func(current *xhtml.Node) { + switch current.Type { + case xhtml.DocumentNode, xhtml.CommentNode, xhtml.DoctypeNode, xhtml.ErrorNode, xhtml.RawNode: + case xhtml.TextNode: + text.WriteString(current.Data) + case xhtml.ElementNode: + if current.Data == "br" { + text.WriteByte('\n') + return + } + for child := current.FirstChild; child != nil; child = child.NextSibling { + visit(child) + } + switch current.Data { + case "div", "li", "p", "tr": + text.WriteByte('\n') + } + } + } + visit(node) + return text.String() +} + +func isHTMLQuoteAttribution(node *xhtml.Node) bool { + if node == nil || node.Type != xhtml.ElementNode { + return false + } + return hasHTMLClass(htmlAttribute(node, "class"), "moz-cite-prefix") +} + +func previousMeaningfulHTMLSibling(node *xhtml.Node) *xhtml.Node { + for sibling := node.PrevSibling; sibling != nil; sibling = sibling.PrevSibling { + if sibling.Type == xhtml.CommentNode { + continue + } + if sibling.Type == xhtml.TextNode && strings.TrimSpace(sibling.Data) == "" { + continue + } + return sibling + } + return nil +} + +func htmlAttribute(node *xhtml.Node, name string) string { + for _, attr := range node.Attr { + if strings.EqualFold(attr.Key, name) { + return attr.Val + } + } + return "" +} + +func hasHTMLClass(classes, want string) bool { + for _, class := range strings.Fields(classes) { + if strings.EqualFold(class, want) { + return true + } + } + return false +} + +func htmlQuoteHasExplicitClose(body string, candidate *xhtml.Node) bool { + type openTag struct { + name string + quoteIndex int + } + + targetIndex := htmlQuoteIndex(candidate) + if targetIndex < 0 { + return false + } + tokenizer := xhtml.NewTokenizer(strings.NewReader(body)) + open := make([]openTag, 0, 8) + quoteClosed := make([]bool, 0, 2) + for { + tokenType := tokenizer.Next() + switch tokenType { + case xhtml.ErrorToken: + return targetIndex < len(quoteClosed) && quoteClosed[targetIndex] + case xhtml.StartTagToken: + token := tokenizer.Token() + quoteIndex := -1 + if isHTMLQuoteToken(token) { + quoteIndex = len(quoteClosed) + quoteClosed = append(quoteClosed, isHTMLQuoteVoidToken(token)) + } + open = append(open, openTag{name: token.Data, quoteIndex: quoteIndex}) + case xhtml.SelfClosingTagToken: + token := tokenizer.Token() + quoteIndex := -1 + if isHTMLQuoteToken(token) { + quoteIndex = len(quoteClosed) + quoteClosed = append(quoteClosed, true) + } + open = append(open, openTag{name: token.Data, quoteIndex: quoteIndex}) + case xhtml.EndTagToken: + token := tokenizer.Token() + for index := len(open) - 1; index >= 0; index-- { + if strings.EqualFold(open[index].name, token.Data) { + if open[index].quoteIndex >= 0 { + quoteClosed[open[index].quoteIndex] = true + } + open = open[:index] + break + } + } + case xhtml.TextToken, xhtml.CommentToken, xhtml.DoctypeToken: + } + } +} + +func htmlQuoteIndex(candidate *xhtml.Node) int { + root := candidate + for root.Parent != nil { + root = root.Parent + } + + index := -1 + count := 0 + var visit func(*xhtml.Node) + visit = func(node *xhtml.Node) { + if isHTMLQuoteContainer(node) { + if node == candidate { + index = count + } + count++ + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + visit(child) + } + } + visit(root) + return index +} + +func isHTMLQuoteToken(token xhtml.Token) bool { + class := "" + id := "" + typeAttr := "" + for _, attr := range token.Attr { + switch strings.ToLower(attr.Key) { + case "class": + class = attr.Val + case "id": + id = strings.ToLower(attr.Val) + case "type": + typeAttr = attr.Val + } + } + if hasHTMLClass(class, "gmail_quote") { + return true + } + switch id { + case "divrplyfwdmsg", "olk_src_body_section", "zmail_extra": + return true + } + if strings.EqualFold(token.Data, "hr") && htmlAttributeToken(token, "data-marker") == "__DIVIDER__" { + return true + } + if strings.EqualFold(token.Data, "div") && strings.Contains(strings.ToLower(htmlAttributeToken(token, "style")), "border-top") { + return true + } + return strings.EqualFold(token.Data, "blockquote") && strings.EqualFold(typeAttr, "cite") +} + +func isHTMLQuoteVoidToken(token xhtml.Token) bool { + return strings.EqualFold(token.Data, "hr") && htmlAttributeToken(token, "data-marker") == "__DIVIDER__" +} + +func htmlAttributeToken(token xhtml.Token, name string) string { + for _, attr := range token.Attr { + if strings.EqualFold(attr.Key, name) { + return attr.Val + } + } + return "" +} + +func isHTMLDocument(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "> older line\n>> older line", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Apple attribution with nested plain quote", + body: "Authored reply.\n\nOn [date], at [time], [author] wrote:\n\n> older line", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Original Message header block", + body: "Authored reply.\n\n-----Original Message-----\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nOlder message", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Reply Message header block", + body: "Authored reply.\n\n----- Reply Message -----\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nOlder message", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Outlook header block", + body: "Authored reply.\n\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nOlder message", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "CRLF and nested quote depth", + body: "Authored reply.\r\n\r\nOn [date], [author] wrote:\r\n\r\n>>> older line\r\n>>>> oldest line", + want: "Authored reply.\r\n\r\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "wrapped attribution with two continuation lines", + body: "Authored reply.\n\nOn [date], [author]\nwith [context]\nand [more context] wrote:\n> older line", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "wrapped attribution with CRLF", + body: "Authored reply.\r\n\r\nOn [date], [author]\r\nwith [context] wrote:\r\n>> older line", + want: "Authored reply.\r\n\r\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "wrapped attribution followed by inline authored text is preserved", + body: "Authored reply.\n\nOn [date], [author]\nwith [context] wrote:\n> older line\nInline response", + want: "Authored reply.\n\nOn [date], [author]\nwith [context] wrote:\n> older line\nInline response", + }, + { + name: "quote followed by authored text is preserved", + body: "Authored reply.\n\nOn [date], [author] wrote:\n> older line\nInline response", + want: "Authored reply.\n\nOn [date], [author] wrote:\n> older line\nInline response", + }, + { + name: "quote authored quote is preserved", + body: "> older line\nInline response\n> another older line", + want: "> older line\nInline response\n> another older line", + }, + { + name: "forwarded header block is preserved", + body: "Authored reply.\n\n---------- Forwarded message ----------\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nForwarded message", + want: "Authored reply.\n\n---------- Forwarded message ----------\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nForwarded message", + }, + { + name: "top-level forward is preserved", + body: "---------- Forwarded message ----------\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nForwarded message", + want: "---------- Forwarded message ----------\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nForwarded message", + }, + { + name: "forward marker nested in terminal quote history is elided", + body: "Authored reply.\n\nOn [date], [author] wrote:\n> ---------- Forwarded message ----------\n> From: [author]\n> older message", + want: "Authored reply.\n\n" + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "unattributed quote block is preserved", + body: "Authored reply.\n\n> quoted prose\n> more quoted prose", + want: "Authored reply.\n\n> quoted prose\n> more quoted prose", + }, + { + name: "ordinary Markdown quote is preserved", + body: "# Heading\n\n> quoted prose", + want: "# Heading\n\n> quoted prose", + }, + { + name: "uncertain attribution is preserved", + body: "Authored reply.\n\nOn [date], [author] wrote:\nnot marked as quoted", + want: "Authored reply.\n\nOn [date], [author] wrote:\nnot marked as quoted", + }, + { + name: "Gmail HTML quote", + body: `

Authored reply.

On [date], [author] wrote:
Older message
`, + isHTML: true, + want: `

Authored reply.

` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Thunderbird HTML quote", + body: `

Authored reply.

Older message
`, + isHTML: true, + want: `

Authored reply.

` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Thunderbird attribution sibling and cite block", + body: `

Authored reply.

On [date], [author] wrote:
Older message
`, + isHTML: true, + want: `

Authored reply.

` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Outlook HTML quote", + body: `
Authored reply.
From: [author]
Subject: [subject]
`, + isHTML: true, + want: `
Authored reply.
` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Outlook border-style header wrapper", + body: `
Authored reply.
From: [author]
Sent: [date]
To: [recipient]
Subject: [subject]
Older message
`, + isHTML: true, + want: `
Authored reply.
` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "arbitrary styled div is preserved", + body: `
Authored reply.

Meaningful styled content

`, + isHTML: true, + want: `
Authored reply.

Meaningful styled content

`, + }, + { + name: "Zimbra HTML quote", + body: `
Authored reply.
Older message
`, + isHTML: true, + want: `
Authored reply.
` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "Zimbra divider and following history", + body: `
Authored reply.

Older message
`, + isHTML: true, + want: `
Authored reply.
` + quotedReplyMarkerForTest, + elided: true, + }, + { + name: "generic HTML blockquote is preserved", + body: `

Authored reply.

Quoted prose
`, + isHTML: true, + want: `

Authored reply.

Quoted prose
`, + }, + { + name: "HTML content after client quote is preserved", + body: `

Authored reply.

Older message

Meaningful text after quote

`, + isHTML: true, + want: `

Authored reply.

Older message

Meaningful text after quote

`, + }, + { + name: "malformed HTML is preserved", + body: `

Authored reply.

Older message`, + isHTML: true, + want: `

Authored reply.

Older message`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, elided := elideQuotedReplyBody(tt.body, tt.isHTML) + testutil.Equal(t, got, tt.want) + testutil.Equal(t, elided, tt.elided) + }) + } +} + +func TestPrintMessageHeader_ElidesBeforeSanitizing(t *testing.T) { + msg := &gmail.Message{ + ID: "synthetic-message", + Body: "Authored \x1b[31mreply\x1b[0m.\n\nOn [date], [author] wrote:\n> older line", + } + + output := testutil.CaptureStdout(t, func() { + printMessageHeader(msg, MessagePrintOptions{IncludeBody: true}) + }) + + testutil.Contains(t, output, "Authored reply.") + testutil.Contains(t, output, quotedReplyMarkerForTest) + testutil.NotContains(t, output, "older line") + testutil.NotContains(t, output, "\x1b") +} diff --git a/mailcmd/read.go b/mailcmd/read.go index 9fe1234..3e1b15d 100644 --- a/mailcmd/read.go +++ b/mailcmd/read.go @@ -7,6 +7,8 @@ import ( ) func newReadCommand() *cobra.Command { + var includeQuotedReplyBodies bool + cmd := &cobra.Command{ Use: "read ", Short: "Read a single message", @@ -29,13 +31,16 @@ Examples: } printMessageHeader(msg, MessagePrintOptions{ - IncludeTo: true, - IncludeBody: true, + IncludeTo: true, + IncludeBody: true, + IncludeQuotedReplyBodies: includeQuotedReplyBodies, }) return nil }, } + cmd.Flags().BoolVar(&includeQuotedReplyBodies, "include-quoted-reply-bodies", false, + "Include complete quoted reply history in message bodies") return cmd } diff --git a/mailcmd/thread.go b/mailcmd/thread.go index f7e978e..742189f 100644 --- a/mailcmd/thread.go +++ b/mailcmd/thread.go @@ -7,6 +7,8 @@ import ( ) func newThreadCommand() *cobra.Command { + var includeQuotedReplyBodies bool + cmd := &cobra.Command{ Use: "thread ", Short: "Read a full conversation thread", @@ -40,8 +42,9 @@ Examples: for i, msg := range messages { fmt.Printf("=== Message %d of %d ===\n", i+1, len(messages)) printMessageHeader(msg, MessagePrintOptions{ - IncludeTo: true, - IncludeBody: true, + IncludeTo: true, + IncludeBody: true, + IncludeQuotedReplyBodies: includeQuotedReplyBodies, }) fmt.Println() } @@ -49,6 +52,8 @@ Examples: return nil }, } + cmd.Flags().BoolVar(&includeQuotedReplyBodies, "include-quoted-reply-bodies", false, + "Include complete quoted reply history in message bodies") return cmd } From c37591d0ede2dec75c162f797e8d9a8f5517f577 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Thu, 6 Aug 2026 11:32:54 -0400 Subject: [PATCH 2/2] fix(mail): preserve ambiguous quoted content --- mailcmd/output_test.go | 22 ++++++ mailcmd/quoted_reply.go | 132 +++++++---------------------------- mailcmd/quoted_reply_test.go | 29 ++++++-- 3 files changed, 70 insertions(+), 113 deletions(-) diff --git a/mailcmd/output_test.go b/mailcmd/output_test.go index 72ce6ec..4a054d2 100644 --- a/mailcmd/output_test.go +++ b/mailcmd/output_test.go @@ -3,6 +3,7 @@ package mailcmd import ( "testing" + "github.com/open-cli-collective/google-cli-common/gmail" "github.com/open-cli-collective/google-cli-common/testutil" ) @@ -32,3 +33,24 @@ func TestMessagePrintOptions(t *testing.T) { testutil.True(t, opts.IncludeQuotedReplyBodies) }) } + +func TestPrintMessageHeader_HTMLQuoteModes(t *testing.T) { + body := `

Authored reply.

Older message

` + msg := &gmail.Message{ID: "synthetic-message", Body: body, BodyIsHTML: true} + + defaultOutput := testutil.CaptureStdout(t, func() { + printMessageHeader(msg, MessagePrintOptions{IncludeBody: true}) + }) + testutil.Contains(t, defaultOutput, "Authored reply.") + testutil.Contains(t, defaultOutput, quotedReplyMarkerForTest) + testutil.NotContains(t, defaultOutput, "Older message") + + includedOutput := testutil.CaptureStdout(t, func() { + printMessageHeader(msg, MessagePrintOptions{ + IncludeBody: true, + IncludeQuotedReplyBodies: true, + }) + }) + testutil.Contains(t, includedOutput, body) + testutil.NotContains(t, includedOutput, quotedReplyMarkerForTest) +} diff --git a/mailcmd/quoted_reply.go b/mailcmd/quoted_reply.go index 926917a..6bad453 100644 --- a/mailcmd/quoted_reply.go +++ b/mailcmd/quoted_reply.go @@ -41,10 +41,7 @@ func elideQuotedReplyPlain(body string) (string, bool) { return body[:line.start] + quotedReplyElisionMarker, true } trimmed := strings.TrimSpace(line.text) - if isReplyHistoryMarker(trimmed) && hasReplyHeaderBlock(lines, i+1) { - return body[:line.start] + quotedReplyElisionMarker, true - } - if isOutlookHeaderStart(lines, i) && hasReplyHeaderBlock(lines, i) { + if (isReplyHistoryMarker(trimmed) || isOutlookReplyDivider(trimmed)) && hasReplyHeaderBlock(lines, i+1) { return body[:line.start] + quotedReplyElisionMarker, true } } @@ -151,11 +148,16 @@ func isReplyHistoryMarker(line string) bool { return strings.Contains(lower, "original message") || strings.Contains(lower, "reply message") } -func isOutlookHeaderStart(lines []replyTextLine, index int) bool { - if replyHeaderName(lines[index].text) != "from" { +func isOutlookReplyDivider(line string) bool { + if len(line) < 8 { return false } - return index == 0 || strings.TrimSpace(lines[index-1].text) == "" + for _, char := range line { + if char != '_' { + return false + } + } + return true } func hasReplyHeaderBlock(lines []replyTextLine, start int) bool { @@ -207,7 +209,7 @@ func elideQuotedReplyHTML(body string) (string, bool) { return body, false } candidate := terminalHTMLQuote(bodyNode) - if candidate == nil || !htmlQuoteHasExplicitClose(body, candidate) { + if candidate == nil || !htmlHasExplicitBalance(body) { return body, false } @@ -345,7 +347,7 @@ func isHTMLQuoteContainer(node *xhtml.Node) bool { } id := strings.ToLower(htmlAttribute(node, "id")) switch id { - case "divrplyfwdmsg", "olk_src_body_section", "zmail_extra": + case "olk_src_body_section", "zmail_extra": return true } if isZimbraDivider(node) { @@ -440,121 +442,37 @@ func hasHTMLClass(classes, want string) bool { return false } -func htmlQuoteHasExplicitClose(body string, candidate *xhtml.Node) bool { - type openTag struct { - name string - quoteIndex int - } - - targetIndex := htmlQuoteIndex(candidate) - if targetIndex < 0 { - return false - } +func htmlHasExplicitBalance(body string) bool { tokenizer := xhtml.NewTokenizer(strings.NewReader(body)) - open := make([]openTag, 0, 8) - quoteClosed := make([]bool, 0, 2) + open := make([]string, 0, 8) for { - tokenType := tokenizer.Next() - switch tokenType { + switch tokenizer.Next() { case xhtml.ErrorToken: - return targetIndex < len(quoteClosed) && quoteClosed[targetIndex] + return len(open) == 0 case xhtml.StartTagToken: token := tokenizer.Token() - quoteIndex := -1 - if isHTMLQuoteToken(token) { - quoteIndex = len(quoteClosed) - quoteClosed = append(quoteClosed, isHTMLQuoteVoidToken(token)) + if !isHTMLVoidElement(token.Data) { + open = append(open, token.Data) } - open = append(open, openTag{name: token.Data, quoteIndex: quoteIndex}) case xhtml.SelfClosingTagToken: - token := tokenizer.Token() - quoteIndex := -1 - if isHTMLQuoteToken(token) { - quoteIndex = len(quoteClosed) - quoteClosed = append(quoteClosed, true) - } - open = append(open, openTag{name: token.Data, quoteIndex: quoteIndex}) case xhtml.EndTagToken: token := tokenizer.Token() - for index := len(open) - 1; index >= 0; index-- { - if strings.EqualFold(open[index].name, token.Data) { - if open[index].quoteIndex >= 0 { - quoteClosed[open[index].quoteIndex] = true - } - open = open[:index] - break - } + if len(open) == 0 || !strings.EqualFold(open[len(open)-1], token.Data) { + return false } + open = open[:len(open)-1] case xhtml.TextToken, xhtml.CommentToken, xhtml.DoctypeToken: } } } -func htmlQuoteIndex(candidate *xhtml.Node) int { - root := candidate - for root.Parent != nil { - root = root.Parent - } - - index := -1 - count := 0 - var visit func(*xhtml.Node) - visit = func(node *xhtml.Node) { - if isHTMLQuoteContainer(node) { - if node == candidate { - index = count - } - count++ - } - for child := node.FirstChild; child != nil; child = child.NextSibling { - visit(child) - } - } - visit(root) - return index -} - -func isHTMLQuoteToken(token xhtml.Token) bool { - class := "" - id := "" - typeAttr := "" - for _, attr := range token.Attr { - switch strings.ToLower(attr.Key) { - case "class": - class = attr.Val - case "id": - id = strings.ToLower(attr.Val) - case "type": - typeAttr = attr.Val - } - } - if hasHTMLClass(class, "gmail_quote") { - return true - } - switch id { - case "divrplyfwdmsg", "olk_src_body_section", "zmail_extra": +func isHTMLVoidElement(name string) bool { + switch strings.ToLower(name) { + case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr": return true + default: + return false } - if strings.EqualFold(token.Data, "hr") && htmlAttributeToken(token, "data-marker") == "__DIVIDER__" { - return true - } - if strings.EqualFold(token.Data, "div") && strings.Contains(strings.ToLower(htmlAttributeToken(token, "style")), "border-top") { - return true - } - return strings.EqualFold(token.Data, "blockquote") && strings.EqualFold(typeAttr, "cite") -} - -func isHTMLQuoteVoidToken(token xhtml.Token) bool { - return strings.EqualFold(token.Data, "hr") && htmlAttributeToken(token, "data-marker") == "__DIVIDER__" -} - -func htmlAttributeToken(token xhtml.Token, name string) string { - for _, attr := range token.Attr { - if strings.EqualFold(attr.Key, name) { - return attr.Val - } - } - return "" } func isHTMLDocument(body string) bool { diff --git a/mailcmd/quoted_reply_test.go b/mailcmd/quoted_reply_test.go index 7cb5430..ed32234 100644 --- a/mailcmd/quoted_reply_test.go +++ b/mailcmd/quoted_reply_test.go @@ -43,8 +43,13 @@ func TestElideQuotedReplyBody(t *testing.T) { elided: true, }, { - name: "Outlook header block", - body: "Authored reply.\n\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nOlder message", + name: "bare Outlook header collision is preserved", + body: "Authored reply.\n\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nPasted content", + want: "Authored reply.\n\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nPasted content", + }, + { + name: "Outlook divider before header block", + body: "Authored reply.\n\n________________________________\nFrom: [author]\nSent: [date]\nTo: [recipient]\nSubject: [subject]\n\nOlder message", want: "Authored reply.\n\n" + quotedReplyMarkerForTest, elided: true, }, @@ -119,6 +124,13 @@ func TestElideQuotedReplyBody(t *testing.T) { want: `

Authored reply.

` + quotedReplyMarkerForTest, elided: true, }, + { + name: "balanced HTML fragment with void element", + body: `

Authored reply.

Older message

`, + isHTML: true, + want: `

Authored reply.

` + quotedReplyMarkerForTest, + elided: true, + }, { name: "Thunderbird HTML quote", body: `

Authored reply.

Older message
`, @@ -134,11 +146,10 @@ func TestElideQuotedReplyBody(t *testing.T) { elided: true, }, { - name: "Outlook HTML quote", - body: `
Authored reply.
From: [author]
Subject: [subject]
`, + name: "ambiguous Outlook reply-forward wrapper is preserved byte-for-byte", + body: `
Authored reply.
From: [author]
Sent: [date]
To: [recipient]
Subject: [subject]
Older message
`, isHTML: true, - want: `
Authored reply.
` + quotedReplyMarkerForTest, - elided: true, + want: `
Authored reply.
From: [author]
Sent: [date]
To: [recipient]
Subject: [subject]
Older message
`, }, { name: "Outlook border-style header wrapper", @@ -185,6 +196,12 @@ func TestElideQuotedReplyBody(t *testing.T) { isHTML: true, want: `

Authored reply.

Older message`, }, + { + name: "malformed enclosing HTML is preserved", + body: `

Authored reply.

Older message

`, + isHTML: true, + want: `

Authored reply.

Older message

`, + }, } for _, tt := range tests {