Files
libredesk/internal/stringutil/emailquote.go
T
Abhinav Raut 72c3f29830 fix hyperlinks getting lost across AI features
AI grammar fix rewrote the draft as plain text, so links in the reply box were dropped. It now sends the editor HTML and asks the model to keep tags. Conversation transcripts, the AI agent history and copilot context also stripped link URLs when converting HTML to text, so the model never saw them. They now keep links as "text ( url )".
2026-07-31 16:08:19 +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(HTML2TextWithLinks(htmlContent))
}
pruneQuotedNodes(doc)
var b strings.Builder
if err := html.Render(&b, doc); err != nil {
return TrimPlainTextQuotes(HTML2TextWithLinks(htmlContent))
}
return TrimPlainTextQuotes(HTML2TextWithLinks(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
}