From 62f5481f0ea5f21e5ef354a1b95fe353760ba903 Mon Sep 17 00:00:00 2001 From: "lijiayi.2333" Date: Wed, 6 May 2026 17:28:57 +0800 Subject: [PATCH 1/6] 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,
@@ -366,7 +380,10 @@ func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
 // 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}) + // Use
instead of

to avoid the Feishu-native paragraph + // rewrite autofix, which would surface a finding even on otherwise + // clean input. + 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) } @@ -395,10 +412,12 @@ func TestEmptyReport_HasContractFields(t *testing.T) { 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) + if len(rep.Blocked) != 0 { + t.Fatalf("unexpected blocked: %+v", rep.Blocked) } - for _, want := range []string{"line-height:1.6", "

", "title", "", "bold", "
    ", "
  • ", "
"} { + // Feishu-native autofix expected to fire on

,

    ,
  • — content + // must still survive untouched even though structure is augmented. + 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) } @@ -649,7 +668,9 @@ func TestRun_StyleMalformedDeclarationDropped(t *testing.T) { // 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}) + // Use
    to avoid the Feishu-native paragraph autofix, which adds + // a fresh style attribute on the rewritten outer wrapper. + 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) } @@ -675,7 +696,8 @@ func TestRun_StyleAttrAutoFixFalseKeepsOriginal(t *testing.T) { // TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes. func TestRun_StyleEmptyValuePassThrough(t *testing.T) { - rep := Run(`

    x

    `, Options{AutoFix: true}) + // Use
    to avoid the Feishu-native paragraph autofix. + rep := Run(`
    x
    `, Options{AutoFix: true}) if len(rep.Applied) != 0 { t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied) } @@ -685,7 +707,7 @@ func TestRun_StyleEmptyValuePassThrough(t *testing.T) { // non-empty hint (consumer contract). func TestRun_HintsForAllBlockedTags(t *testing.T) { cases := []string{ - ``, ``, ``, + ``, ``, ``, ``, `
    `, ``, ``, ``, ``, ``, diff --git a/shortcuts/mail/lint/rules.go b/shortcuts/mail/lint/rules.go index 4a9c6fa582..1780680733 100644 --- a/shortcuts/mail/lint/rules.go +++ b/shortcuts/mail/lint/rules.go @@ -16,7 +16,6 @@ const ( RuleTagMarqueeToText = "TAG_MARQUEE_TO_TEXT" RuleTagBlinkToText = "TAG_BLINK_TO_TEXT" RuleTagScriptBlocked = "TAG_SCRIPT_BLOCKED" - RuleTagStyleBlocked = "TAG_STYLE_BLOCKED" RuleTagIframeBlocked = "TAG_IFRAME_BLOCKED" RuleTagObjectBlocked = "TAG_OBJECT_BLOCKED" RuleTagEmbedBlocked = "TAG_EMBED_BLOCKED" @@ -34,6 +33,19 @@ const ( // Style-level rules. RuleStylePropertyDropped = "STYLE_PROPERTY_DROPPED" + + // Feishu-native autofix rules. These autofix the inline style / + // class / nesting shape of common elements so AI-authored HTML + // matches what Feishu mail-editor itself emits, fixing the visual + // "extra blank line between blocks", "list bullets/numbers missing", + // "link color wrong" etc. classes of issues. The rewrite is purely + // additive — user-supplied inline styles take precedence; the lib + // only fills the missing properties. + RuleStyleListNative = "STYLE_LIST_NATIVE_INLINE_APPLIED" + RuleStyleListItemNative = "STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED" + RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED" + RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED" + RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN" ) // Tag classification ---------------------------------------------------------- @@ -101,7 +113,6 @@ var allowedTags = map[string]bool{ // maps to the rule id surfaced in Finding.RuleID. var blockedTags = map[string]string{ "script": RuleTagScriptBlocked, - "style": RuleTagStyleBlocked, "iframe": RuleTagIframeBlocked, "object": RuleTagObjectBlocked, "embed": RuleTagEmbedBlocked, @@ -275,9 +286,11 @@ var allowedStyleProps = map[string]bool{ "overflow": true, "overflow-wrap": true, "vertical-align": true, - "list-style": true, - "list-style-type": true, - "font-family": true, + "list-style": true, + "list-style-type": true, + "list-style-position": true, + "transition": true, + "font-family": true, "text-transform": true, "hyphens": true, "max-width": true, diff --git a/shortcuts/mail/lint/types.go b/shortcuts/mail/lint/types.go index 95c80a5d18..b6e9a95964 100644 --- a/shortcuts/mail/lint/types.go +++ b/shortcuts/mail/lint/types.go @@ -6,10 +6,10 @@ // `+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 — ` + +
    + +
    +

    [调研主题] 市场调研报告

    +

    [YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]

    +
    + +
    +

    调研背景

    +

    [一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 [N] 类玩家([类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1:公开资料 / 厂商文档 / 行业报告] + [数据来源 2:自有实测 / 内部调研笔记] + [数据来源 3:访谈 / 体验]。

    +
    + +
    +
    +
    [N]
    +
    调研对象
    +
    +
    +
    [N]
    +
    已就绪能力
    +
    +
    +
    [N]
    +
    明确缺口
    +
    +
    +
    [N]
    +
    高优待办
    +
    +
    + +
    +

    1. [章节标题:例 "全球市场态势"]

    +

    [一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    玩家 / 对象定位 / 类型[关键评分维度]关键观察
    [玩家 1][类别][标签][一句话观察]
    [玩家 2][类别][标签][一句话观察]
    [玩家 3][类别][标签][一句话观察]
    [玩家 4][类别][标签][一句话观察]
    +
    + +
    +

    2. [章节标题:例 "接入摩擦点"] ⚠️ 风险

    +

    [一句话描述:从哪里观察 / 案例 / 数据来源]

    + + + + + + + + + + + + + + + + + + + + + + + +
    摩擦类型 / 维度具体表现业务影响
    [摩擦 1][具体表现 / 案例][对业务 / 团队的影响]
    [摩擦 2][具体表现][影响]
    [摩擦 3][具体表现][影响]
    +
    + +
    +

    3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]

    +
    +
    +
    [玩家 / 对象 1]
    +
    [一句话产品定位 / 核心能力 / 差异化]
    +
    关键差异:[一句话提炼]
    +
    +
    +
    [玩家 / 对象 2]
    +
    [产品定位]
    +
    关键差异:[一句话]
    +
    +
    +
    [玩家 / 对象 3]
    +
    [产品定位]
    +
    关键差异:[一句话]
    +
    +
    +

    [小结一句话:玩家共性 / 自家路线对比]

    +
    + +
    +

    4. [章节标题:例 "安全风险全景" / "潜在隐患"] ⚠️ 高危

    +

    [一句话描述:风险来源 / 关联前期工作]

    + + + + + + + + + + + + + + + + + + + + + + + +
    威胁 / 风险案例 / 来源自家现状
    [风险 1][案例 / 来源链接 / 引用前期报告][标签]
    [风险 2][案例 / 来源][标签]
    [风险 3](重点)[案例 / 来源][标签]
    +
    + 结论:[一段话,提炼本章节最关键的判断 / 行动建议] +
    +
    + +
    +

    5. [章节标题:例 "自家已就绪能力"] ✓ 优势

    +

    [一句话描述:基于哪些 PR / 已交付的工作得出]

    +
      +
    • [能力 1] — [简述 + 关联 PR / 文档链接]
    • +
    • [能力 2] — [简述]
    • +
    • [能力 3] — [简述]
    • +
    • [能力 4] — [简述]
    • +
    +
    + +
    +

    6. [章节标题:例 "待补能力 / 机会清单"]

    +

    [一句话描述:清单口径 / 优先级判定依据]

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #优先级能力 / 缺口建议落地
    1P0[能力 / 缺口 1][具体落地路径 / Owner / 估算]
    2P0[能力 / 缺口 2][具体落地路径]
    3P1[能力 / 缺口 3][具体落地路径]
    4P1[能力 / 缺口 4][具体落地路径]
    5P2[能力 / 缺口 5][具体落地路径]
    +
    + +
    +

    关联工作产出佐证

    +

    本调研报告中部分章节的依据来自下列在执行中的工作:

    + +
    + +
    +

    建议与下一步

    +
      +
    1. [行动 1] — [具体路径 + 时间窗 + Owner]
    2. +
    3. [行动 2] — [具体路径 + 时间窗]
    4. +
    5. [行动 3] — [具体路径]
    6. +
    7. [行动 4] — [具体路径]
    8. +
    +
    + +
    +

    此报告由 [生成方式:Claude Code + lark-cli / 人工整理 / 半自动] 整合生成

    +

    调研者:[your@email] · [团队] | 关联材料:[文档 / 笔记路径 / 前期报告]

    +
    + +
    diff --git a/skills/lark-mail/assets/templates/weekly--personal-report.html b/skills/lark-mail/assets/templates/weekly--personal-report.html new file mode 100644 index 0000000000..8961c0dcd0 --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--personal-report.html @@ -0,0 +1,43 @@ + +
    [姓名] 个人工作周报 · [YYYY 第 NN 周]
    +
    [团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]
    + +
    本周工作内容
    + +
    1. [项目 / 主任务名称]已完成 · 📄 文档 · PR 链接
    +
    • [子项 1.1:动作描述,附数据 / 链接]
    • [子项 1.2:动作描述]
    • [子项 1.3:动作描述,含具体数字 / 占比 / 时长]
    + +
    2. [项目 / 主任务名称]进行中 · 📄 文档
    +
    • [子项 2.1:动作 + 当前进度 + 数据]
    • [子项 2.2:动作 + 当前进度]
    + +
    3. [项目 / 主任务名称]已完成
    +
    • [子项 3.1]
    • [子项 3.2]
    + +
    下周工作内容
    + +
    1. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
    +
    • [子项 1.1:具体动作 + 推进方式,例「先 spike POC,再发 RFC 同协作方对齐方案」]
    • [子项 1.2:里程碑 / 关键产出 + 完成方式]
    • [子项 1.3:依赖 / 协作方 / 验收标准]
    + +
    2. [项目 / 主任务名称]P0 · 预计 [YYYY-MM-DD]
    +
    • [子项 2.1:动作 + 推进方式]
    • [子项 2.2:里程碑 / 关键产出]
    • [子项 2.3:依赖 / 验收]
    + +
    3. [项目 / 主任务名称]P1 · 预计 [YYYY-MM-DD]
    +
    • [子项 3.1:动作 + 推进方式]
    • [子项 3.2:里程碑]
    • [子项 3.3:协作方]
    + +
    4. [项目 / 主任务名称]P2 · 预计 [YYYY-MM-DD]
    +
    • [子项 4.1:动作 + 推进方式]
    • [子项 4.2:依赖 / 关键产出]
    + +
    风险与疑问
    +
    • [风险 / 疑问 1] — [背景:描述风险来源 / 触发场景];[影响:会延期 / 阻塞哪些工作];[建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]
    • [风险 / 疑问 2] — [背景];[影响];[建议]
    • [风险 / 疑问 3] — [背景];[影响];[建议]
    +
    (若本周无风险 / 疑问,整段替换为:。)
    + +
    — [姓名] / [团队] / [日期]|[your@email]
    diff --git a/skills/lark-mail/references/mail-feishu-native-snippets.md b/skills/lark-mail/references/mail-feishu-native-snippets.md index 5b8c0ed85d..808f8ace1e 100644 --- a/skills/lark-mail/references/mail-feishu-native-snippets.md +++ b/skills/lark-mail/references/mail-feishu-native-snippets.md @@ -54,6 +54,48 @@ > **关键 marker**:`
      ` 必带 `data-list-bullet="true"`;每个 `
    • ` 必带 `class="temp-li bullet1"` + `data-li-line="true"` + `data-list="bullet1"` + `dir="auto"`(**ul 内 li 不需要 data-ol-id 和 data-start**,那是 ol 专用)。其他规则同 ol。 +## 缩进 / 列表嵌套 + +飞书 mail-editor 的"缩进"通过**列表嵌套 + `margin-left:24px`** 实现,不要用 `

      ` 或 ` ` 模拟。lcpr 写信路径下两种等价 pattern 任选其一: + +**Pattern A(推荐 / 简洁):`

      ` 包裹单层 ul/ol** + +```html +
      1. 父级标题(普通 div,不是 list)
      +
      • 子项一
      • 子项二
      +``` + +多级缩进就嵌套多个 div:`
      ...
      ` = 48px 缩进。 + +**Pattern B(mail-editor 原生):嵌套 ul/ol + 内层 `margin:0 0 0 24px` shorthand** + +```html +
        • 缩进的子项一
        • 缩进的子项二
      +``` + +> 内层用 `margin:0 0 0 24px` 必须是 **shorthand**(4 值短写法)。lint 的 `hasInlineStyleProp` 会识别 shorthand 已声明 margin-left,不再追加 `margin-left:0` 覆盖你的 24px。如果误写成长写法 `margin-left:24px;margin-top:0;...`,lint 也能识别保留。 + +**Pattern C:ol(1) → 嵌入 ul circle → ol(2) 接续编号** + +```html +
      1. 第一项
        • 第一项的子项
      1. 第二项
      +``` + +> 显式写 `start="2"` + `data-start="2"` + 共享 `data-ol-id`;lint 的 `ensureAttr` 不覆盖已存在的属性,所以这套接续语义保留。`
        ` 的 start 属性写在 attribute 而不是 style 里,更不受 ensureFeishuListStyle 影响。 + +**Pattern D:ol decimal → 嵌入 ol lower-alpha(数字 1/2/3 → 字母 a/b/c)** + +```html +
          1. 字母子项 a
          2. 字母子项 b
        +``` + +> **关键 marker(嵌套 / 缩进)**: +> - **Pattern A** 的外层 div 用 `padding-left:24px`(lint 不动 div padding,最稳);适合 div 编号 + 子项 list 的层级 +> - **Pattern B/C/D** 的内层 list 用 `margin:0 0 0 24px` shorthand;lint 修复后 shorthand 内的 margin-left:24px 不会被覆盖(`hasInlineStyleProp` 识别 shorthand) +> - **子层 li class 必须降一级**:`bullet1` → `bullet2`,`number1` → `number2`;对应 list-style-type:`disc` → `circle`,`decimal` → `lower-alpha` +> - **ol 编号接续**:同一 list 集合(哪怕被嵌套 ul 打断)共享 `data-ol-id`;ol 父项的 `data-start="N"` 和 `
          ` 跨嵌套继续递增;ul 子层若属于某个 ol 集合也带同一 `data-ol-id` +> - **每层 li 之间不能有空白文本节点**(同顶层规则;lint autofix 会 strip 掉) + ## 引用块(blockquote) ```html From bc180b0b0fef1ccd5d142c507e99e795406ffd41 Mon Sep 17 00:00:00 2001 From: "lijiayi.2333" Date: Fri, 8 May 2026 15:50:09 +0800 Subject: [PATCH 6/6] feat(mail/templates): add job-application + team weekly; document mention + highlight snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #756 follow-up. Two new MVP templates pre-rendered in Feishu mail-editor native shape: job-application--resume.html: - 22px Feishu deep blue title (姓名) + 14px grey 应聘 [职位] subtitle - 9 sections under 16px bold headers with thin bottom border: 基本信息 (4-row table) / 教育经历 / 工作经历 / 项目经历 / 技能 / 证书 / 语言能力 / 竞赛 / 获奖 / 自我评价 - ol main item + ul sub-item nested for work / project history - 10 skill chips with pill border-radius + Feishu blue on rgb(232,243, 255) bg - Subject template encoded as HTML comment at top, all placeholders weekly--team-report.html: - Section header style: 18px bold black title + Feishu blue (rgb(36,91, 219)) bottom border 3px, width auto-fits title via display:inline- block + padding-bottom - Top-level ol main items with shared data-ol-id (weekly-this / weekly-next) for cross-list numbering continuation across nested ul - 3 本周 projects with mail-editor native nested ul/ol shorthand margin:0 0 0 24px (works with the lint shorthand-margin fix from commit 2e6b28b); project 2 includes a 3rd-level nested ul (bullet3 + square) demoing grandchild items - 4 下周 items with sub-numbered ol (number2 + lower-alpha) for sub- priorities under boldless top-level priorities - Status text inline at li tail (,已完成 / 进行中 / 评审中 / 阻塞 / 待启动) per Xulong 0613 weekly style — no badges - Yellow highlight on 阻塞 status (background-color rgb(255,225,140) + black text rgb(31,35,41), no padding/border-radius / no bold — inline highlighter style, distinct from chip badges) - 20 mention chips with id="at-user-N" hardcoded marker so Feishu client recognizes via isAtUserDOM (no data-user-id since OAPI has no legitimate path to fetch user_id) - Top SUBJECT comment only — no other meta instructions Snippets updates (references/mail-feishu-native-snippets.md): - @用户 mention (药丸 chip) section rewrite: only id="at-user-N" + mailto + style + rel are required; data-user-id removed entirely (实测 isAtUserDOM 不查这个字段 + OAPI 没合法途径获取真实 user_id) - New 文字高亮 (荧光笔风格) section: 3-color palette for inline highlighting (粉色 rgb(255,200,220) 关键里程碑 / 黄色 rgb(255,225, 140) 待关注 / 浅绿 rgb(190,230,200) 已完成), unified text fg=rgb(31,35,41), no bold, no padding/border-radius — replaces the badge approach with marker-pen-style inline highlighting Co-Authored-By: Claude Opus 4.7 (1M context) --- .../templates/job-application--resume.html | 40 ++++++++++++++++++ .../assets/templates/weekly--team-report.html | 19 +++++++++ .../references/mail-feishu-native-snippets.md | 42 ++++++++++++++++++- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 skills/lark-mail/assets/templates/job-application--resume.html create mode 100644 skills/lark-mail/assets/templates/weekly--team-report.html diff --git a/skills/lark-mail/assets/templates/job-application--resume.html b/skills/lark-mail/assets/templates/job-application--resume.html new file mode 100644 index 0000000000..bf1aa5a441 --- /dev/null +++ b/skills/lark-mail/assets/templates/job-application--resume.html @@ -0,0 +1,40 @@ + +
          尊敬的 [收件人称呼,如 HR / 招聘负责人 / 部门 leader]:
          +
          您好!
          +
          我是 [姓名],[工作年限] 工作经验的 [岗位领域,如 后端工程师 / 产品设计师 / 数据分析师]。关注到贵司正在招聘 [期望职位],结合自身背景与岗位要求高度匹配,特此投递简历,期待加入团队的机会。详细信息见下方简历正文。
          +
          [姓名]
          +
          应聘 [期望职位]|[工作年限] 工作经验
          +
          基本信息
          +
          姓名[姓名]性别[性别]
          电话[+86 1XX-XXXX-XXXX]邮箱[your@email]
          生日[YYYY-MM-DD]([N] 岁)工作年限[N] 年
          家乡[城市]当前城市[城市]
          意向城市[城市 1] / [城市 2] / 不限期望职位[期望职位]
          +
          教育经历
          +
          1. [学校名称] · [学历,如 本科 / 硕士 / 博士] · [专业] · [YYYY-MM] ~ [YYYY-MM]
          2. [学校名称] · [学历] · [专业] · [YYYY-MM] ~ [YYYY-MM]
          +
          工作经历
          +
          1. [公司名称] · [职位] · [全职 / 实习 / 兼职] · [YYYY-MM] ~ [YYYY-MM 或 至今]
          +
            • [工作职责描述 1:聚焦动作 + 产出,附数据 / 影响范围]
            • [工作职责描述 2:核心成果 + 关键技术 / 方法]
            • [工作职责描述 3]
          +
          1. [公司名称] · [职位] · [全职 / 实习 / 兼职] · [YYYY-MM] ~ [YYYY-MM]
          +
            • [工作职责描述 1]
            • [工作职责描述 2]
          +
          项目经历
          +
          1. [项目名称] · [角色,如 负责人 / 核心开发 / 设计主导] · [YYYY-MM] ~ [YYYY-MM]
          +
            • [项目背景 / 业务价值 1 句话]
            • [关键贡献 / 技术栈]
            • [项目成果 / 数据指标]
          +
          1. [项目名称] · [角色] · [YYYY-MM] ~ [YYYY-MM]
          +
            • [项目描述 + 关键贡献 + 成果数据]
          +
          技能
          +
          [技能 1][技能 2][技能 3][技能 4][技能 5][技能 6][技能 7][技能 8]
          +
          证书
          +
          • [证书名称] · [YYYY-MM] — [一句话描述:颁发机构 / 等级 / 用途]
          • [证书名称] · [YYYY-MM] — [描述]
          +
          语言能力
          +
          • [语言,如 中文 / 英文 / 日语] — [精通程度:母语 / 流利 / 商务 / 日常 / 入门]
          • [语言] — [精通程度] / [证书或考试成绩,如 CET-6 590、TOEFL 105、JLPT N1]
          +
          竞赛信息
          +
          • [竞赛名称] · [YYYY-MM] — [名次 / 角色 + 一句话描述]
          • [竞赛名称] · [YYYY-MM] — [描述]
          +
          获奖信息
          +
          • [获奖名称] · [YYYY-MM] — [颁发机构 / 评选范围 + 一句话描述]
          • [获奖名称] · [YYYY-MM] — [描述]
          +
          自我评价
          +
          [2-3 句话简评:技术深度 / 协作风格 / 长期方向,与岗位要求高度契合的方向。建议聚焦"为什么我适合这个岗位",避免"努力踏实诚信"这种通用形容词。]
          +
          如需作品集 / 实习证明 / 推荐信等其它材料,欢迎进一步沟通面谈。期待您的回复。
          +
          谢谢您的时间!
          +
          此致
          +
          [姓名]
          +
          [+86 1XX-XXXX-XXXX]|[your@email]|[YYYY-MM-DD]
          diff --git a/skills/lark-mail/assets/templates/weekly--team-report.html b/skills/lark-mail/assets/templates/weekly--team-report.html new file mode 100644 index 0000000000..f416c2306a --- /dev/null +++ b/skills/lark-mail/assets/templates/weekly--team-report.html @@ -0,0 +1,19 @@ + +
          本周工作
          +
          1. [项目 / 事件 1 名称]@[姓名 a]@[姓名 b]
          +
          文档:[文档名]
          + +
          1. [项目 / 事件 2 名称]@[姓名 g]
          +
          技术方案:[文档名] · 设计稿:[设计稿名]
          +
            • [子项 2.1:含孙子项的动作主题]
              • [孙子项 2.1.1:必要时再细分一层;不需要可整段删除]@[姓名 h]
              • [孙子项 2.1.2]
            • [子项 2.2]@[姓名 i],进行中
            • [子项 2.3]@[姓名 j],评审中
          +
          1. [项目 / 事件 3 名称]@[姓名 k]@[姓名 l]阻塞
          +
          阻塞分析:[文档名]
          + +
          下周工作
          +
          1. [重点 1:项目 / 事件名]@[姓名 o],预计 [YYYY-MM-DD]
          2. [重点 2:含子重点的项目]
          +
            1. [子重点 a:动作 / 推进方式]@[姓名 p]
            2. [子重点 b:动作]@[姓名 q]
          +
          1. [重点 3:项目 / 事件名]@[姓名 r]@[姓名 s],预计 [YYYY-MM-DD]
          2. [重点 4:项目 / 事件名]@[姓名 t],预计 [YYYY-MM-DD]
          +
          — [姓名] / [团队] / [日期]|[your@email]
          diff --git a/skills/lark-mail/references/mail-feishu-native-snippets.md b/skills/lark-mail/references/mail-feishu-native-snippets.md index 808f8ace1e..bdc7151531 100644 --- a/skills/lark-mail/references/mail-feishu-native-snippets.md +++ b/skills/lark-mail/references/mail-feishu-native-snippets.md @@ -110,13 +110,51 @@ > **关键 marker**:`class="not-doclink"` 防止飞书把它误识为内部 doc share;`color:rgb(20,86,240)` 是飞书品牌蓝;`text-decoration:none` 去掉默认下划线;`rel` 三件套是 noopener noreferrer 安全属性。 -@用户 mention(药丸样式): +## @用户 mention(药丸 chip) + +飞书 mail-editor 客户端的 `isAtUserDOM` 判定**唯一硬标准是 a 标签 `id` 以 `at-user` 开头**——不要求标签类型,不需要 `data-user-id`。 + +**统一写法**(只有邮箱与显示姓名两处需要填,其余全部 hardcoded): + +```html +@姓名 +``` + +> **填写规则**: +> - **`mailto:user@bytedance.com`** —— 真实邮箱地址,唯一需要业务方填的链接目标 +> - **`@姓名`** —— 链接显示文本,按需替换为被 @ 的人名 +> +> **以下字段全部 hardcoded,不要尝试替换**: +> - **`id="at-user-N"`** —— N 用顺序编号(同文档内唯一即可,如 `at-user-1` / `at-user-2` / ...)。这是飞书客户端 `isAtUserDOM` 识别为 mention DOM 的唯一硬标准 +> - inline style 五件套 + `rel="nofollow noopener noreferrer"` —— 药丸 chip 视觉 + 安全属性,按字符照抄 + +视觉退化(如果不加 `id="at-user-*"`,飞书客户端将其作为普通链接处理,仅 inline style 形成药丸装饰,不识别为 mention DOM): ```html @姓名 ``` -> **关键 marker**:`border-radius:999em` 让链接显示成药丸形(飞书 mention chip 标准样式);`padding:2px` 给 chip 加左右内边距;`margin:0 2px` 跟前后文字留间隙。 +> **OAPI 写信通知限制**:飞书 mail OAPI 写信路径**不触发** mention 通知到飞书 IM —— 即使 marker 全套齐,被 @ 的人靠**收到邮件本身**感知,不靠飞书 IM push。模板里 mention chip 的目的是让客户端识别 + 视觉一致,不是触发通知。 + +## 文字高亮(荧光笔风格) + +嵌入文本流的彩色背景高亮,**不加 padding / border-radius**(区别于 badge / chip 标签风格),文字色统一用飞书原生黑 `rgb(31,35,41)`,**不加粗**。3 种颜色按语义场景选用: + +| 颜色 | 背景 RGB | 适用场景 | +|------|----------|----------| +| 粉色 | `rgb(255,200,220)` | 重要数据 / 关键里程碑 | +| 黄色 | `rgb(255,225,140)` | 待关注 / 需跟进 / 阻塞状态 | +| 浅绿 | `rgb(190,230,200)` | 已完成 / 关键成果 | + +写法(文字 fg 全部 `rgb(31,35,41)`): + +```html +关键里程碑数据 +阻塞 / 待跟进事项 +已完成 / 关键成果 +``` + +> **使用原则**:高亮是替代加粗的强调手段(参见父项加粗一致性原则)。一封邮件里同种语义统一用同种颜色(如所有"阻塞"都用黄色),不要混用。粉色容易抢眼,留给最关键的 1-2 处数据。 ## 文字强调