.
// If isHTML is true, content is embedded as-is; otherwise it is HTML-escaped.
func buildBodyDiv(content string, isHTML bool) string {
if content == "" {
return ""
}
var inner string
if isHTML {
inner = content
} else {
inner = strings.ReplaceAll(htmlEscape(content), "\n", "
")
}
return fmt.Sprintf(`
%s
`, bodyDivStyle, inner)
}
// buildReplyQuoteHTML builds the collapsed quote block for reply/reply-all emails.
// Returns empty string when there is no content to quote.
func buildReplyQuoteHTML(orig *originalMessage) string {
if orig.bodyRaw == "" && orig.headFrom == "" {
return ""
}
prefixHTML := buildReplyPrefixHTML(orig)
bodyHTML := orig.bodyRaw
if bodyHTML != "" && !bodyIsHTML(bodyHTML) {
bodyHTML = fmt.Sprintf(plainTextBodyFmt, htmlEscape(bodyHTML))
}
var bodyPart string
if bodyHTML != "" {
bodyPart = `
` + bodyHTML + `
`
}
return fmt.Sprintf(replyQuoteHTMLFmt, quoteBlockBorderStyle, prefixHTML, bodyPart)
}
// buildForwardSeparatorHTML builds the separator line div placed before the meta block.
func buildForwardSeparatorHTML(orig *originalMessage) string {
labels := quoteMetaLabels(orig.subject)
return fmt.Sprintf(separatorDivFmt, separatorStyle, htmlEscape(labels.Separator))
}
// buildForwardMetaHTML constructs the meta block (From/Date/Subject/To/Cc) for forwarded emails.
func buildForwardMetaHTML(orig *originalMessage) string {
return buildForwardMetaWrapper(buildMetaRows(orig))
}
// buildForwardQuoteHTML builds the header quote block for forwarded emails.
// The separator div is placed outside the meta wrapper, matching the Lark client structure.
func buildForwardQuoteHTML(orig *originalMessage) string {
separatorHTML := buildForwardSeparatorHTML(orig)
metaHTML := buildForwardMetaHTML(orig)
bodyHTML := orig.bodyRaw
if bodyHTML != "" && !bodyIsHTML(bodyHTML) {
bodyHTML = fmt.Sprintf(plainTextBodyFmt, htmlEscape(bodyHTML))
}
var bodyPart string
if bodyHTML != "" {
bodyPart = `
` + bodyHTML + `
`
}
return fmt.Sprintf(forwardQuoteHTMLFmt, genID("lark-mail-quote-cli"), quoteBlockBorderStyle, genID("lark-mail-quote-cli"), separatorHTML, metaHTML, bodyPart)
}
// zhWeekdays maps time.Weekday to Chinese weekday names in Lark's format.
var zhWeekdays = [7]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}
// formatMailDate formats a Unix millisecond timestamp for use in quote blocks.
// lang is the detected language ("zh" or "en", from detectSubjectLang).
// The time is rendered in the system local timezone:
// - "zh": "2006年1月2日 (周X) 15:04" (matches Feishu native client)
// - "en": "Mon, 02 Jan 2006 15:04 MST"
func formatMailDate(ms int64, lang string) string {
t := time.UnixMilli(ms).Local()
if lang == "zh" {
return fmt.Sprintf("%s (%s) %s",
t.Format("2006年1月2日"),
zhWeekdays[t.Weekday()],
t.Format("15:04"))
}
return t.Format("Mon, 02 Jan 2006 15:04 MST")
}
// htmlTagRe matches known HTML tags followed by a tag-terminating character
// (whitespace, >, or />). This avoids false positives like "
" or
// "price < 100". The tag list follows the Chromium/WHATWG MIME sniffing approach
// with additional common tags for email content.
var htmlTagRe = regexp.MustCompile(
`(?i)<(?:` +
`!doctype\s+html|!--|` +
`html|head|body|div|p|br|span|a|b|i|em|strong|` +
`h[1-6]|ul|ol|li|table|tr|td|th|img|font|style|script|` +
`iframe|title|form|input|select|textarea|button|label|` +
`blockquote|pre|code|hr|section|article|header|footer|nav|main` +
`)[\s/>]`)
// bodyIsHTML reports whether s appears to contain HTML markup.
func bodyIsHTML(s string) bool {
if !strings.Contains(s, "<") {
return false
}
return htmlTagRe.MatchString(s)
}
// htmlEscape escapes the five standard XML/HTML special characters.
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "<")
s = strings.ReplaceAll(s, ">", ">")
s = strings.ReplaceAll(s, `"`, """)
s = strings.ReplaceAll(s, "'", "'")
return s
}
// stripHTMLForQuote converts an HTML body to plain text suitable for quoted replies.
func stripHTMLForQuote(s string) string {
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
if s[i] != '<' {
b.WriteByte(s[i])
i++
continue
}
end := strings.IndexByte(s[i:], '>')
if end < 0 {
b.WriteString(s[i:])
break
}
tag := strings.ToLower(strings.TrimSpace(s[i+1 : i+end]))
i += end + 1
fields := strings.Fields(tag)
if len(fields) > 0 && (fields[0] == "script" || fields[0] == "style") {
closeTag := "" + fields[0] + ">"
if idx := strings.Index(strings.ToLower(s[i:]), closeTag); idx >= 0 {
i += idx + len(closeTag)
}
continue
}
switch {
case tag == "br" || tag == "br/" || tag == "br /":
b.WriteByte('\n')
case strings.HasPrefix(tag, "/p") || strings.HasPrefix(tag, "/div") ||
strings.HasPrefix(tag, "/tr") || tag == "/h1" || tag == "/h2" ||
tag == "/h3" || tag == "/h4" || tag == "/h5" || tag == "/h6" ||
tag == "/li":
b.WriteByte('\n')
}
}
result := b.String()
result = strings.ReplaceAll(result, "&", "&")
result = strings.ReplaceAll(result, "<", "<")
result = strings.ReplaceAll(result, ">", ">")
result = strings.ReplaceAll(result, """, `"`)
result = strings.ReplaceAll(result, "'", "'")
result = strings.ReplaceAll(result, " ", " ")
for strings.Contains(result, "\n\n\n") {
result = strings.ReplaceAll(result, "\n\n\n", "\n\n")
}
return strings.TrimSpace(result)
}
// stripLargeAttachmentCard removes the large attachment HTML card from
// orig.bodyRaw and returns the extracted card HTML (empty if none found).
// Forward uses the returned card to re-insert it into the body area;
// reply/reply-all discards the return value so the card doesn't appear
// in the quoted block.
func stripLargeAttachmentCard(orig *originalMessage) string {
if !draftpkg.HTMLContainsLargeAttachment(orig.bodyRaw) {
return ""
}
bodyWithout, card, trailing := draftpkg.SplitAtLargeAttachment(orig.bodyRaw)
orig.bodyRaw = bodyWithout + trailing
return card
}
// quoteForReply formats the original message body as a quoted block.
// HTML replies use the Lark adit-html-block--collapsed structure;
// plain-text replies use the classic "> " prefix format with meta header.
func quoteForReply(orig *originalMessage, html bool) string {
if html {
return buildReplyQuoteHTML(orig)
}
// Plain-text path: meta header + "> " prefixed body lines.
if orig.bodyRaw == "" && orig.headFrom == "" {
return ""
}
var sb strings.Builder
sb.WriteString("\n\n")
// Build meta header lines (From/Subject/Date/To/Cc), each prefixed with "> "
sb.WriteString(buildReplyMetaPlainText(orig))
// Blank line between meta and body
sb.WriteString(">\n")
text := stripHTMLForQuote(orig.bodyRaw)
for _, line := range strings.Split(text, "\n") {
sb.WriteString("> ")
sb.WriteString(line)
sb.WriteString("\n")
}
return sb.String()
}
// buildReplyMetaPlainText builds the meta header block for plain-text replies.
// Each line is prefixed with "> " and follows the same label/format logic as HTML replies.
func buildReplyMetaPlainText(orig *originalMessage) string {
return buildPlainTextMetaRows(orig, "> ")
}
// buildPlainTextMetaRows builds the meta rows for plain-text output.
// linePrefix is prepended to each line (e.g., "> " for replies, "" for forwards).
// Field order matches buildMetaRows: From -> Date -> Subject -> To -> Cc.
func buildPlainTextMetaRows(orig *originalMessage, linePrefix string) string {
labels := quoteMetaLabels(orig.subject)
var sb strings.Builder
// From
if orig.headFrom != "" {
from := buildPlainTextAddress(orig.headFromName, orig.headFrom)
sb.WriteString(linePrefix)
sb.WriteString(labels.From)
sb.WriteString(labels.Colon)
sb.WriteString(from)
sb.WriteString("\n")
}
// Date
if orig.headDate != "" {
sb.WriteString(linePrefix)
sb.WriteString(labels.Date)
sb.WriteString(labels.Colon)
sb.WriteString(orig.headDate)
sb.WriteString("\n")
}
// Subject
if orig.subject != "" {
sb.WriteString(linePrefix)
sb.WriteString(labels.Subject)
sb.WriteString(labels.Colon)
sb.WriteString(orig.subject)
sb.WriteString("\n")
}
// To
if len(orig.toAddressesFull) > 0 {
sb.WriteString(linePrefix)
sb.WriteString(labels.To)
sb.WriteString(labels.Colon)
sb.WriteString(buildPlainTextAddressList(orig.toAddressesFull))
sb.WriteString("\n")
} else if len(orig.toAddresses) > 0 {
sb.WriteString(linePrefix)
sb.WriteString(labels.To)
sb.WriteString(labels.Colon)
sb.WriteString(strings.Join(orig.toAddresses, ", "))
sb.WriteString("\n")
}
// Cc
if len(orig.ccAddressesFull) > 0 {
sb.WriteString(linePrefix)
sb.WriteString(labels.Cc)
sb.WriteString(labels.Colon)
sb.WriteString(buildPlainTextAddressList(orig.ccAddressesFull))
sb.WriteString("\n")
} else if len(orig.ccAddresses) > 0 {
sb.WriteString(linePrefix)
sb.WriteString(labels.Cc)
sb.WriteString(labels.Colon)
sb.WriteString(strings.Join(orig.ccAddresses, ", "))
sb.WriteString("\n")
}
return sb.String()
}
// buildPlainTextAddress formats a single address for plain-text output.
// With name: "Name"
// Without name:
func buildPlainTextAddress(name, email string) string {
if name != "" {
return fmt.Sprintf(`"%s" <%s>`, name, email)
}
return fmt.Sprintf("<%s>", email)
}
// buildPlainTextAddressList formats a list of addresses for plain-text output.
func buildPlainTextAddressList(pairs []mailAddressPair) string {
if len(pairs) == 0 {
return ""
}
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, buildPlainTextAddress(p.Name, p.Email))
}
return strings.Join(items, ", ")
}
// removeDuplicateSubjectPrefix strips leading reply/forward prefixes (case-insensitive)
// to prevent accumulation on chained replies/forwards (e.g. "Re: Re: Re: topic").
// Handles both ASCII prefixes (Re:, Fwd:, Fw:) and Chinese prefixes (回复:, 转发:).
func removeDuplicateSubjectPrefix(subject string) string {
for {
trimmed := strings.TrimSpace(subject)
lower := strings.ToLower(trimmed)
switch {
case strings.HasPrefix(lower, "re:"):
subject = strings.TrimSpace(trimmed[3:])
case strings.HasPrefix(lower, "fwd:"):
subject = strings.TrimSpace(trimmed[4:])
case strings.HasPrefix(lower, "fw:"):
subject = strings.TrimSpace(trimmed[3:])
case strings.HasPrefix(trimmed, "回复:"):
subject = strings.TrimSpace(trimmed[len("回复:"):])
case strings.HasPrefix(trimmed, "转发:"):
subject = strings.TrimSpace(trimmed[len("转发:"):])
default:
return trimmed
}
}
}
// buildReplySubject prepends the language-appropriate reply prefix once,
// stripping any existing prefixes to prevent accumulation on chained replies.
func buildReplySubject(original string) string {
return quoteMetaLabels(original).ReplyPrefix + removeDuplicateSubjectPrefix(original)
}
// buildForwardSubject prepends the language-appropriate forward prefix once,
// stripping any existing prefixes to prevent accumulation on chained forwards.
func buildForwardSubject(original string) string {
return quoteMetaLabels(original).ForwardPrefix + removeDuplicateSubjectPrefix(original)
}
// buildForwardedMessage formats the original message as a plain-text forwarding block.
func buildForwardedMessage(orig *originalMessage, body string) string {
labels := quoteMetaLabels(orig.subject)
var sb strings.Builder
if body != "" {
sb.WriteString(body)
sb.WriteString("\n\n")
}
sb.WriteString(labels.Separator + "\n")
sb.WriteString(buildPlainTextMetaRows(orig, ""))
sb.WriteString("\n")
if orig.bodyRaw != "" {
sb.WriteString(stripHTMLForQuote(orig.bodyRaw))
}
return sb.String()
}