// 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
blocks, or in // HTML-escaped content. // // Matches: // -(reply) // -(forward) var quoteWrapperRe = regexp.MustCompile(`]*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( `]*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( `]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` + `|]*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 actualelement 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 openingof a large attachment // card container (id starts with "large-file-area-"). var largeFileAreaOpenRe = regexp.MustCompile( `]*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 (`), 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( `(?:that matches // the]*>\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 := `]*>` 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 `` + content + `` } // FindMatchingCloseDiv finds the position after the closing") { 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]) + "..." }