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..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"
)
@@ -15,6 +16,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 +29,28 @@ 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)
})
}
+
+func TestPrintMessageHeader_HTMLQuoteModes(t *testing.T) {
+ body := `
Authored reply.
`
+ 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
new file mode 100644
index 0000000..6bad453
--- /dev/null
+++ b/mailcmd/quoted_reply.go
@@ -0,0 +1,482 @@
+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) || isOutlookReplyDivider(trimmed)) && hasReplyHeaderBlock(lines, i+1) {
+ 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 isOutlookReplyDivider(line string) bool {
+ if len(line) < 8 {
+ return false
+ }
+ for _, char := range line {
+ if char != '_' {
+ return false
+ }
+ }
+ return true
+}
+
+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 || !htmlHasExplicitBalance(body) {
+ 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 "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 htmlHasExplicitBalance(body string) bool {
+ tokenizer := xhtml.NewTokenizer(strings.NewReader(body))
+ open := make([]string, 0, 8)
+ for {
+ switch tokenizer.Next() {
+ case xhtml.ErrorToken:
+ return len(open) == 0
+ case xhtml.StartTagToken:
+ token := tokenizer.Token()
+ if !isHTMLVoidElement(token.Data) {
+ open = append(open, token.Data)
+ }
+ case xhtml.SelfClosingTagToken:
+ case xhtml.EndTagToken:
+ token := tokenizer.Token()
+ 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 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
+ }
+}
+
+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: "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,
+ },
+ {
+ 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: "balanced HTML fragment with void element",
+ body: `Authored reply.
`,
+ 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: "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.
From: [author]
Sent: [date]
To: [recipient]
Subject: [subject]
Older message
`,
+ },
+ {
+ 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.
`,
+ 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`,
+ },
+ {
+ name: "malformed enclosing HTML is preserved",
+ body: `
Authored reply.
`,
+ isHTML: true,
+ want: `
Authored reply.
`,
+ },
+ }
+
+ 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
}