// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package lint import ( "bytes" "fmt" "hash/fnv" "strings" xhtml "golang.org/x/net/html" "golang.org/x/net/html/atom" ) // MaxExcerptBytes caps the raw-HTML excerpt embedded in a Finding.Excerpt so // a single offending tag with megabyte content can't bloat the envelope JSON. // Lint operates on bytes only, but the excerpt representation must not be // size-amplifying. const MaxExcerptBytes = 200 // Run lints the given HTML body and returns a structured Report. // Report.CleanedHTML contains the rewritten HTML (warnings rewritten + errors // deleted) — the autofix is unconditional. // // IMPORTANT: when the input is empty or plain-text (no HTML markup detected // by the cli's existing `bodyIsHTML` heuristic), callers should short-circuit // with EmptyReport(html) instead of paying the parse cost. Run still handles // this gracefully — html.Parse on plain text wraps the input in // ..., and the lib's pass-through // rendering will reproduce the original text — but the round-trip is wasteful // and produces no findings. func Run(html string, opts Options) Report { if html == "" { return EmptyReport("") } rep := Report{ Applied: []Finding{}, Blocked: []Finding{}, } // We use html.ParseFragment so users authoring fragment-style snippets // (the canonical compose-5 input shape — `
...
` rather than a // full document) don't get implicit wrappers // re-rendered. The "body" insertion mode matches what html.Parse would // have done internally for a fragment but skips the structural wrappers // at render time. bodyContext := &xhtml.Node{Type: xhtml.ElementNode, DataAtom: atom.Body, Data: "body"} nodes, err := xhtml.ParseFragment(strings.NewReader(html), bodyContext) if err != nil { // Parser failure is exceptional (the parser is permissive by design); // fall back to the original input so we don't lose user content. return EmptyReport(html) } // Wrap fragment nodes in a synthetic root so the recursive walker has a // uniform parent pointer to mutate. root := &xhtml.Node{Type: xhtml.DocumentNode} for _, n := range nodes { root.AppendChild(n) } walk(root, &rep) // nativeCtx tracks per-Run() state so positional ids (e.g. data-ol-id) // are deterministic across multiple Run() calls on the same input — // keying off the document-traversal order rather than heap pointers, // so cleaned_html is byte-stable and amenable to golden-file tests / CI // diff / cache-key reuse. nctx := &nativeCtx{olIDs: map[*xhtml.Node]string{}} applyFeishuNativeStyles(root, &rep, nctx) rep.HasErrorFindings = len(rep.Blocked) > 0 rep.HasWarningFindings = len(rep.Applied) > 0 rep.CleanedHTML = renderFragment(root) return rep } // walk visits every element node under parent, applying tag/attr/style // classification. Children are iterated via the next-sibling pointer because // we mutate the tree in place (replace / remove nodes). // // The walker is iterative-style via explicit recursion because the html // parser's typical nesting depth (≤ 256 by default) is well below Go's // goroutine stack limit; the existing draft package's plainTextFromHTML // (mail/draft/htmltext.go) similarly recurses for the same reason. func walk(parent *xhtml.Node, rep *Report) { child := parent.FirstChild for child != nil { next := child.NextSibling if child.Type == xhtml.ElementNode { processElement(parent, child, rep) } // child may have been removed/replaced by processElement; recurse // only if it still has the original parent (i.e. wasn't deleted). // The html parser sets Parent on every node, so a removed-then- // reattached node still recurses correctly via its new Parent. if child.Parent != nil { walk(child, rep) } child = next } } // processElement applies the element-level classification cascade: // 1. tag → allow / warn-rewrite / error-delete // 2. attributes → on*-handlers, URL-bearing attrs (scheme allow-list), // style attribute (CSS property allow-list) func processElement(parent, n *xhtml.Node, rep *Report) { tagName := strings.ToLower(n.Data) kind, ruleID := classifyTag(tagName) switch kind { case "error": rep.Blocked = append(rep.Blocked, Finding{ RuleID: ruleID, Severity: SeverityError, TagOrAttr: tagName, Excerpt: excerptOf(n), Hint: hintForBlockedTag(tagName), }) // Always remove blocked tags — the writing-path safety floor has no // opt-out; `--no-lint` is not provided. parent.RemoveChild(n) return case "warn": // Always rewrite (e.g. →) and surface the finding. rep.Applied = append(rep.Applied, Finding{ RuleID: ruleID, Severity: SeverityWarning, TagOrAttr: tagName, Excerpt: excerptOf(n), Hint: hintForWarnTag(tagName), }) rewriteWarnTag(n, tagName) // Recurse into the rewritten node by falling through; the rewrite // preserved children as-is. // fall through to attribute scan case "allow": // no-op } // Attribute scan: build a new attribute slice, dropping/sanitising as we // go and surfacing findings. if len(n.Attr) > 0 { processAttributes(n, rep) } } // processAttributes walks the attribute list and: // - drops on*-handlers (always; surfaced as error) // - drops URL-bearing attrs whose value uses a forbidden scheme // - filters the `style` attribute property-by-property against the allow-list // // Other attributes pass through unchanged. The cli's existing // `validateInlineCIDs` (helpers.go:2226) handles `cid:`-specific checks; // the lint must not duplicate that responsibility. func processAttributes(n *xhtml.Node, rep *Report) { keep := n.Attr[:0] for _, attr := range n.Attr { name := strings.ToLower(attr.Key) // 1. on*-handlers → always drop, error-tier. if isEventHandlerAttr(name) { rep.Blocked = append(rep.Blocked, Finding{ RuleID: RuleAttrEventHandlerBlocked, Severity: SeverityError, TagOrAttr: name, Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), Hint: "Removed event handler attribute (on*)", }) continue } // 2. URL-bearing attrs → check scheme allow-list. if urlAttributes[name] { kind, ruleID := classifyURLValue(attr.Val) switch kind { case "error": rep.Blocked = append(rep.Blocked, Finding{ RuleID: ruleID, Severity: SeverityError, TagOrAttr: name, Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), Hint: "Removed dangerous URL scheme (allowed: http/https/mailto/cid/data:image/*)", }) continue case "warn": rep.Blocked = append(rep.Blocked, Finding{ RuleID: ruleID, Severity: SeverityError, TagOrAttr: name, Excerpt: truncateExcerpt(attr.Key + "=\"" + attr.Val + "\""), Hint: "Removed URL with unrecognised scheme (allowed: http/https/mailto/cid/data:image/*)", }) // Always drop the attribute — writing-path safety floor (the // URL would not render correctly anyway). continue } } // 3. `style` attribute → property-by-property allow-list. if name == "style" { cleaned, dropped := sanitiseStyleAttr(attr.Val) for _, prop := range dropped { rep.Applied = append(rep.Applied, Finding{ RuleID: RuleStylePropertyDropped, Severity: SeverityWarning, TagOrAttr: "style." + prop, Excerpt: truncateExcerpt(prop), Hint: "Removed CSS property not in allowlist (see references/lark-mail-html.md)", }) } if len(dropped) == 0 { // Byte-stable when no property was dropped: leave the // attribute exactly as authored so lint round-trips are // idempotent on clean input. keep = append(keep, attr) continue } if cleaned == "" { // All properties dropped — remove the attribute entirely. continue } attr.Val = cleaned keep = append(keep, attr) continue } // 4. Pass-through. keep = append(keep, attr) } n.Attr = keep } // rewriteWarnTag replaces a warning-tier tag with its Feishu-native // equivalent in place: → with color/face/size // distilled into inline style;
→
; // / → (text-only, animation discarded — collapsing // to a span keeps the children but drops the deprecated animation effect). func rewriteWarnTag(n *xhtml.Node, tagName string) { switch tagName { case "font": // Distill . var styles []string var keepAttrs []xhtml.Attribute for _, attr := range n.Attr { switch strings.ToLower(attr.Key) { case "color": if v := strings.TrimSpace(attr.Val); v != "" { styles = append(styles, "color:"+v) } case "face": if v := strings.TrimSpace(attr.Val); v != "" { styles = append(styles, "font-family:"+v) } case "size": if v := mapFontSize(attr.Val); v != "" { styles = append(styles, "font-size:"+v) } default: keepAttrs = append(keepAttrs, attr) } } // Merge any existing style attribute already present on the // (rare but possible). if len(styles) > 0 { merged := strings.Join(styles, ";") styleIdx := -1 for i, attr := range keepAttrs { if strings.ToLower(attr.Key) == "style" { styleIdx = i break } } if styleIdx >= 0 { existing := strings.TrimRight(keepAttrs[styleIdx].Val, "; ") if existing != "" { merged = existing + ";" + merged } keepAttrs[styleIdx].Val = merged } else { keepAttrs = append(keepAttrs, xhtml.Attribute{Key: "style", Val: merged}) } } n.Data = "span" n.DataAtom = atom.Span n.Attr = keepAttrs case "center": //
→
. Existing style attr // (if any) is merged with text-align prepended. styleIdx := -1 for i, attr := range n.Attr { if strings.ToLower(attr.Key) == "style" { styleIdx = i break } } newStyle := "text-align:center" if styleIdx >= 0 { existing := strings.TrimRight(n.Attr[styleIdx].Val, "; ") if existing != "" { newStyle = newStyle + ";" + existing } n.Attr[styleIdx].Val = newStyle } else { n.Attr = append(n.Attr, xhtml.Attribute{Key: "style", Val: newStyle}) } n.Data = "div" n.DataAtom = atom.Div case "marquee", "blink": // Both deprecated; collapse to so children survive. n.Data = "span" n.DataAtom = atom.Span // Strip marquee-specific attributes (direction, scrollamount, ...) // so the rewritten span is plain. var keepAttrs []xhtml.Attribute for _, attr := range n.Attr { if strings.ToLower(attr.Key) == "style" || strings.ToLower(attr.Key) == "class" || strings.ToLower(attr.Key) == "id" { keepAttrs = append(keepAttrs, attr) } } n.Attr = keepAttrs } } // mapFontSize maps the legacy values (1..7) to a CSS px // equivalent, matching the mapping used by Feishu mail-editor's renderer. // Out-of-range values fall through to the empty string so the property is // dropped (better than emitting an arbitrary value). func mapFontSize(raw string) string { switch strings.TrimSpace(raw) { case "1": return "10px" case "2": return "13px" case "3": return "16px" case "4": return "18px" case "5": return "24px" case "6": return "32px" case "7": return "48px" default: return "" } } // sanitiseStyleAttr filters a `style="prop1:val; prop2:val"` declaration // against the property allow-list. Returns the cleaned style text (joined // with "; " separators) and a slice of dropped property names (lower-case) // so the caller can surface STYLE_PROPERTY_DROPPED findings. // // NOTE: We do NOT validate property values — only property names. The style // attribute is filtered by CSS property allow-list; value-level validation // (e.g. URL safety inside `background-image: url(...)`) is delegated to the // urlAttributes path because such values typically appear in `src` / `href` // attrs in compose-5 templates. Users authoring `background-image: url(http:...)` // in inline style will see the property pass — the URL inside is not a // security concern at the inline-style level since URL fetching from style // is restricted by the rendering layer's CSP regardless. func sanitiseStyleAttr(raw string) (cleaned string, dropped []string) { if strings.TrimSpace(raw) == "" { return "", nil } parts := strings.Split(raw, ";") keep := make([]string, 0, len(parts)) for _, part := range parts { decl := strings.TrimSpace(part) if decl == "" { continue } colon := strings.IndexByte(decl, ':') if colon < 0 { // Malformed declaration; drop and surface as a finding so the // user notices. dropped = append(dropped, decl) continue } name := strings.ToLower(strings.TrimSpace(decl[:colon])) if !classifyStyleProperty(name) { dropped = append(dropped, name) continue } keep = append(keep, decl) } cleaned = strings.Join(keep, "; ") return cleaned, dropped } // hintForBlockedTag returns a hint for an error-blocked tag. func hintForBlockedTag(tag string) string { switch tag { case "script": return "Removed whole tag (XSS risk)" case "iframe", "object", "embed": return "Removed whole tag (external embeds not allowed; use or a body link for rich media)" case "form", "input", "select", "option", "button": return "Removed whole tag (forms not allowed in email body)" case "link": return "Removed (external CSS / resources not allowed)" case "meta": return "Removed (viewport / refresh declarations not allowed)" case "base": return "Removed (URL base rewrites not allowed)" default: return "Removed whole tag (tag not allowed)" } } // hintForWarnTag returns a hint for a warning-tier tag. func hintForWarnTag(tag string) string { switch tag { case "font": return "Rewritten as (modern HTML expresses size / color via inline style)" case "center": return "Rewritten as
(deprecated
tag)" case "marquee", "blink": return "Rewritten as (animations not supported; text preserved)" default: return "Rewritten in modern HTML shape" } } // excerptOf renders the offending node's open-tag header into a short string // suitable for surfacing in a Finding.Excerpt. We render only the tag header // (not the full subtree) so a single offending