Files
libredesk/internal/stringutil/emailquote.go
T
Abhinav Raut 2e89586a11 send links to the LLM as markdown so drafts keep real hyperlinks
Transcripts flattened links to "text ( url )", so the model copied that shape and drafts came back with the URL in brackets instead of a link. HTML to text for the LLM now writes "[text](url)", which Markdown2HTML turns back into an anchor.
2026-07-31 18:02:22 +05:30

120 lines
3.0 KiB
Go

package stringutil
import (
"regexp"
"strings"
"golang.org/x/net/html"
)
// Marker sets mirror the frontend's .hide-quoted-text selectors in
// frontend/shared-ui/assets/styles/main.scss - keep the two in sync.
var (
// Outlook/Hotmail markers: the quoted history follows as trailing siblings, so the marker AND
// everything after it at the same level is quote.
quoteMarkerIDs = map[string]bool{"divRplyFwdMsg": true, "appendonsend": true, "OLK_SRC_BODY_SECTION": true}
quoteMarkerClasses = []string{"OutlookMessageHeader"}
// Containers that wrap the whole quote.
quoteContainerClasses = []string{"yahoo_quoted", "gmail_quote_container", "protonmail_quote"}
attributionRegex = regexp.MustCompile(`(?i)^on\b.{0,200}\bwrote:$`)
originalMessageRegex = regexp.MustCompile(`(?i)^-{2,}\s*original message\s*-{2,}$`)
)
// HTML2TextNoQuotes converts email HTML to plain text with quoted reply chains removed, returning "" when the message was quote-only.
func HTML2TextNoQuotes(htmlContent string) string {
doc, err := html.Parse(strings.NewReader(htmlContent))
if err != nil {
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent))
}
pruneQuotedNodes(doc)
var b strings.Builder
if err := html.Render(&b, doc); err != nil {
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(htmlContent))
}
return TrimPlainTextQuotes(HTML2TextMarkdownLinks(b.String()))
}
// TrimPlainTextQuotes strips a trailing quoted-reply block (">" lines, "On ... wrote:" and "Original Message" markers) from plain text.
func TrimPlainTextQuotes(text string) string {
lines := strings.Split(text, "\n")
end := len(lines)
for end > 0 {
l := strings.TrimSpace(lines[end-1])
if l == "" || strings.HasPrefix(l, ">") || attributionRegex.MatchString(l) || originalMessageRegex.MatchString(l) {
end--
continue
}
break
}
return strings.TrimSpace(strings.Join(lines[:end], "\n"))
}
func pruneQuotedNodes(n *html.Node) {
var next *html.Node
for child := n.FirstChild; child != nil; child = next {
next = child.NextSibling
if isQuoteNode(child) {
n.RemoveChild(child)
continue
}
if isQuoteMarkerNode(child) {
for sib := child; sib != nil; {
after := sib.NextSibling
n.RemoveChild(sib)
sib = after
}
return
}
pruneQuotedNodes(child)
}
}
func isQuoteNode(n *html.Node) bool {
if n.Type != html.ElementNode {
return false
}
if n.Data == "blockquote" {
return true
}
for _, c := range quoteContainerClasses {
if hasClass(n, c) {
return true
}
}
return false
}
func isQuoteMarkerNode(n *html.Node) bool {
if n.Type != html.ElementNode {
return false
}
if quoteMarkerIDs[attrValue(n, "id")] {
return true
}
for _, c := range quoteMarkerClasses {
if hasClass(n, c) {
return true
}
}
return false
}
func attrValue(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
func hasClass(n *html.Node, want string) bool {
for _, class := range strings.Fields(attrValue(n, "class")) {
if class == want {
return true
}
}
return false
}