Skip to content

feat(mail): add HTML lint lib + +lint-html shortcut + write-path enforcement#756

Closed
bubbmon233 wants to merge 2 commits into
larksuite:mainfrom
bubbmon233:harness/01kqy6y49nzgktyd0abjgjthrv
Closed

feat(mail): add HTML lint lib + +lint-html shortcut + write-path enforcement#756
bubbmon233 wants to merge 2 commits into
larksuite:mainfrom
bubbmon233:harness/01kqy6y49nzgktyd0abjgjthrv

Conversation

@bubbmon233

@bubbmon233 bubbmon233 commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add HTML lint capability to lark-cli mail: a new +lint-html shortcut and a reusable mail-domain lint library, plus mandatory sanitization on compose / draft-edit write paths so unsafe or malformed HTML can't reach drafts.

Changes

  • New shortcuts/mail/lint package (97% test coverage)
  • New +lint-html shortcut as the CLI entry point
  • Enforced sanitization on 5 compose write paths and the draft-edit patch path
  • 3 reference docs and 3 examples added under references/
  • SKILL.md updated to expose +lint-html trigger keywords
  • Unit + integration tests

Test Plan

  • Unit tests pass (shortcuts/mail/lint ~97% coverage)
  • Manual local verification confirms the lark mail +lint-html <input> command works as expected
  • Compose / draft-edit sanitization verified against fixture HTML inputs

Related Issues

  • None

Summary by CodeRabbit

  • New Features

    • Added an HTML linting suite for mail with auto-fix and strict modes; integrated linting into draft create/edit/forward/reply/send flows and exposed cleaned HTML and lint metadata in outputs.
    • Added a standalone lint-html command for local HTML validation and reporting.
  • Documentation

    • Added HTML writing/style guidelines, an allowlist reference, and lint-html usage docs with examples and output format.
  • Tests

    • Extensive test coverage added for lint rules, write-path integration, and the lint-html command.

…rcement

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 — <script> /
<style> / <iframe> / on*-handlers / javascript:/vbscript: URLs are removed
unconditionally. The lib is consumed by:

- A new read-only +lint-html shortcut (--body / --body-file / --auto-fix /
  --strict). Returns {warnings, errors, cleaned_html}.

- The compose-5 write paths (+send / +draft-create / +reply / +reply-all /
  +forward) and +draft-edit body ops (set_body / set_reply_body), which
  call the lib BEFORE emlbuilder; the stdout envelope always carries
  lint_applied[] and original_blocked[] arrays so callers can rely on them
  unconditionally. There is no --no-lint opt-out — that's the safety
  contract per spec §4.3.

For reply / reply-all / forward the lint runs on the user-authored body +
signature only; the <blockquote> derived from the original message is left
intact (server-side RemoteSanitizer already vetted it, double-sanitising
would risk dropping legitimate Lark quote-block class markup).

Adds three reference docs (HTML allowlist, Feishu native writing + 3
complete reusable templates, +lint-html usage) and updates the
skill-template main doc to link them and forbid hand-rolled raw EML.

No new third-party deps — reuses golang.org/x/net/html which is already in
go.mod via mail/draft. Lint package coverage 97.0%.

sprint: S4
@CLAassistant

CLAassistant commented May 6, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ bubbmon233
❌ fengzhihao.infeng


fengzhihao.infeng seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions github-actions Bot added domain/mail PR touches the mail domain size/XL Architecture-level or global-impact change labels May 6, 2026
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ae08f54b-0545-456a-8160-393295712264

📥 Commits

Reviewing files that changed from the base of the PR and between 62f5481 and 520b9db.

📒 Files selected for processing (1)
  • shortcuts/mail/mail_quote.go

📝 Walkthrough

Walkthrough

Adds a new Go HTML linting package for mail (types, rules, linter) and integrates write‑path linting into mail flows (draft create/edit/forward/reply/send) plus a read‑only +lint-html shortcut. Also adds tests and developer-facing documentation and allowlist reference materials.

Changes

HTML Linting Implementation & Integration

Layer / File(s) Summary
Type & Rule Definitions
shortcuts/mail/lint/types.go, shortcuts/mail/lint/rules.go
New public types (Severity, Finding, Options, Report), rule ID constants, allowlists for tags/URL schemes/CSS properties, and classification functions (classifyTag, classifyURLValue, classifyStyleProperty, isEventHandlerAttr).
Core Linting Engine
shortcuts/mail/lint/linter.go, shortcuts/mail/lint/linter_test.go
New Run(html string, opts Options) Report, DOM walker, attribute/style sanitizers, tag rewrites (font→span, center→div, marquee/blink→text), excerpt truncation (MaxExcerptBytes), and comprehensive unit tests for pass-through, autofix, blocking, and edge cases.
Write-Path Lint Helpers
shortcuts/mail/mail_lint_writepath.go, shortcuts/mail/mail_lint_writepath_test.go
Adds runWritePathLint() (AutoFix enabled) and applyLintToEnvelope() plus helpers to ensure envelope keys (lint_applied, original_blocked) are present; tests for plain-text short-circuit, autofix behavior, and envelope population.
Mail Shortcut Integration
shortcuts/mail/mail_draft_create.go, shortcuts/mail/mail_draft_edit.go, shortcuts/mail/mail_forward.go, shortcuts/mail/mail_reply.go, shortcuts/mail/mail_reply_all.go, shortcuts/mail/mail_send.go, and tests
Wires write-path lint into compose flows: initializes lint envelope fields, runs runWritePathLint() on HTML bodies, replaces body with cleaned HTML when applicable, and surfaces lint_applied / original_blocked in outputs. buildRawEMLForDraftCreate updated to return lint data; tests updated accordingly.
Lint HTML Command
shortcuts/mail/mail_lint_html.go, shortcuts/mail/mail_lint_html_test.go, shortcuts/mail/shortcuts.go
Adds MailLintHTML shortcut (+lint-html) with --body/--body-file/--auto-fix/--strict flags, input validation, read helpers, pretty-print and JSON envelope output; tests cover flag validation, file safety, strict mode, and JSON serializability.
Regex Adjustment for HTML Detection
shortcuts/mail/mail_quote.go
Expanded htmlTagRe to include additional tags referenced by lint warn/autofix lists and added explanatory comment relevant to bodyIsHTML detection.

Documentation & Reference Guides

Layer / File(s) Summary
Skill & Domain Docs
skills/lark-mail/SKILL.md, skill-template/domains/mail.md
Inserted HTML writing/style guidance, linting workflow description, and added a +lint-html shortcut entry into the shortcuts table.
Reference Guides
skills/lark-mail/references/lark-mail-html-allowlist.md, skills/lark-mail/references/lark-mail-feishu-native.md, skills/lark-mail/references/lark-mail-lint-html.md
Three new documents: comprehensive HTML/CSS/URL allowlist, Feishu-native email writing guide with templates, and lint-html usage/return-value guide and safety notes.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant LintCmd as +lint-html
    participant Reader as HTML Reader
    participant Linter as lint.Run()
    participant Parser as HTML Parser
    participant Walker as DOM Walker
    participant Classifier as Rule Classifier
    participant Output as JSON Output

    User->>LintCmd: invoke with --body / --body-file
    LintCmd->>Reader: readLintHTMLBody()
    Reader-->>LintCmd: rawHTML
    LintCmd->>Linter: Run(rawHTML, Options{AutoFix,Strict})
    Linter->>Parser: parse fragment
    Parser-->>Linter: DOM nodes
    Linter->>Walker: walk nodes
    Walker->>Classifier: classifyTag / classifyURLValue / classifyStyleProperty
    Classifier-->>Walker: kind, ruleID / allowed?
    Walker->>Linter: append Finding (applied/blocked)
    Linter-->>LintCmd: Report{Applied, Blocked, CleanedHTML}
    LintCmd->>Output: render envelope JSON / pretty
    Output-->>User: result
Loading
sequenceDiagram
    participant User
    participant MailSend as mail_send.go
    participant Resolver as Image Resolver
    participant WriteLint as runWritePathLint()
    participant Linter as lint.Run()
    participant Envelope as Output Envelope

    User->>MailSend: Execute send with body HTML
    MailSend->>Resolver: resolveLocalImages()
    Resolver-->>MailSend: resolvedHTML
    MailSend->>WriteLint: runWritePathLint(resolvedHTML)
    WriteLint->>Linter: Run(html, Options{AutoFix:true})
    Linter-->>WriteLint: Report{CleanedHTML, Applied, Blocked}
    WriteLint-->>MailSend: cleanedHTML, report
    MailSend->>MailSend: replace body with cleanedHTML
    MailSend->>Envelope: applyLintToEnvelope(report)
    Envelope-->>User: send / saved-draft output with lint_applied and original_blocked
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • larksuite/cli#205: Modifies mail compose paths and local-image/CID handling—overlaps with write-path processing added here.
  • larksuite/cli#318: Refactors buildRawEMLForDraftCreate and compose flows; touches same integration points as this lint plumbing.
  • larksuite/cli#438: Changes draft creation/result handling and envelope construction, related to how lint fields are propagated.

Suggested labels

enhancement

Suggested reviewers

  • chanthuang
  • haidaodashushu
  • infeng

Poem

🐰 I hopped through tags both old and new,
turning to , and trimming <script> too.
Warnings I whisper, errors I block,
keeping mail safe—one tidy hop.
Cleaned HTML: a carrot for you! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly summarizes the main change: adding HTML linting capability with a new lint library, CLI shortcut, and write-path enforcement for the mail domain.
Description check ✅ Passed The PR description provides all required sections: a concise summary, a detailed list of changes, a test plan with verification checkboxes, and related issues. The description is comprehensive and directly addresses the change scope.
Docstring Coverage ✅ Passed Docstring coverage is 96.43% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented May 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.70412% with 71 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.64%. Comparing base (d317493) to head (520b9db).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
shortcuts/mail/mail_lint_html.go 59.49% 25 Missing and 7 partials ⚠️
shortcuts/mail/lint/linter.go 95.05% 9 Missing and 5 partials ⚠️
shortcuts/mail/mail_draft_create.go 63.63% 8 Missing and 4 partials ⚠️
shortcuts/mail/mail_draft_edit.go 46.66% 8 Missing ⚠️
shortcuts/mail/mail_lint_writepath.go 77.77% 2 Missing and 2 partials ⚠️
shortcuts/mail/shortcuts.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #756      +/-   ##
==========================================
+ Coverage   65.08%   65.64%   +0.55%     
==========================================
  Files         503      513      +10     
  Lines       46581    47293     +712     
==========================================
+ Hits        30318    31044     +726     
+ Misses      13623    13593      -30     
- Partials     2640     2656      +16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@520b9dbaf187494a4a83a5ff089c93006e42d4b4

🧩 Skill update

npx skills add bubbmon233/cli#harness/01kqy6y49nzgktyd0abjgjthrv -y -g

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
shortcuts/mail/mail_forward.go (1)

245-278: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a +forward regression test for the authored-only lint boundary.

This path now has forward-specific behavior: lint the user-authored prefix/signature, but leave the forwarded quote untouched, and emit lint_applied / original_blocked in the output. I only see draft-create coverage in the added tests, so this contract can regress without detection.

As per coding guidelines "Every behavior change must have an accompanying test".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_forward.go` around lines 245 - 278, Add a regression test
that exercises the HTML-forward path to ensure linting only touches the authored
prefix/signature and leaves the forwarded quote untouched: create a test (e.g.
TestForward_LintAuthoredOnly) that builds a forward request triggering the
branch in mail_forward.go (set useHTML true, include an original message with a
forward quote built via buildForwardQuoteHTML and a user-authored body that
runWritePathLint will modify), call the forward handling code to produce
composedHTMLBody, and assert the emitted lint flags/metrics include lint_applied
and original_blocked (or that lintBlocked is set for the original part) so the
contract around runWritePathLint + composedHTMLBody/regression for
forward-specific behavior is covered.
🧹 Nitpick comments (6)
shortcuts/mail/lint/types.go (1)

84-87: 💤 Low value

Doc nit: Blocked also receives strict-promoted warnings.

The comment states Blocked only surfaces error-tier findings, but in linter.go (processElement and the URL-warning attribute branch) warnings get appended to Blocked when opts.Strict is true. Consider mentioning this for accuracy.

📝 Suggested wording
-	// Blocked surfaces error-tier findings that the lib removed unconditionally
-	// (writing-path safety floor: <script> / on* / javascript: URLs always go,
-	// regardless of AutoFix).
+	// Blocked surfaces error-tier findings that the lib removed unconditionally
+	// (writing-path safety floor: <script> / on* / javascript: URLs always go,
+	// regardless of AutoFix). Strict mode also promotes warning-tier findings
+	// into Blocked so callers can use a single field to drive non-zero exits.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/lint/types.go` around lines 84 - 87, Update the comment for
the Blocked field to state that it contains not only error-tier findings removed
unconditionally but also warnings that are promoted to blocked when opts.Strict
is enabled; reference the logic in linter.go (processElement and the URL-warning
attribute branch) which appends warnings to Blocked under strict mode so the
comment accurately reflects both sources.
shortcuts/mail/mail_lint_writepath.go (1)

62-82: 💤 Low value

Two helpers that do the same thing — consider keeping just one.

emptyLintEnvelopeFields and emptyLintFindings are byte-identical apart from the result-name labels and the doc-comment framing. Keeping both invites drift if one is later updated (e.g., to allocate a shared zero-value singleton). Since the file is new, picking one name now costs nothing.

♻️ Suggested consolidation
-// emptyLintEnvelopeFields returns the writing-path stdout-envelope fields
-// representing "no lint pass occurred" (e.g. plain-text body branch). Used by
-// compose 5's plain-text path so the public envelope still carries the
-// contract keys as empty arrays.
-func emptyLintEnvelopeFields() (lintApplied, originalBlocked []lint.Finding) {
-	return []lint.Finding{}, []lint.Finding{}
-}
-
 // lintFinding aliases the lint package's Finding type for callers that don't
 // want to import shortcuts/mail/lint directly (e.g. function signatures in
 // existing mail_*.go files that want to keep their import set minimal). It is
 // purely a syntactic convenience — both names refer to the same struct.
 type lintFinding = lint.Finding

-// emptyLintFindings returns two non-nil empty Finding slices, used by helpers
-// that initialise their outputs before knowing whether the body is HTML.
-// Equivalent to emptyLintEnvelopeFields but named to reflect "findings" rather
-// than "envelope fields" so call-sites read consistently with their context.
-func emptyLintFindings() (applied, blocked []lint.Finding) {
+// emptyLintFindings returns two non-nil empty Finding slices used by both the
+// plain-text write path (where no lint pass occurs) and helpers that
+// initialise their outputs before knowing whether the body is HTML.
+func emptyLintFindings() (applied, blocked []lint.Finding) {
 	return []lint.Finding{}, []lint.Finding{}
 }

Then update any callers of emptyLintEnvelopeFields to use emptyLintFindings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_lint_writepath.go` around lines 62 - 82, Remove the
duplicate helper by keeping only one of the identical functions (preferably
emptyLintFindings for naming clarity) and delete emptyLintEnvelopeFields; ensure
you preserve the returned types (two non-nil empty []lint.Finding slices) and
the doc-comment intent; then update all callers that reference
emptyLintEnvelopeFields to call emptyLintFindings instead (search for usages and
replace the symbol), and run tests/linters to confirm no references remain.
shortcuts/mail/lint/rules.go (2)

127-156: 💤 Low value

Docstring claims classifyTag returns "unknown" but it never does.

The doc on Line 129 lists "unknown" as a possible kind, but the function only ever returns "allow", "warn", or "error" — the unknown-tag branch on Line 155 returns "allow" with an empty rule id. Either drop the mention from the docstring or actually return "unknown" so the caller in linter.go:processElement can distinguish niche tags.

📝 Suggested wording fix
-// 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. <object>-like). The cli's existing
+// classifyTag returns the rule kind for the given lower-case tag name.
+//
+// kind is one of "allow", "warn", "error". For "warn" / "error", ruleID
+// names the firing rule. Unknown / niche tags fall through to "allow" with
+// an empty ruleID — the cli's existing
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/lint/rules.go` around lines 127 - 156, The docstring
incorrectly lists "unknown" as a possible kind but classifyTag never returns it;
update classifyTag to return "unknown" (with an empty ruleID) for
niche/structurally-suspect tags instead of falling through to "allow" so callers
like processElement in linter.go can distinguish them; specifically, change the
final return in classifyTag (and adjust any comments) to return "unknown", ""
and verify processElement handles the "unknown" branch appropriately.

304-318: 💤 Low value

Prefix match admits unknown border-* / padding-* / margin-* properties.

classifyStyleProperty accepts any property starting with border-, padding-, or margin- — including non-standard ones like border-foo or padding-x-custom. In practice CSS doesn't define such names, but combined with the explicit allow-list philosophy this is a slightly looser policy than what the user-facing allowlist doc implies. Consider enumerating the eight valid border-* directional/property variants explicitly if you want a tight allow-list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/lint/rules.go` around lines 304 - 318, The
classifyStyleProperty function currently allows any property starting with
border-, padding-, or margin-, which could admit invalid CSS properties. To fix
this, replace the prefix checks for these groups with explicit enumeration of
the valid variants, listing the eight accepted border-* properties and valid
padding-* and margin-* properties explicitly in allowedStyleProps or a separate
list. Update classifyStyleProperty to only return true for these exact known
properties instead of any prefixed string. This tightening aligns the function
with the explicit allow-list approach.
shortcuts/mail/lint/linter.go (1)

391-431: 💤 Low value

The naive semicolon split is theoretically problematic but not practically impactful for the current allowlist.

strings.Split(raw, ";") doesn't respect CSS quoted strings or url(...) contexts. However, the allowlisted properties (color, font-size, padding, margin, border, etc.) don't typically contain quoted values with embedded semicolons. The examples given (font-family and background-image) aren't allowlisted, so this edge case doesn't manifest in practice.

If the allowlist expands to include properties with complex values (e.g., font-family with multiple fallbacks), consider implementing proper CSS declaration parsing that respects quotes and parentheses, or document this limitation explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/lint/linter.go` around lines 391 - 431, The current
sanitiseStyleAttr uses strings.Split(raw, ";") which wrongly splits semicolons
inside quoted strings or url(...) values; update sanitiseStyleAttr to tokenise
declarations with a small stateful parser that iterates runes and tracks being
inside single/double quotes and parentheses so semicolons inside those contexts
are ignored, then trim and process each declaration as before (use existing
classifyStyleProperty(name) and preserve dropped-list behavior), or if you
prefer not to implement parsing now, update the function comment to explicitly
document this limitation and add a TODO referencing possible future allowlist
entries (e.g., font-family) that would require proper CSS declaration parsing.
shortcuts/mail/mail_draft_create.go (1)

188-195: ⚡ Quick win

Pretty output hides the new lint findings.

out now carries lint_applied / original_blocked, but --format pretty still prints only the draft ID/reference. That means this shortcut can silently rewrite or strip HTML without any user-visible hint in a supported output mode.

Possible lightweight fix
 		fmt.Fprintln(w, "Draft created.")
+		if len(lintApplied) > 0 || len(lintBlocked) > 0 {
+			fmt.Fprintf(w, "lint_applied: %d, original_blocked: %d\n", len(lintApplied), len(lintBlocked))
+		}
 		// Intentionally keep +draft-create output minimal: unlike reply/forward/send
 		// draft-save flows, it does not add a follow-up send tip.
 		fmt.Fprintf(w, "draft_id: %s\n", draftResult.DraftID)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_draft_create.go` around lines 188 - 195, The pretty
output in mail_draft_create.go (inside the runtime.OutFormat callback that
prints draftResult.DraftID and out["reference"]) omits the new out keys
lint_applied and original_blocked, hiding lint rewrites; update the
runtime.OutFormat writer lambda to check out["lint_applied"] and
out["original_blocked"] (or their boolean/string forms) and print concise lines
when present (e.g., "lint_applied: true" / "original_blocked: true" or the
provided values) so --format pretty surfaces these new lint-related flags
alongside draft_id and reference.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@shortcuts/mail/lint/linter.go`:
- Around line 235-265: The style-property branch in sanitisation (inside the if
name == "style" block using sanitiseStyleAttr and RuleStylePropertyDropped)
always appends Findings to rep.Applied with SeverityWarning and ignores
opts.Strict; update that branch so when opts.Strict is true any dropped property
Finding is created with SeverityError and appended to rep.Blocked (or otherwise
marked as an error) instead of rep.Applied, preserving the same
TagOrAttr/Excerpt/Hint; also ensure the code path that keeps/auto-fixes
attributes still behaves the same but promotes warnings to errors for strict
mode. Add a unit test in linter_test.go that calls Run(html, Options{Strict:
true}) with a template containing only a forbidden CSS property (e.g.
style="position:absolute") and assert rep.Blocked is non-empty /
rep.HasErrorFindings == true.

In `@shortcuts/mail/mail_lint_writepath_test.go`:
- Around line 154-160: The test currently does a type assertion like "las, _ :=
la.([]interface{})" which silently yields nil for non-array values and thus
misses regressions to null or other types; update the assertions to first verify
the value is actually a []interface{} (e.g., use the two-value form "las, ok :=
la.([]interface{}); if !ok { t.Fatalf(\"lint_applied must be an array, got %T\",
la) }") and then check its length, and do the same for "ob" / "original_blocked"
(e.g., "obs, ok := ob.([]interface{}); if !ok { t.Fatalf(\"original_blocked must
be an array, got %T\", ob) }" followed by the length check).

In `@skills/lark-mail/references/lark-mail-lint-html.md`:
- Around line 100-110: The scenario text incorrectly references original_blocked
(a write-path envelope key) instead of the actual +lint-html output shape;
update the description to state that +lint-html returns its problems under
data.errors (and/or data.warnings) and that the AI should inspect data.errors
for TAG_SCRIPT_BLOCKED, then use the cleaned_html to backfill the subsequent
+draft-create step; specifically replace mentions of original_blocked with
data.errors and reference TAG_SCRIPT_BLOCKED, +lint-html, and +draft-create so
readers can locate the relevant fields and flow.

---

Outside diff comments:
In `@shortcuts/mail/mail_forward.go`:
- Around line 245-278: Add a regression test that exercises the HTML-forward
path to ensure linting only touches the authored prefix/signature and leaves the
forwarded quote untouched: create a test (e.g. TestForward_LintAuthoredOnly)
that builds a forward request triggering the branch in mail_forward.go (set
useHTML true, include an original message with a forward quote built via
buildForwardQuoteHTML and a user-authored body that runWritePathLint will
modify), call the forward handling code to produce composedHTMLBody, and assert
the emitted lint flags/metrics include lint_applied and original_blocked (or
that lintBlocked is set for the original part) so the contract around
runWritePathLint + composedHTMLBody/regression for forward-specific behavior is
covered.

---

Nitpick comments:
In `@shortcuts/mail/lint/linter.go`:
- Around line 391-431: The current sanitiseStyleAttr uses strings.Split(raw,
";") which wrongly splits semicolons inside quoted strings or url(...) values;
update sanitiseStyleAttr to tokenise declarations with a small stateful parser
that iterates runes and tracks being inside single/double quotes and parentheses
so semicolons inside those contexts are ignored, then trim and process each
declaration as before (use existing classifyStyleProperty(name) and preserve
dropped-list behavior), or if you prefer not to implement parsing now, update
the function comment to explicitly document this limitation and add a TODO
referencing possible future allowlist entries (e.g., font-family) that would
require proper CSS declaration parsing.

In `@shortcuts/mail/lint/rules.go`:
- Around line 127-156: The docstring incorrectly lists "unknown" as a possible
kind but classifyTag never returns it; update classifyTag to return "unknown"
(with an empty ruleID) for niche/structurally-suspect tags instead of falling
through to "allow" so callers like processElement in linter.go can distinguish
them; specifically, change the final return in classifyTag (and adjust any
comments) to return "unknown", "" and verify processElement handles the
"unknown" branch appropriately.
- Around line 304-318: The classifyStyleProperty function currently allows any
property starting with border-, padding-, or margin-, which could admit invalid
CSS properties. To fix this, replace the prefix checks for these groups with
explicit enumeration of the valid variants, listing the eight accepted border-*
properties and valid padding-* and margin-* properties explicitly in
allowedStyleProps or a separate list. Update classifyStyleProperty to only
return true for these exact known properties instead of any prefixed string.
This tightening aligns the function with the explicit allow-list approach.

In `@shortcuts/mail/lint/types.go`:
- Around line 84-87: Update the comment for the Blocked field to state that it
contains not only error-tier findings removed unconditionally but also warnings
that are promoted to blocked when opts.Strict is enabled; reference the logic in
linter.go (processElement and the URL-warning attribute branch) which appends
warnings to Blocked under strict mode so the comment accurately reflects both
sources.

In `@shortcuts/mail/mail_draft_create.go`:
- Around line 188-195: The pretty output in mail_draft_create.go (inside the
runtime.OutFormat callback that prints draftResult.DraftID and out["reference"])
omits the new out keys lint_applied and original_blocked, hiding lint rewrites;
update the runtime.OutFormat writer lambda to check out["lint_applied"] and
out["original_blocked"] (or their boolean/string forms) and print concise lines
when present (e.g., "lint_applied: true" / "original_blocked: true" or the
provided values) so --format pretty surfaces these new lint-related flags
alongside draft_id and reference.

In `@shortcuts/mail/mail_lint_writepath.go`:
- Around line 62-82: Remove the duplicate helper by keeping only one of the
identical functions (preferably emptyLintFindings for naming clarity) and delete
emptyLintEnvelopeFields; ensure you preserve the returned types (two non-nil
empty []lint.Finding slices) and the doc-comment intent; then update all callers
that reference emptyLintEnvelopeFields to call emptyLintFindings instead (search
for usages and replace the symbol), and run tests/linters to confirm no
references remain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a74e228d-0056-4a05-9c64-2247b9fc18e4

📥 Commits

Reviewing files that changed from the base of the PR and between 15ae1fa and 62f5481.

📒 Files selected for processing (21)
  • shortcuts/mail/lint/linter.go
  • shortcuts/mail/lint/linter_test.go
  • shortcuts/mail/lint/rules.go
  • shortcuts/mail/lint/types.go
  • shortcuts/mail/mail_draft_create.go
  • shortcuts/mail/mail_draft_create_test.go
  • shortcuts/mail/mail_draft_edit.go
  • shortcuts/mail/mail_forward.go
  • shortcuts/mail/mail_lint_html.go
  • shortcuts/mail/mail_lint_html_test.go
  • shortcuts/mail/mail_lint_writepath.go
  • shortcuts/mail/mail_lint_writepath_test.go
  • shortcuts/mail/mail_reply.go
  • shortcuts/mail/mail_reply_all.go
  • shortcuts/mail/mail_send.go
  • shortcuts/mail/shortcuts.go
  • skill-template/domains/mail.md
  • skills/lark-mail/SKILL.md
  • skills/lark-mail/references/lark-mail-feishu-native.md
  • skills/lark-mail/references/lark-mail-html-allowlist.md
  • skills/lark-mail/references/lark-mail-lint-html.md

Comment on lines +235 to +265
// 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: "已删除非白名单 CSS 属性(详见 references/lark-mail-html-allowlist.md)",
})
}
if len(dropped) == 0 {
attr.Val = cleaned
keep = append(keep, attr)
continue
}
if !opts.AutoFix {
// AutoFix=false: keep the original property list so users see
// exactly what would change.
keep = append(keep, attr)
continue
}
if cleaned == "" {
// All properties dropped — remove the attribute entirely.
continue
}
attr.Val = cleaned
keep = append(keep, attr)
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

STYLE_PROPERTY_DROPPED warnings bypass --strict.

In --strict mode every warning is supposed to be promoted to SeverityError and pushed onto Blocked so +lint-html --strict exits non-zero. The tag and URL branches honour this (see Lines 138–143 and 221–226), but the style-property branch always writes to Applied with SeverityWarning regardless of opts.Strict. As a result, a template containing only blacklisted CSS (e.g. style="position:absolute") silently passes +lint-html --strict even though the doc/spec states "任意 warning 也升级成 error".

🛡️ Proposed fix
 		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:      "已删除非白名单 CSS 属性(详见 references/lark-mail-html-allowlist.md)",
-				})
+				finding := Finding{
+					RuleID:    RuleStylePropertyDropped,
+					Severity:  SeverityWarning,
+					TagOrAttr: "style." + prop,
+					Excerpt:   truncateExcerpt(prop),
+					Hint:      "已删除非白名单 CSS 属性(详见 references/lark-mail-html-allowlist.md)",
+				}
+				if opts.Strict {
+					finding.Severity = SeverityError
+					rep.Blocked = append(rep.Blocked, finding)
+				} else {
+					rep.Applied = append(rep.Applied, finding)
+				}
 			}

Add a corresponding test case in linter_test.go exercising Run(html, Options{Strict: true}) with a body that only contains a forbidden CSS property and assert len(rep.Blocked) > 0 / rep.HasErrorFindings == true.

As per coding guidelines, "Every behavior change must have an accompanying test".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/lint/linter.go` around lines 235 - 265, The style-property
branch in sanitisation (inside the if name == "style" block using
sanitiseStyleAttr and RuleStylePropertyDropped) always appends Findings to
rep.Applied with SeverityWarning and ignores opts.Strict; update that branch so
when opts.Strict is true any dropped property Finding is created with
SeverityError and appended to rep.Blocked (or otherwise marked as an error)
instead of rep.Applied, preserving the same TagOrAttr/Excerpt/Hint; also ensure
the code path that keeps/auto-fixes attributes still behaves the same but
promotes warnings to errors for strict mode. Add a unit test in linter_test.go
that calls Run(html, Options{Strict: true}) with a template containing only a
forbidden CSS property (e.g. style="position:absolute") and assert rep.Blocked
is non-empty / rep.HasErrorFindings == true.

Comment on lines +154 to +160
// Empty arrays are decoded as []interface{}{} (or could be nil; both
// are valid JSON []).
if las, _ := la.([]interface{}); len(las) != 0 {
t.Errorf("lint_applied should be empty, got %d", len(las))
}
if obs, _ := ob.([]interface{}); len(obs) != 0 {
t.Errorf("original_blocked should be empty, got %d", len(obs))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fail on non-array values in the plain-text envelope test.

These assertions still pass if lint_applied or original_blocked regresses to null or any non-array type, because a failed []interface{} assertion produces a nil slice with length 0. Since the contract here is “always arrays”, the test should check the type first and then the length.

Suggested tightening
-	if las, _ := la.([]interface{}); len(las) != 0 {
-		t.Errorf("lint_applied should be empty, got %d", len(las))
-	}
-	if obs, _ := ob.([]interface{}); len(obs) != 0 {
-		t.Errorf("original_blocked should be empty, got %d", len(obs))
-	}
+	las, ok := la.([]interface{})
+	if !ok {
+		t.Fatalf("lint_applied should decode as an array, got %T", la)
+	}
+	if len(las) != 0 {
+		t.Errorf("lint_applied should be empty, got %d", len(las))
+	}
+	obs, ok := ob.([]interface{})
+	if !ok {
+		t.Fatalf("original_blocked should decode as an array, got %T", ob)
+	}
+	if len(obs) != 0 {
+		t.Errorf("original_blocked should be empty, got %d", len(obs))
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Empty arrays are decoded as []interface{}{} (or could be nil; both
// are valid JSON []).
if las, _ := la.([]interface{}); len(las) != 0 {
t.Errorf("lint_applied should be empty, got %d", len(las))
}
if obs, _ := ob.([]interface{}); len(obs) != 0 {
t.Errorf("original_blocked should be empty, got %d", len(obs))
// Empty arrays are decoded as []interface{}{} (or could be nil; both
// are valid JSON []).
las, ok := la.([]interface{})
if !ok {
t.Fatalf("lint_applied should decode as an array, got %T", la)
}
if len(las) != 0 {
t.Errorf("lint_applied should be empty, got %d", len(las))
}
obs, ok := ob.([]interface{})
if !ok {
t.Fatalf("original_blocked should decode as an array, got %T", ob)
}
if len(obs) != 0 {
t.Errorf("original_blocked should be empty, got %d", len(obs))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_lint_writepath_test.go` around lines 154 - 160, The test
currently does a type assertion like "las, _ := la.([]interface{})" which
silently yields nil for non-array values and thus misses regressions to null or
other types; update the assertions to first verify the value is actually a
[]interface{} (e.g., use the two-value form "las, ok := la.([]interface{}); if
!ok { t.Fatalf(\"lint_applied must be an array, got %T\", la) }") and then check
its length, and do the same for "ob" / "original_blocked" (e.g., "obs, ok :=
ob.([]interface{}); if !ok { t.Fatalf(\"original_blocked must be an array, got
%T\", ob) }" followed by the length check).

Comment on lines +100 to +110
## 典型场景

### 场景 1:AI 在 +draft-create 之前自检

```bash
HTML='<p>本周进展:</p><font color="red">紧急</font><script>track()</script>'
lark-cli mail +lint-html --body "$HTML"
```

→ AI 看到 `original_blocked` 含 `TAG_SCRIPT_BLOCKED`,把 cleaned_html 回填给后续 `+draft-create`,避免在写信路径才被静悄悄改动。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Field name mismatch in scenario 1 description.

Line 109 tells readers that the AI will see original_blocked in +lint-html's output, but the documented return shape on lines 50–73 only contains data.warnings[] and data.errors[] (the keys lint_applied / original_blocked are reserved for the write‑path envelope per line 13). For consistency, this scenario should describe inspecting data.errors (containing TAG_SCRIPT_BLOCKED).

📝 Suggested wording
-→ AI 看到 `original_blocked` 含 `TAG_SCRIPT_BLOCKED`,把 cleaned_html 回填给后续 `+draft-create`,避免在写信路径才被静悄悄改动。
+→ AI 看到 `data.errors[]` 含 `TAG_SCRIPT_BLOCKED`,把 `cleaned_html` 回填给后续 `+draft-create`,避免在写信路径才被静悄悄改动。
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-mail/references/lark-mail-lint-html.md` around lines 100 - 110,
The scenario text incorrectly references original_blocked (a write-path envelope
key) instead of the actual +lint-html output shape; update the description to
state that +lint-html returns its problems under data.errors (and/or
data.warnings) and that the AI should inspect data.errors for
TAG_SCRIPT_BLOCKED, then use the cleaned_html to backfill the subsequent
+draft-create step; specifically replace mentions of original_blocked with
data.errors and reference TAG_SCRIPT_BLOCKED, +lint-html, and +draft-create so
readers can locate the relevant fields and flow.

…circuited

bodyIsHTML used htmlTagRe as its only allow-list. The regex was missing
center/marquee/blink, so single-tag bodies like '<center>x</center>' fell
into the EmptyReport short-circuit in mail_lint_html.go and bypassed lint
entirely, producing warnings=[] / errors=[] / cleaned_html=<input>.

Sync htmlTagRe with lint.warnAutofixTags (font/center/marquee/blink) so
the three new warn-autofix tags reach lint.Run and fire their respective
TAG_*_TO_* warnings as the spec requires.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/mail PR touches the mail domain size/XL Architecture-level or global-impact change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants