From 62f5481f0ea5f21e5ef354a1b95fe353760ba903 Mon Sep 17 00:00:00 2001 From: "lijiayi.2333" Date: Wed, 6 May 2026 17:28:57 +0800 Subject: [PATCH 1/2] feat(mail): add HTML lint lib + +lint-html shortcut + write-path enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new mail/lint package classifies HTML tags / attrs / inline styles into three tiers (allow / warn-and-autofix / error-delete) aligned with the mail-editor editor-kit branch DOMPurify config but stricter —

after

`, Options{AutoFix: autoFix}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleTagScriptBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked) + } + if strings.Contains(rep.CleanedHTML, " content should be deleted, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML) + } + }) + } +} + +// TestRun_BlockedTagsRemoved iterates all error-tier tags. +func TestRun_BlockedTagsRemoved(t *testing.T) { + cases := map[string]string{ + ``: RuleTagIframeBlocked, + ``: RuleTagObjectBlocked, + ``: RuleTagEmbedBlocked, + `
`: RuleTagFormBlocked, + ``: RuleTagStyleBlocked, + ``: RuleTagLinkBlocked, + ``: RuleTagMetaBlocked, + ``: RuleTagBaseBlocked, + } + for input, wantRule := range cases { + t.Run(input[:min(len(input), 30)], func(t *testing.T) { + rep := Run(input, Options{AutoFix: true}) + found := false + for _, f := range rep.Blocked { + if f.RuleID == wantRule { + found = true + break + } + } + if !found { + t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked) + } + }) + } +} + +// TestRun_EventHandlerAttrBlocked verifies on*-handlers are stripped (spec +// §4.4 — "属性 on*(onclick 等)"). +func TestRun_EventHandlerAttrBlocked(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + if len(rep.Blocked) != 1 { + t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked)) + } + if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked { + t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked) + } + if strings.Contains(rep.CleanedHTML, "onclick") { + t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, `id="ok"`) { + t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors. +func TestRun_OnErrorAttrBlocked(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" { + hasErr = true + } + } + if !hasErr { + t.Errorf("onerror should fire, got %+v", rep.Blocked) + } +} + +// ===================================================================== +// URL scheme allow-list (spec §4.4 — "URL scheme"). +// ===================================================================== + +// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped. +func TestRun_JavaScriptURLBlocked(t *testing.T) { + rep := Run(`click`, Options{AutoFix: true}) + hasErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + hasErr = true + } + } + if !hasErr { + t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked) + } + if strings.Contains(rep.CleanedHTML, "javascript:") { + t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_VBScriptURLBlocked verifies vbscript: is rejected. +func TestRun_VBScriptURLBlocked(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Errorf("expected vbscript: to be blocked, got 0 findings") + } +} + +// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected +// (only data:image/* is allowed per spec §4.4). +func TestRun_DataNonImageURLBlocked(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Errorf("expected data:text/html to be blocked") + } +} + +// TestRun_DataImageAllowed verifies data:image/png passes. +func TestRun_DataImageAllowed(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + t.Errorf("data:image/* should pass, got %+v", f) + } + } +} + +// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass. +func TestRun_RelativeURLAllowed(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked { + t.Errorf("relative URL should pass, got %+v", f) + } + } +} + +// ===================================================================== +// Style property allow-list (spec §4.4 — last paragraph). +// ===================================================================== + +// TestRun_StylePropertyDropped verifies non-allow-list properties drop. +func TestRun_StylePropertyDropped(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + dropped := []string{} + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + dropped = append(dropped, f.TagOrAttr) + } + } + if !sliceContains(dropped, "style.position") { + t.Errorf("expected position to be dropped, got %v", dropped) + } + if !sliceContains(dropped, "style.z-index") { + t.Errorf("expected z-index to be dropped, got %v", dropped) + } + if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") { + t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule. +func TestRun_StyleBorderPrefixAllowed(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + t.Errorf("border-* should pass, got %+v", f) + } + } +} + +// ===================================================================== +// CleanedHTML output / contract guarantees (spec §4.3). +// ===================================================================== + +// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty +// slices when nothing is found (the JSON envelope contract requires `[]`, +// not `null`). +func TestRun_EmptyArraysAlwaysPresent(t *testing.T) { + rep := Run(`

nothing here

`, Options{AutoFix: true}) + if rep.Applied == nil || rep.Blocked == nil { + t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked)) + } +} + +// TestEmptyReport_HasContractFields covers the helper used by compose 5's +// plain-text branch. +func TestEmptyReport_HasContractFields(t *testing.T) { + rep := EmptyReport(`plain text`) + if rep.Applied == nil { + t.Error("Applied must be non-nil") + } + if rep.Blocked == nil { + t.Error("Blocked must be non-nil") + } + if rep.CleanedHTML != "plain text" { + t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text") + } +} + +// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through +// the parser doesn't accidentally lose user content. +func TestRun_CleanedHTMLPreservesStructure(t *testing.T) { + html := `

title

body bold end

  • a
  • b
` + rep := Run(html, Options{AutoFix: true}) + if len(rep.Blocked) != 0 || len(rep.Applied) != 0 { + t.Fatalf("unexpected findings: %+v %+v", rep.Blocked, rep.Applied) + } + for _, want := range []string{"line-height:1.6", "

", "title", "", "bold", "
    ", "
  • ", "
"} { + if !strings.Contains(rep.CleanedHTML, want) { + t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML) + } + } +} + +// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input. +func TestRun_EmptyInput(t *testing.T) { + rep := Run("", Options{AutoFix: true}) + if rep.CleanedHTML != "" { + t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML) + } + if len(rep.Applied) != 0 || len(rep.Blocked) != 0 { + t.Errorf("empty input must produce empty findings") + } +} + +// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings. +func TestRun_HasErrorFindingsFlag(t *testing.T) { + rep := Run(``, Options{AutoFix: true}) + if !rep.HasErrorFindings { + t.Error("expected HasErrorFindings=true") + } + clean := Run(`

safe

`, Options{AutoFix: true}) + if clean.HasErrorFindings { + t.Error("expected HasErrorFindings=false on clean HTML") + } +} + +// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings. +func TestRun_HasWarningFindingsFlag(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if !rep.HasWarningFindings { + t.Error("expected HasWarningFindings=true") + } +} + +// ===================================================================== +// Excerpt cap (S2 contract «Server-mirrored constraints»). +// ===================================================================== + +// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap. +func TestTruncateExcerpt_RespectsCap(t *testing.T) { + long := strings.Repeat("x", MaxExcerptBytes+50) + got := truncateExcerpt(long) + if len(got) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes) + } + if !strings.HasSuffix(got, " ...") { + t.Errorf("expected truncation suffix, got %q", got[len(got)-10:]) + } +} + +// TestRun_ExcerptCappedForLargeOffender verifies large blocked content +// produces a short excerpt (envelope size protection). +func TestRun_ExcerptCappedForLargeOffender(t *testing.T) { + bigAttr := strings.Repeat("a", MaxExcerptBytes*2) + rep := Run(`x`, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Fatal("expected blocked finding") + } + for _, f := range rep.Blocked { + if len(f.Excerpt) > MaxExcerptBytes { + t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes) + } + } +} + +// ===================================================================== +// Helpers. +// ===================================================================== + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func sliceContains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ===================================================================== +// Additional coverage to lift the package to ≥ 90% line coverage +// (sprint requirement S4 item 7). +// ===================================================================== + +// TestMapFontSize_ExhaustiveSpan covers every mapping +// + invalid values fall through to "" so the property is dropped. +func TestMapFontSize_ExhaustiveSpan(t *testing.T) { + cases := map[string]string{ + "1": "10px", + "2": "13px", + "3": "16px", + "4": "18px", + "5": "24px", + "6": "32px", + "7": "48px", + "": "", + "8": "", + "abc": "", + "3.5": "", + " 3 ": "16px", + } + for raw, want := range cases { + got := mapFontSize(raw) + if got != want { + t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want) + } + } +} + +// TestRun_FontTagWithFaceMappedToFontFamily ensures → +// font-family inline style. +func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if !strings.Contains(rep.CleanedHTML, "font-family:Arial") { + t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an +// existing style attribute on the same element. +func TestRun_FontTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "color:red") { + t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_CenterTagWithExistingStyleMerged ensures
's style merge. +func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) { + rep := Run(`
x
`, Options{AutoFix: true}) + if !strings.Contains(rep.CleanedHTML, "text-align:center") { + t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML) + } + if !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id. +func TestRun_MarqueeRetainsClassAndID(t *testing.T) { + rep := Run(`y`, Options{AutoFix: true}) + if !strings.Contains(rep.CleanedHTML, `class="cls"`) { + t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, `direction`) { + t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_UnknownSchemeWarning verifies an unknown URL scheme produces a +// warning (not an error) and is dropped only when AutoFix is true. +func TestRun_UnknownSchemeWarning(t *testing.T) { + t.Run("autofix-true drops attr", func(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + gotWarn := false + for _, f := range rep.Applied { + if f.RuleID == RuleAttrUnsafeSchemeBlocked { + gotWarn = true + } + } + if !gotWarn { + t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED warning, got %+v", rep.Applied) + } + if strings.Contains(rep.CleanedHTML, "webcal:") { + t.Errorf("expected unknown scheme stripped under AutoFix, cleaned=%q", rep.CleanedHTML) + } + }) + t.Run("autofix-false keeps attr", func(t *testing.T) { + rep := Run(`x`, Options{AutoFix: false}) + if !strings.Contains(rep.CleanedHTML, "webcal:") { + t.Errorf("expected unknown scheme kept under AutoFix=false, cleaned=%q", rep.CleanedHTML) + } + }) + t.Run("strict promotes warning", func(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true, Strict: true}) + gotErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrUnsafeSchemeBlocked { + gotErr = true + } + } + if !gotErr { + t.Errorf("expected unsafe-scheme to be promoted to error in strict, got %+v", rep.Blocked) + } + }) +} + +// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..." +// is still caught after control-byte stripping in classifyURLValue. +func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) { + rep := Run("x", Options{AutoFix: true}) + gotErr := false + for _, f := range rep.Blocked { + if f.RuleID == RuleAttrJSURLBlocked { + gotErr = true + } + } + if !gotErr { + t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked) + } +} + +// TestRun_FileSchemeBlocked verifies file: URLs are rejected. +func TestRun_FileSchemeBlocked(t *testing.T) { + rep := Run(`x`, Options{AutoFix: true}) + if len(rep.Blocked) == 0 { + t.Error("expected file: to be blocked") + } +} + +// TestRun_StyleMalformedDeclarationDropped verifies a property without a +// colon delimiter is treated as malformed and dropped. +func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + gotMalformed := false + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" { + gotMalformed = true + } + } + if !gotMalformed { + t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied) + } + if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") { + t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style +// attribute is removed entirely when every property is invalid. +func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + if strings.Contains(rep.CleanedHTML, "style=") { + t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleAttrAutoFixFalseKeepsOriginal verifies AutoFix=false keeps +// the original style attribute even when properties would have been dropped. +func TestRun_StyleAttrAutoFixFalseKeepsOriginal(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: false}) + gotDropped := false + for _, f := range rep.Applied { + if f.RuleID == RuleStylePropertyDropped { + gotDropped = true + } + } + if !gotDropped { + t.Errorf("expected drop finding for AutoFix=false, got %+v", rep.Applied) + } + if !strings.Contains(rep.CleanedHTML, "position:") { + t.Errorf("expected original style preserved under AutoFix=false, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes. +func TestRun_StyleEmptyValuePassThrough(t *testing.T) { + rep := Run(`

x

`, Options{AutoFix: true}) + if len(rep.Applied) != 0 { + t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied) + } +} + +// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a +// non-empty hint (consumer contract). +func TestRun_HintsForAllBlockedTags(t *testing.T) { + cases := []string{ + ``, ``, ``, + ``, ``, `
`, + ``, ``, ``, + ``, ``, + } + for _, html := range cases { + rep := Run(html, Options{AutoFix: true}) + for _, f := range rep.Blocked { + if f.Hint == "" { + t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint. +func TestRun_HintsForAllWarnTags(t *testing.T) { + cases := []string{ + `x`, `
x
`, + `x`, `x`, + } + for _, html := range cases { + rep := Run(html, Options{AutoFix: true}) + for _, f := range rep.Applied { + if f.Hint == "" { + t.Errorf("warn rule %s missing hint for %q", f.RuleID, html) + } + } + } +} + +// TestClassifyTag_Coverage exercises classifyTag with every category. +func TestClassifyTag_Coverage(t *testing.T) { + if k, _ := classifyTag("p"); k != "allow" { + t.Errorf("p classified as %q", k) + } + if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked { + t.Errorf("script classified as %q/%q", k, id) + } + if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan { + t.Errorf("font classified as %q/%q", k, id) + } + // Niche tag passes silently (e.g.
). + if k, _ := classifyTag("details"); k != "allow" { + t.Errorf("niche tag
should pass through, got %q", k) + } + // Case-insensitive. + if k, _ := classifyTag("SCRIPT"); k != "error" { + t.Errorf("SCRIPT (uppercase) should still classify as error") + } +} + +// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only, +// no-scheme variants. +func TestClassifyURLValue_CoverageEdges(t *testing.T) { + cases := map[string]string{ + "": "ok", + " ": "ok", + "https://x": "ok", + "https://x/path?q=1": "ok", + "#fragment": "ok", + "/relative": "ok", + "javascript:alert(1)": "error", + "vbscript:msgbox 1": "error", + "data:image/png;base64,XYZ": "ok", + "data:text/html,x` + + `

y

` + rep := Run(html, Options{AutoFix: true}) + if len(rep.Blocked) < 4 { + t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked) + } +} + +// TestRun_NestedStructurePreserved verifies deep nesting passes through. +func TestRun_NestedStructurePreserved(t *testing.T) { + html := `

deep

` + rep := Run(html, Options{AutoFix: true}) + if len(rep.Blocked) != 0 { + t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked) + } + if !strings.Contains(rep.CleanedHTML, "deep") { + t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML) + } +} + +// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a +// blocked tag inside an allowed parent leaves the parent intact. +func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) { + html := `
beforeafter
` + rep := Run(html, Options{AutoFix: true}) + if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") { + t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML) + } + if strings.Contains(rep.CleanedHTML, "); we treat them as transparent so the wrapper + // nodes the parser inserts don't generate spurious findings. + "html": true, + "head": true, + "body": true, +} + +// blockedTags enumerates tags whose content is removed in full and a +// SeverityError finding is emitted (spec §4.4 row "错误(删除)"). Each entry +// maps to the rule id surfaced in Finding.RuleID. +var blockedTags = map[string]string{ + "script": RuleTagScriptBlocked, + "style": RuleTagStyleBlocked, + "iframe": RuleTagIframeBlocked, + "object": RuleTagObjectBlocked, + "embed": RuleTagEmbedBlocked, + "form": RuleTagFormBlocked, + "input": RuleTagInputBlocked, + "select": RuleTagInputBlocked, + "option": RuleTagInputBlocked, + "button": RuleTagInputBlocked, + "link": RuleTagLinkBlocked, + "meta": RuleTagMetaBlocked, + "base": RuleTagBaseBlocked, +} + +// warnAutofixTags enumerates tags rewritten when AutoFix is true (spec §4.4 +// row "警告 + 自动修复"). The replacement strategy is per-tag (see rewrite.go). +var warnAutofixTags = map[string]string{ + "font": RuleTagFontToSpan, + "center": RuleTagCenterToDiv, + "marquee": RuleTagMarqueeToText, + "blink": RuleTagBlinkToText, +} + +// classifyTag returns the rule kind for the given lower-case tag name. +// +// kind is one of "allow", "warn", "error", "unknown". For "warn" / "error", +// ruleID names the firing rule; for "unknown", the caller falls back to +// allow-list-by-default but emits a hint via RuleTagUnknownStripped only when +// the tag is structurally suspect (e.g. -like). The cli's existing +// `htmlTagRe` regex is the de-facto allow-list shipping with the codebase, so +// we don't aggressively flag anything outside `allowedTags` — drop-through +// preserves user intent for niche tags (e.g. `
` / ``) that +// browsers + Feishu native renderer already handle. +func classifyTag(tag string) (kind, ruleID string) { + tag = strings.ToLower(tag) + if allowedTags[tag] { + return "allow", "" + } + if id, ok := blockedTags[tag]; ok { + return "error", id + } + if id, ok := warnAutofixTags[tag]; ok { + return "warn", id + } + // Unknown / niche tags: pass through silently. The cli's existing + // `htmlTagRe` (mail_quote.go:333) tolerates them too, and the server-side + // RemoteSanitizer will remove anything risky regardless. Users authoring + // HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`, + // `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged + // so reply / forward quote markup survives lint round-trips. (Spec §4.4 + // "通过" row second sentence: "飞书原生 quote 体系...class".) + return "allow", "" +} + +// Attribute / URL / style classification -------------------------------------- + +// allowedURLSchemes lists URL schemes that pass through hyperlink-bearing +// attrs (`href`, `src`, `cite`, `formaction` etc.). Per spec §4.4: http(s), +// mailto, cid, data:image/* are allowed; everything else (notably javascript: +// and vbscript:) is blocked. Empty / relative URLs (no scheme) are always +// allowed because they resolve relatively at render time and pose no +// injection vector. +var allowedURLSchemes = map[string]bool{ + "http": true, + "https": true, + "mailto": true, + "cid": true, +} + +// blockedURLSchemes is the explicit deny-list. data:image/* is special-cased +// in classifyURLValue. +var blockedURLSchemes = map[string]bool{ + "javascript": true, + "vbscript": true, + "file": true, +} + +// classifyURLValue returns ("ok", "") if the URL value is acceptable, or +// ("error", ruleID) when it must be removed (javascript:/vbscript:/file:), +// or ("warn", ruleID) when the scheme is unrecognised but not actively +// dangerous. Empty values pass through (browsers ignore them). +func classifyURLValue(raw string) (kind, ruleID string) { + value := strings.TrimSpace(raw) + if value == "" { + return "ok", "" + } + // Strip leading whitespace + control bytes that could obscure the + // scheme (e.g. "java\tscript:..."). The html-parser already strips + // stray whitespace at attribute boundaries; this is defence-in-depth + // for older clients that paste from Word with U+0009 / U+0020 inside + // the scheme prefix. + value = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7F { + return -1 + } + return r + }, value) + + // Find the colon delimiter; everything before it is the scheme. + colon := strings.IndexByte(value, ':') + if colon < 0 { + // No scheme → relative URL → allow. + return "ok", "" + } + scheme := strings.ToLower(value[:colon]) + rest := value[colon+1:] + + switch { + case allowedURLSchemes[scheme]: + return "ok", "" + case scheme == "data": + // data:image/* is whitelisted; anything else (e.g. data:text/html;...) + // is rejected. The check tolerates any subtype under image/* (png / + // jpeg / gif / svg+xml / webp) so users embedding base64 thumbnails + // don't trip the rule. + rest = strings.TrimSpace(rest) + if strings.HasPrefix(strings.ToLower(rest), "image/") { + return "ok", "" + } + return "error", RuleAttrJSURLBlocked + case blockedURLSchemes[scheme]: + return "error", RuleAttrJSURLBlocked + default: + // Unknown scheme: surface a warning so users see it but don't + // drop legitimate webcal:/tel: / similar in case downstream + // renders eventually support them. + return "warn", RuleAttrUnsafeSchemeBlocked + } +} + +// urlAttributes lists attributes whose value is a URL and must therefore +// pass classifyURLValue. Lower-case canonical names. +var urlAttributes = map[string]bool{ + "href": true, + "src": true, + "cite": true, + "formaction": true, + "action": true, + "background": true, + "poster": true, +} + +// allowedStyleProps enumerates CSS property names that pass through the +// inline `style="..."` attribute (spec §4.4 last paragraph). Everything else +// is removed from the property list and surfaced via STYLE_PROPERTY_DROPPED. +// +// `border-*` / `padding-*` / `margin-*` are treated as prefix matches by +// classifyStyleProperty so the four directional variants (border-top etc.) +// are all admitted without enumerating each. +var allowedStyleProps = map[string]bool{ + "color": true, + "background-color": true, + "font-size": true, + "font-weight": true, + "font-style": true, + "text-align": true, + "text-decoration": true, + "line-height": true, + "padding": true, + "margin": true, + "border": true, + "width": true, + "height": true, + "display": true, + "text-indent": true, + // Quote-block / native Feishu styles spec §4.4 "通过" (the editor-kit + // branch keeps them). Whitespace + word-break are part of the existing + // `
` / quote wrapper styles in mail_quote.go (e.g. `bodyDivStyle`).
+	"white-space":     true,
+	"word-break":      true,
+	"word-wrap":       true,
+	"overflow":        true,
+	"overflow-wrap":   true,
+	"vertical-align":  true,
+	"list-style":      true,
+	"list-style-type": true,
+	"font-family":     true,
+	"text-transform":  true,
+	"hyphens":         true,
+	"max-width":       true,
+	"min-width":       true,
+	"max-height":      true,
+	"min-height":      true,
+	"border-radius":   true,
+	"box-sizing":      true,
+	"opacity":         true,
+	"cursor":          true,
+}
+
+// stylePropAllowedPrefixes enumerates property name prefixes treated as
+// allowed regardless of suffix (spec §4.4 "border-*"). A trailing "-" makes
+// the prefix self-documenting.
+var stylePropAllowedPrefixes = []string{
+	"border-",
+	"padding-",
+	"margin-",
+}
+
+// classifyStyleProperty reports whether the given lower-case property name
+// is in the allow-list (incl. prefix matches).
+func classifyStyleProperty(name string) bool {
+	name = strings.ToLower(strings.TrimSpace(name))
+	if name == "" {
+		return false
+	}
+	if allowedStyleProps[name] {
+		return true
+	}
+	for _, p := range stylePropAllowedPrefixes {
+		if strings.HasPrefix(name, p) {
+			return true
+		}
+	}
+	return false
+}
+
+// isEventHandlerAttr reports whether the attribute name is a DOM event
+// handler (`on*`). The lib removes every such attribute regardless of its
+// value (spec §4.4 row "错误(删除)" + the well-known XSS vector).
+func isEventHandlerAttr(name string) bool {
+	name = strings.ToLower(strings.TrimSpace(name))
+	if !strings.HasPrefix(name, "on") {
+		return false
+	}
+	if len(name) <= 2 {
+		return false
+	}
+	// Defence-in-depth: avoid matching legitimate attrs whose name happens
+	// to begin with "on" (e.g. `onerror`-like attrs all start "on" + ascii
+	// letter). The `>= 'a'` check filters out "on-something" with hyphens.
+	c := name[2]
+	return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
+}
diff --git a/shortcuts/mail/lint/types.go b/shortcuts/mail/lint/types.go
new file mode 100644
index 0000000000..95c80a5d18
--- /dev/null
+++ b/shortcuts/mail/lint/types.go
@@ -0,0 +1,116 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+// Package lint implements the mail-domain HTML lint lib used by `+lint-html`
+// and the writing-path internals of the compose 5 shortcuts (`+send`,
+// `+draft-create`, `+reply`, `+reply-all`, `+forward`) and `+draft-edit` body
+// ops. The lib classifies HTML tags / attributes / inline styles into three
+// tiers (pass / warn-and-autofix / error-delete) per technical-design §4.4 and
+// the mail-editor `editor-kit` branch DOMPurify config (`ALLOW_UNKNOWN_PROTOCOLS:
+// true`, `forbidTags: ['style']`), but stricter — ``:             RuleTagStyleBlocked,
 		``:    RuleTagLinkBlocked,
 		``: RuleTagMetaBlocked,
 		``:          RuleTagBaseBlocked,
@@ -358,6 +372,35 @@ func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
 	}
 }
 
+// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent
+// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner
+//