Files
larksuite-cli/shortcuts/mail/draft/htmltext.go
xzcong0820 cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00

149 lines
4.1 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"bytes"
"strings"
xhtml "golang.org/x/net/html"
)
// plainTextFromHTML produces a conservative plain-text fallback from HTML.
// It is used only for shortcut ergonomics when a draft effectively has a
// generated text/plain fallback paired with the authored text/html body.
//
// The implementation uses an explicit stack instead of recursion so that
// deeply nested HTML cannot cause a goroutine stack overflow.
func plainTextFromHTML(raw string) string {
doc, err := xhtml.Parse(strings.NewReader(raw))
if err != nil {
return strings.TrimSpace(raw)
}
var buf bytes.Buffer
type pendingEntry struct {
node *xhtml.Node // the element whose children we are iterating
child *xhtml.Node // next child to visit (nil = done)
}
stack := []pendingEntry{{node: doc, child: doc.FirstChild}}
for len(stack) > 0 {
top := &stack[len(stack)-1]
// all children processed — emit post-children block boundary, then pop
if top.child == nil {
if isHTMLBlockBoundary(top.node) && buf.Len() > 0 && bufLastByte(&buf) != '\n' {
buf.WriteByte('\n')
}
stack = stack[:len(stack)-1]
continue
}
n := top.child
top.child = top.child.NextSibling
// skip non-text tags and their entire subtree
if isHTMLNonTextTag(n) {
continue
}
// emit text content
if n.Type == xhtml.TextNode {
text := collapseHTMLWhitespace(n.Data)
if text != "" {
if last := bufLastByte(&buf); last != 0 && last != '\n' && last != ' ' {
buf.WriteByte(' ')
}
buf.WriteString(text)
}
}
// pre-children block boundary newline
if isHTMLBlockBoundary(n) && buf.Len() > 0 && bufLastByte(&buf) != '\n' {
buf.WriteByte('\n')
}
// push this node so its children get processed next
if n.FirstChild != nil {
stack = append(stack, pendingEntry{node: n, child: n.FirstChild})
}
}
lines := strings.Split(buf.String(), "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func bufLastByte(buf *bytes.Buffer) byte {
if buf.Len() == 0 {
return 0
}
return buf.Bytes()[buf.Len()-1]
}
// isHTMLNonTextTag reports whether n is an element whose text content
// should never appear in a plain-text conversion (scripts, styles, etc.).
func isHTMLNonTextTag(n *xhtml.Node) bool {
if n == nil || n.Type != xhtml.ElementNode {
return false
}
switch strings.ToLower(n.Data) {
case "head", "meta", "script", "noscript", "style", "link", "title":
return true
default:
return false
}
}
func collapseHTMLWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func isHTMLBlockBoundary(n *xhtml.Node) bool {
if n == nil || n.Type != xhtml.ElementNode {
return false
}
switch strings.ToLower(n.Data) {
case "address", "article", "aside", "blockquote", "br", "dd", "div", "dl", "dt",
"figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6",
"header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "table", "tr", "ul":
return true
default:
return false
}
}
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
// mail package can render an HTML signature as a plain-text fallback when a
// message body is sent in plain-text mode. The conversion logic is unchanged.
func PlainTextFromHTML(raw string) string {
return plainTextFromHTML(raw)
}
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
// This is intentionally heuristic: it exists to reject obvious plain-text
// input when a draft's authored body is text/html.
func bodyLooksLikeHTML(raw string) bool {
lower := strings.ToLower(raw)
return strings.Contains(lower, "<html") ||
strings.Contains(lower, "<body") ||
strings.Contains(lower, "<div") ||
strings.Contains(lower, "<p") ||
strings.Contains(lower, "<br") ||
strings.Contains(lower, "<span") ||
strings.Contains(lower, "<section") ||
strings.Contains(lower, "<article") ||
strings.Contains(lower, "<table") ||
strings.Contains(lower, "<a ")
}