项目文件夹

文件
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

600 行
21 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"math/rand"
"net/url"
"regexp"
"strings"
"time"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
)
// ---- HTML quote block builders (Lark adit-html-block structure) ---------------
//
// These helpers mirror the structure used by Lark's mail composer:
// - Reply/Reply-all: adit-html-block--collapsed (collapsible quoted block)
// - Forward: adit-html-block--header (always-expanded header block)
// ---- CSS class / style constants -------------------------------------------
const (
// quoteIDChars is the character set for generated quote element IDs.
quoteIDChars = "abcdefghijklmnopqrstuvwxyz0123456789"
// quoteBlockBorderStyle is the shared border style for all adit-html-block wrappers.
quoteBlockBorderStyle = "border-left: none; padding-left: 0px;"
// metaBlockStyle is the shared style for quote meta info blocks (From/Date/Subject/To/Cc).
metaBlockStyle = "padding: 12px; background: rgb(245, 246, 247); color: rgb(31, 35, 41); border-radius: 4px; margin-bottom: 12px;"
// replyMetaMargin is the margin-top applied to reply/reply-all meta blocks.
replyMetaMargin = "margin-top: 24px;"
// forwardMetaMargin is the margin-top applied to forward meta blocks (closer to separator).
forwardMetaMargin = "margin-top: 2px;"
// separatorStyle is the style for the forward separator line.
separatorStyle = "color: rgb(100, 106, 115); margin-top: 24px; margin-bottom: 8px;"
// bodyDivStyle is applied to the user-authored body <div> in HTML emails.
bodyDivStyle = "word-break:break-word;line-height:1.6;font-size:14px;color:rgb(0,0,0);"
// addressAnchorStyle is the inline style for mailto hyperlinks.
addressAnchorStyle = "overflow-wrap: break-word; color: inherit; text-decoration: none; white-space: pre-wrap; hyphens: none; word-break: break-word; cursor: pointer;"
)
// ---- HTML format-string templates ------------------------------------------
// All structural HTML is kept here so functions contain only logic, not markup.
const (
// addressAnchorFmt renders a mailto <a> element: (escapedAddr, escapedAddr, escapedAddr).
addressAnchorFmt = `<a class="quote-head-meta-mailto" data-mailto="mailto:%s" href="mailto:%s" style="` + addressAnchorStyle + `">%s</a>`
// metaRowFmt renders one labeled metadata row (label, content).
metaRowFmt = `<div class="lme-line-signal"><span style="">%s: %s</span></div>`
// replyMetaWrapperFmt wraps meta rows for reply/reply-all (margin, style, inner).
replyMetaWrapperFmt = `<div class="adit-html-block__attr history-quote-meta-wrapper history-quote-gap-tag" style="%s %s"><div style="word-break: break-word;">%s</div></div>`
// forwardMetaWrapperFmt wraps meta rows for forward (id, margin, style, inner).
forwardMetaWrapperFmt = `<div id="%s" class="adit-html-block__header history-quote-meta-after-forward-title history-quote-meta-wrapper" style="%s %s"><div style="word-break: break-word;">%s</div></div>`
// separatorDivFmt renders the forward separator line (style, text).
separatorDivFmt = `<div class="history-quote-forward-title lme-line-signal history-quote-gap-tag" style="%s">%s</div>`
// plainTextBodyFmt wraps a plain-text quoted body for use inside HTML emails (escapedText).
plainTextBodyFmt = `<pre style="white-space:pre-wrap">%s</pre>`
)
// ---- Quote wrapper formats (var, not const, because they reference draftpkg.QuoteWrapperClass) ---
var (
// replyQuoteHTMLFmt is the outer collapsed-block structure for reply (style, prefix, body).
replyQuoteHTMLFmt = `<div class="` + draftpkg.QuoteWrapperClass + `"><div data-html-block="quote" data-mail-html-ignore="">` +
`<div class="adit-html-block adit-html-block--collapsed" style="%s">` +
`<div><div>%s%s</div></div>` +
`</div></div></div>`
// forwardQuoteHTMLFmt is the outer header-block structure for forward (outerID, style, innerID, sep, meta, body).
forwardQuoteHTMLFmt = `<div id="%s" class="` + draftpkg.QuoteWrapperClass + `"><div data-html-block="quote" data-mail-html-ignore="">` +
`<div class="adit-html-block adit-html-block--header" style="%s">` +
`<div id="%s">%s%s%s</div>` +
`</div></div></div>`
)
// genID returns an element id of the form "<prefix>XXXXXX"
// where XXXXXX is 6 random alphanumeric characters.
func genID(prefix string) string {
b := make([]byte, 6)
for i := range b {
b[i] = quoteIDChars[rand.Intn(len(quoteIDChars))]
}
return prefix + string(b)
}
// detectSubjectLang returns "zh" if the subject contains CJK characters, "en" otherwise.
func detectSubjectLang(subject string) string {
for _, r := range subject {
if (r >= '\u4e00' && r <= '\u9fff') || // CJK Unified Ideographs
(r >= '\u3400' && r <= '\u4dbf') || // CJK Extension A
(r >= '\uf900' && r <= '\ufaff') || // CJK Compatibility Ideographs
(r >= '\u3040' && r <= '\u30ff') { // Hiragana + Katakana
return "zh"
}
}
return "en"
}
type quoteMetaLabelSet struct {
From string
Date string
Subject string
To string
Cc string
Separator string // plaintext forward separator line
Colon string // "" for Chinese, ": " for English (used in plaintext)
ReplyPrefix string // subject prefix for reply, e.g. "Re: " or "回复:"
ForwardPrefix string // subject prefix for forward, e.g. "Fwd: " or "转发:"
}
// quoteMetaLabels returns the label set appropriate for the given subject language.
func quoteMetaLabels(subject string) quoteMetaLabelSet {
if detectSubjectLang(subject) == "zh" {
return quoteMetaLabelSet{
From: "发件人",
Date: "时间",
Subject: "主题",
To: "收件人",
Cc: "抄送",
Separator: "--------- 转发消息 ---------",
Colon: "",
ReplyPrefix: "回复:",
ForwardPrefix: "转发:",
}
}
return quoteMetaLabelSet{
From: "From",
Date: "Date",
Subject: "Subject",
To: "To",
Cc: "Cc",
Separator: "---------- Forwarded message ---------",
Colon: ": ",
ReplyPrefix: "Re: ",
ForwardPrefix: "Fwd: ",
}
}
// buildAddressAnchor renders an email address as a mailto hyperlink (<a> only).
// The href uses URL encoding (RFC 6068) to prevent mailto: parameter injection;
// the display text and data attribute use HTML entity encoding.
func buildAddressAnchor(addr string) string {
urlEncoded := url.PathEscape(addr)
displayText := htmlEscape(addr)
return fmt.Sprintf(addressAnchorFmt, displayText, urlEncoded, displayText)
}
// buildAddressHTML renders a single address.
//
// With name: "Name"&lt;<a>addr</a>&gt;
// Without name: &lt;<a>addr</a>&gt;
func buildAddressHTML(name, addr string) string {
anchor := buildAddressAnchor(addr)
if name != "" {
return fmt.Sprintf(`"%s"&lt;%s&gt;`, htmlEscape(name), anchor)
}
return `&lt;` + anchor + `&gt;`
}
// buildAddressPairListHTML renders a list of name+email pairs.
// Each address is wrapped in its own <span>.
func buildAddressPairListHTML(pairs []mailAddressPair) string {
if len(pairs) == 0 {
return ""
}
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, `<span>`+buildAddressHTML(p.Name, p.Email)+`</span>`)
}
return strings.Join(items, ", ")
}
// buildAddressListHTML renders a list of email-only addresses.
// Kept as a fallback for contexts where display names are unavailable.
func buildAddressListHTML(addrs []string) string {
if len(addrs) == 0 {
return ""
}
items := make([]string, 0, len(addrs))
for _, addr := range addrs {
items = append(items, `<span>`+buildAddressHTML("", addr)+`</span>`)
}
return strings.Join(items, ", ")
}
// buildMetaRow renders a single labeled metadata row.
// The entire content (label + value) is wrapped in <span style="">.
func buildMetaRow(label, content string) string {
return fmt.Sprintf(metaRowFmt, htmlEscape(label), content)
}
// buildReplyMetaWrapper wraps meta rows for reply/reply-all quote blocks.
func buildReplyMetaWrapper(inner string) string {
return fmt.Sprintf(replyMetaWrapperFmt, replyMetaMargin, metaBlockStyle, inner)
}
// buildForwardMetaWrapper wraps meta rows for forward quote blocks.
// Uses adit-html-block__header class and margin-top: 2px (not 24px).
func buildForwardMetaWrapper(inner string) string {
return fmt.Sprintf(forwardMetaWrapperFmt, genID("lark-mail-meta-cli"), forwardMetaMargin, metaBlockStyle, inner)
}
// buildMetaRows assembles the inner HTML rows (From/Date/Subject/To/Cc) shared by
// both reply and forward quote blocks.
func buildMetaRows(orig *originalMessage) string {
labels := quoteMetaLabels(orig.subject)
var rows strings.Builder
rows.WriteString(buildMetaRow(labels.From, buildAddressHTML(orig.headFromName, orig.headFrom)))
if orig.headDate != "" {
rows.WriteString(buildMetaRow(labels.Date, htmlEscape(orig.headDate)))
}
if orig.subject != "" {
rows.WriteString(buildMetaRow(labels.Subject, htmlEscape(orig.subject)))
}
if len(orig.toAddressesFull) > 0 {
rows.WriteString(buildMetaRow(labels.To, buildAddressPairListHTML(orig.toAddressesFull)))
} else if len(orig.toAddresses) > 0 {
rows.WriteString(buildMetaRow(labels.To, buildAddressListHTML(orig.toAddresses)))
}
if len(orig.ccAddressesFull) > 0 {
rows.WriteString(buildMetaRow(labels.Cc, buildAddressPairListHTML(orig.ccAddressesFull)))
} else if len(orig.ccAddresses) > 0 {
rows.WriteString(buildMetaRow(labels.Cc, buildAddressListHTML(orig.ccAddresses)))
}
return rows.String()
}
// buildReplyPrefixHTML constructs the metadata prefix block for reply emails.
// Rendered as adit-html-block__attr with From/Date/Subject/To/Cc fields.
func buildReplyPrefixHTML(orig *originalMessage) string {
return buildReplyMetaWrapper(buildMetaRows(orig))
}
// buildBodyDiv wraps the user-authored body content in a styled <div>.
// 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", "<br>")
}
return fmt.Sprintf(`<div style="%s">%s</div>`, 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 = `<div>` + bodyHTML + `</div>`
}
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 = `<div>` + bodyHTML + `</div>`
}
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 "<brief.pdf>" 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
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, "&amp;", "&")
result = strings.ReplaceAll(result, "&lt;", "<")
result = strings.ReplaceAll(result, "&gt;", ">")
result = strings.ReplaceAll(result, "&quot;", `"`)
result = strings.ReplaceAll(result, "&#39;", "'")
result = strings.ReplaceAll(result, "&nbsp;", " ")
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" <email>
// Without name: <email>
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()
}