mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Add a Priority field to DraftProjection populated from the EML header pair X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌 historical drafts), with case-insensitive lookup via the existing headerValue helper and a local mapping table aligned with the backend gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is present (the symmetric read of --set-priority normal=remove_header) the projection emits "unknown" so agents have a stable read-side surface. Append one notes entry to buildDraftEditPatchTemplate documenting the --set-priority flag and the X-Cli-Priority translation contract. The write-side (--set-priority flag, parsePriority helper, translation branch in mail_draft_edit.go, EML header target) is unchanged — already shipped on master. sprint: S4
479 lines
16 KiB
Go
479 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package draft
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// QuoteWrapperClass is the CSS class name used by Lark's mail composer
|
|
// for reply/forward quote blocks. Both +reply and +forward wrap the
|
|
// quoted original message in a <div> with this class.
|
|
// Exported so that mail_quote.go (the generator) and projection.go
|
|
// (the detector) share a single source of truth.
|
|
const QuoteWrapperClass = "history-quote-wrapper"
|
|
|
|
// Well-known anchors for the large attachment HTML card generated by
|
|
// CLI and the desktop client. The HTML structure is:
|
|
//
|
|
// <div id="large-file-area-{timestamp}" ...>
|
|
// <div>Title</div>
|
|
// <div id="large-file-item" ...>
|
|
// ... filename, size, <a data-mail-token="..."> ...
|
|
// </div>
|
|
// <div id="large-file-item" ...> ... </div>
|
|
// </div>
|
|
const (
|
|
LargeFileContainerIDPrefix = "large-file-area-"
|
|
LargeFileItemID = "large-file-item"
|
|
LargeAttachmentTokenAttr = "data-mail-token"
|
|
)
|
|
|
|
// LargeAttachmentIDsHeader is the header name CLI writes when creating
|
|
// or editing a draft. The value is base64-encoded JSON: [{"id":"<token>"}].
|
|
const LargeAttachmentIDsHeader = "X-Lms-Large-Attachment-Ids"
|
|
|
|
// ServerLargeAttachmentHeader is the header name the mail server returns
|
|
// on readback. The value is base64-encoded JSON with richer metadata:
|
|
// [{"file_key":"<token>","file_name":"...","file_size":...}].
|
|
const ServerLargeAttachmentHeader = "X-Lark-Large-Attachment"
|
|
|
|
// quoteWrapperRe matches an actual <div> element whose class attribute
|
|
// contains QuoteWrapperClass. This avoids false positives when the
|
|
// string appears as plain text, inside <pre> blocks, or in
|
|
// HTML-escaped content.
|
|
//
|
|
// Matches:
|
|
// - <div class="history-quote-wrapper"> (reply)
|
|
// - <div id="..." class="history-quote-wrapper"> (forward)
|
|
var quoteWrapperRe = regexp.MustCompile(`<div\s[^>]*class="[^"]*` + QuoteWrapperClass + `[^"]*"`)
|
|
|
|
var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`)
|
|
|
|
// SignatureWrapperClass is the CSS class for the mail signature container.
|
|
const SignatureWrapperClass = "lark-mail-signature"
|
|
|
|
var signatureWrapperRe = regexp.MustCompile(
|
|
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`)
|
|
|
|
// signatureIDRe extracts the id from a signature wrapper div, regardless of
|
|
// whether id appears before or after the class attribute.
|
|
var signatureIDRe = regexp.MustCompile(
|
|
`<div\s[^>]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` +
|
|
`|<div\s[^>]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass)
|
|
|
|
func Project(snapshot *DraftSnapshot) DraftProjection {
|
|
proj := DraftProjection{
|
|
Subject: snapshot.Subject,
|
|
To: append([]Address{}, snapshot.To...),
|
|
Cc: append([]Address{}, snapshot.Cc...),
|
|
Bcc: append([]Address{}, snapshot.Bcc...),
|
|
ReplyTo: append([]Address{}, snapshot.ReplyTo...),
|
|
InReplyTo: snapshot.InReplyTo,
|
|
References: snapshot.References,
|
|
}
|
|
|
|
if part := findPart(snapshot.Body, snapshot.PrimaryTextPartID); part != nil {
|
|
proj.BodyText = string(part.Body)
|
|
}
|
|
if part := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); part != nil {
|
|
html := string(part.Body)
|
|
proj.BodyHTMLSummary = summarizeHTML(html)
|
|
proj.HasQuotedContent = hasQuotedContent(html)
|
|
proj.HasSignature = signatureWrapperRe.MatchString(html)
|
|
if proj.HasSignature {
|
|
if m := signatureIDRe.FindStringSubmatch(html); m != nil {
|
|
// alternation regex: id is in m[1] (class-first) or m[2] (id-first)
|
|
if m[1] != "" {
|
|
proj.SignatureID = m[1]
|
|
} else if len(m) >= 3 {
|
|
proj.SignatureID = m[2]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
parts := flattenParts(snapshot.Body)
|
|
inlineCIDs := make(map[string]bool)
|
|
for _, part := range parts {
|
|
if part == nil || part.IsMultipart() {
|
|
continue
|
|
}
|
|
if part.EncodingProblem {
|
|
name := part.FileName()
|
|
if name == "" {
|
|
name = part.PartID
|
|
}
|
|
proj.Warnings = append(proj.Warnings,
|
|
"part "+name+" has encoding problems; its content may be degraded (e.g. malformed base64, bad charset, or unparseable Content-Type)")
|
|
}
|
|
summary := PartSummary{
|
|
PartID: part.PartID,
|
|
FileName: part.FileName(),
|
|
ContentType: part.MediaType,
|
|
Disposition: part.ContentDisposition,
|
|
CID: part.ContentID,
|
|
}
|
|
switch {
|
|
case strings.EqualFold(part.ContentDisposition, "attachment"):
|
|
proj.AttachmentsSummary = append(proj.AttachmentsSummary, summary)
|
|
case strings.EqualFold(part.ContentDisposition, "inline") || part.ContentID != "":
|
|
proj.InlineSummary = append(proj.InlineSummary, summary)
|
|
if part.ContentID != "" {
|
|
inlineCIDs[strings.ToLower(part.ContentID)] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var htmlBody string
|
|
if part := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); part != nil {
|
|
htmlBody = string(part.Body)
|
|
for _, cid := range extractCIDRefs(htmlBody) {
|
|
if !inlineCIDs[strings.ToLower(cid)] {
|
|
proj.Warnings = append(proj.Warnings, "missing inline MIME part for cid:"+cid)
|
|
}
|
|
}
|
|
}
|
|
|
|
proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody)
|
|
|
|
proj.Priority = parsePriorityFromHeaders(snapshot.Headers)
|
|
|
|
return proj
|
|
}
|
|
|
|
// parsePriorityFromHeaders derives the read-side priority projection from
|
|
// EML headers. It mirrors the write-side helper helpers.go:parsePriority
|
|
// (which translates --set-priority high|normal|low into set_header /
|
|
// remove_header X-Cli-Priority ops). Lookup order is case-insensitive
|
|
// via headerValue:
|
|
// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by
|
|
// mail-data-access headersToPbBodyExtra)
|
|
// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts)
|
|
//
|
|
// When neither header is present (including after the write-side translates
|
|
// --set-priority normal into remove_header X-Cli-Priority), this returns
|
|
// "normal" — absence of a priority header is the standard email convention
|
|
// for normal priority. Agents cannot distinguish "explicitly normal" from
|
|
// "never set" — known limitation.
|
|
func parsePriorityFromHeaders(headers []Header) string {
|
|
if v := headerValue(headers, "X-Cli-Priority"); v != "" {
|
|
return mapPriorityValue(v)
|
|
}
|
|
if v := headerValue(headers, "X-Priority"); v != "" {
|
|
return mapPriorityValue(v)
|
|
}
|
|
return "normal"
|
|
}
|
|
|
|
// mapPriorityValue normalises a raw priority header value to the projection
|
|
// vocabulary {"high","normal","low","unknown"}. The accepted input table is
|
|
// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that
|
|
// CLI read-side projection observes the same set of values the server
|
|
// recognises on write.
|
|
func mapPriorityValue(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "high", "1 (highest)":
|
|
return "high"
|
|
case "3", "normal", "3 (normal)":
|
|
return "normal"
|
|
case "5", "low", "5 (lowest)":
|
|
return "low"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// projectLargeAttachments extracts large attachment info from the draft.
|
|
// It first tries the server-format header (X-Lark-Large-Attachment) which
|
|
// carries filename and size directly. Falls back to merging CLI-format
|
|
// header tokens with HTML-parsed metadata.
|
|
func projectLargeAttachments(headers []Header, htmlBody string) []LargeAttachmentSummary {
|
|
if summaries := ParseLargeAttachmentSummariesFromHeader(headers); len(summaries) > 0 {
|
|
return summaries
|
|
}
|
|
tokens := parseLargeAttachmentTokens(headers)
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
metas := ParseLargeAttachmentItemsFromHTML(htmlBody)
|
|
out := make([]LargeAttachmentSummary, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
meta := metas[token]
|
|
meta.Token = token
|
|
out = append(out, meta)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func flattenParts(root *Part) []*Part {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
out := []*Part{root}
|
|
for _, child := range root.Children {
|
|
out = append(out, flattenParts(child)...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractCIDRefs(html string) []string {
|
|
matches := cidRefRegexp.FindAllStringSubmatch(html, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(matches))
|
|
seen := make(map[string]bool, len(matches))
|
|
for _, match := range matches {
|
|
cid := strings.Trim(strings.TrimSpace(match[1]), "<>")
|
|
key := strings.ToLower(cid)
|
|
if cid == "" || seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, cid)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasQuotedContent reports whether the HTML body contains a reply or
|
|
// forward quote block generated by the Lark mail composer.
|
|
// Uses regex to match an actual <div> element with the class attribute,
|
|
// avoiding false positives from plain-text or code-snippet occurrences.
|
|
func hasQuotedContent(html string) bool {
|
|
return quoteWrapperRe.MatchString(html)
|
|
}
|
|
|
|
// SplitAtQuote splits an HTML body into the user-authored content and
|
|
// the trailing reply/forward quote block. If no quote block is found,
|
|
// quote is empty and body is the original html unchanged.
|
|
func SplitAtQuote(html string) (body, quote string) {
|
|
loc := quoteWrapperRe.FindStringIndex(html)
|
|
if loc == nil {
|
|
return html, ""
|
|
}
|
|
return html[:loc[0]], html[loc[0]:]
|
|
}
|
|
|
|
// largeFileAreaOpenRe matches the opening <div> of a large attachment
|
|
// card container (id starts with "large-file-area-").
|
|
var largeFileAreaOpenRe = regexp.MustCompile(
|
|
`<div\s[^>]*id="` + regexp.QuoteMeta(LargeFileContainerIDPrefix) + `[^"]*"`)
|
|
|
|
// SplitAtLargeAttachment splits HTML into three pieces around the first
|
|
// large-file-area container: content before, the entire container block,
|
|
// and content after. If no container is present, returns (html, "", "").
|
|
//
|
|
// Used by set_body / set_reply_body to preserve the large attachment card
|
|
// across body replacements.
|
|
func SplitAtLargeAttachment(html string) (before, card, after string) {
|
|
loc := largeFileAreaOpenRe.FindStringIndex(html)
|
|
if loc == nil {
|
|
return html, "", ""
|
|
}
|
|
startTag := loc[0]
|
|
end := FindMatchingCloseDiv(html, startTag)
|
|
return html[:startTag], html[startTag:end], html[end:]
|
|
}
|
|
|
|
// splitAtSystemTail splits html at the earliest system-managed element:
|
|
// either the large-file-area card container or the history-quote-wrapper,
|
|
// whichever appears first. When neither is present, returns (html, "").
|
|
//
|
|
// This is the placement point for signatures. In Lark mail's compose
|
|
// order the signature sits right after the user-authored region and
|
|
// before any attachment cards or quoted content.
|
|
func splitAtSystemTail(html string) (userRegion, systemTail string) {
|
|
cardLoc := largeFileAreaOpenRe.FindStringIndex(html)
|
|
quoteLoc := quoteWrapperRe.FindStringIndex(html)
|
|
pos := -1
|
|
if cardLoc != nil {
|
|
pos = cardLoc[0]
|
|
}
|
|
if quoteLoc != nil && (pos < 0 || quoteLoc[0] < pos) {
|
|
pos = quoteLoc[0]
|
|
}
|
|
if pos < 0 {
|
|
return html, ""
|
|
}
|
|
return html[:pos], html[pos:]
|
|
}
|
|
|
|
// PlaceSignatureBeforeSystemTail is the single source of truth for
|
|
// signature placement. It removes any existing signature from html, then
|
|
// inserts sigBlock at the split point between the user-authored region
|
|
// and the system-managed tail (large attachment card or history quote
|
|
// wrapper, whichever comes first).
|
|
//
|
|
// Used by both compose-time signature injection
|
|
// (mail/signature_compose.go) and edit-time insert_signature op
|
|
// (draft/patch.go), guaranteeing they produce a consistent HTML layout
|
|
// [user][sig][card?][quote?].
|
|
//
|
|
// When sigBlock is empty, behaves as a simple "remove signature" on the
|
|
// HTML string level — note that callers needing MIME-part orphan cleanup
|
|
// should handle that separately.
|
|
func PlaceSignatureBeforeSystemTail(html, sigBlock string) string {
|
|
cleaned := RemoveSignatureHTML(html)
|
|
if sigBlock == "" {
|
|
return cleaned
|
|
}
|
|
user, tail := splitAtSystemTail(cleaned)
|
|
return user + sigBlock + tail
|
|
}
|
|
|
|
// HTMLContainsLargeAttachment reports whether the given HTML fragment
|
|
// contains a large attachment card container (`<div ... id="large-file-area-..."`).
|
|
// Used to detect whether a user-supplied set_body value already carries
|
|
// a card, in which case auto-preservation is skipped.
|
|
func HTMLContainsLargeAttachment(html string) bool {
|
|
return largeFileAreaOpenRe.MatchString(html)
|
|
}
|
|
|
|
// FindHTMLBodyPart walks the MIME tree and returns the first text/html
|
|
// body part (skipping attachment-disposition parts), or nil when none exists.
|
|
func FindHTMLBodyPart(root *Part) *Part {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(root.MediaType, "text/html") && !strings.EqualFold(root.ContentDisposition, "attachment") {
|
|
return root
|
|
}
|
|
for _, c := range root.Children {
|
|
if f := FindHTMLBodyPart(c); f != nil {
|
|
return f
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindTextBodyPart walks the MIME tree and returns the first text/plain
|
|
// body part (skipping attachment-disposition parts), or nil when none exists.
|
|
func FindTextBodyPart(root *Part) *Part {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(root.MediaType, "text/plain") && !strings.EqualFold(root.ContentDisposition, "attachment") {
|
|
return root
|
|
}
|
|
for _, c := range root.Children {
|
|
if f := FindTextBodyPart(c); f != nil {
|
|
return f
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InsertBeforeQuoteOrAppend inserts block into html right before the
|
|
// outermost quote wrapper (<div ... class="history-quote-wrapper">), or
|
|
// appends it to the end when no quote block is present. Matching uses
|
|
// quoteWrapperRe (an actual element with the class attribute), avoiding
|
|
// false positives from plain-text or code-snippet occurrences of the
|
|
// class name.
|
|
func InsertBeforeQuoteOrAppend(html, block string) string {
|
|
loc := quoteWrapperRe.FindStringIndex(html)
|
|
if loc == nil {
|
|
return html + block
|
|
}
|
|
return html[:loc[0]] + block + html[loc[0]:]
|
|
}
|
|
|
|
// ── Exported signature HTML utilities ──
|
|
// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package).
|
|
|
|
// signatureSpacingRe matches 1-2 empty-line divs before the signature.
|
|
var signatureSpacingRe = regexp.MustCompile(
|
|
`(?:<div[^>]*><div[^>]*><br></div></div>\s*){1,2}$`)
|
|
|
|
// SignatureSpacingRe returns the compiled regex for signature spacing detection.
|
|
func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe }
|
|
|
|
// SignatureSpacing returns the 2 empty-line divs placed before the signature,
|
|
// matching the structure generated by the Lark mail editor.
|
|
func SignatureSpacing() string {
|
|
line := `<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto"><br></div></div>`
|
|
return line + line
|
|
}
|
|
|
|
// BuildSignatureHTML wraps signature content in the standard signature container div.
|
|
// sigID is HTML-escaped to prevent attribute injection.
|
|
func BuildSignatureHTML(sigID, content string) string {
|
|
return `<div id="` + html.EscapeString(sigID) + `" class="` + SignatureWrapperClass + `" style="padding-top:6px;padding-bottom:6px">` + content + `</div>`
|
|
}
|
|
|
|
// FindMatchingCloseDiv finds the position after the closing </div> that matches
|
|
// the <div at startPos, tracking nesting depth.
|
|
func FindMatchingCloseDiv(html string, startPos int) int {
|
|
depth := 0
|
|
i := startPos
|
|
for i < len(html) {
|
|
if strings.HasPrefix(html[i:], "<div") {
|
|
depth++
|
|
i += 4
|
|
} else if strings.HasPrefix(html[i:], "</div>") {
|
|
depth--
|
|
i += 6
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
} else {
|
|
i++
|
|
}
|
|
}
|
|
return len(html)
|
|
}
|
|
|
|
// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML.
|
|
// Returns the HTML unchanged if no signature is found.
|
|
func RemoveSignatureHTML(html string) string {
|
|
start, end, ok := locateSignatureBlock(html)
|
|
if !ok {
|
|
return html
|
|
}
|
|
return html[:start] + html[end:]
|
|
}
|
|
|
|
// ExtractSignatureBlock returns the signature block (including any
|
|
// preceding spacing that would be removed by RemoveSignatureHTML) from
|
|
// html. Returns "" when html has no signature.
|
|
//
|
|
// Symmetric to RemoveSignatureHTML: RemoveSignatureHTML(html) +
|
|
// ExtractSignatureBlock(html) reconstitutes the original html.
|
|
func ExtractSignatureBlock(html string) string {
|
|
start, end, ok := locateSignatureBlock(html)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return html[start:end]
|
|
}
|
|
|
|
// locateSignatureBlock returns the start and end offsets of the
|
|
// signature block (including any preceding spacing) in html. ok=false
|
|
// when no signature is present.
|
|
func locateSignatureBlock(html string) (start, end int, ok bool) {
|
|
loc := signatureWrapperRe.FindStringIndex(html)
|
|
if loc == nil {
|
|
return 0, 0, false
|
|
}
|
|
sigStart := loc[0]
|
|
sigEnd := FindMatchingCloseDiv(html, sigStart)
|
|
// Extend backward to include preceding spacing.
|
|
beforeSig := html[:sigStart]
|
|
if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil {
|
|
sigStart = spacingLoc[0]
|
|
}
|
|
return sigStart, sigEnd, true
|
|
}
|
|
|
|
func summarizeHTML(html string) string {
|
|
trimmed := strings.TrimSpace(html)
|
|
runes := []rune(trimmed)
|
|
if len(runes) <= 240 {
|
|
return trimmed
|
|
}
|
|
return string(runes[:240]) + "..."
|
|
}
|