Files
larksuite-cli/shortcuts/mail/draft/projection.go
xzcong0820 e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
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
2026-05-19 14:02:01 +08:00

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]) + "..."
}