Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"net/http"
netmail "net/mail"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
Expand All @@ -25,6 +26,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/htmllint"
"github.com/larksuite/cli/shortcuts/mail/ics"
)

Expand Down Expand Up @@ -2203,6 +2205,35 @@ func buildDraftSavedOutput(draftResult draftpkg.DraftResult, mailboxID string) m
return out
}

func lintHTMLBeforeWrite(raw string) (string, htmllint.Result, error) {
result, err := htmllint.Lint(raw, true)
if err != nil {
return raw, result, err
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("LARK_CLI_MAIL_LINT_MODE")), "warn-only") {
return raw, result, nil
}
if result.CleanedHTML != "" || result.HasFindings() {
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

lintHTMLBeforeWrite currently returns result.CleanedHTML whenever it is non-empty. Since htmllint.Lint(autoFix=true) always renders HTML into CleanedHTML for any non-empty input, this will rewrite/canonicalize all HTML even when there are no findings. Consider only returning CleanedHTML when it differs from the input or when findings require a change, otherwise return the original raw string to avoid unexpected formatting/escaping changes.

Suggested change
if result.CleanedHTML != "" || result.HasFindings() {
if result.CleanedHTML != "" && (result.HasFindings() || result.CleanedHTML != raw) {

Copilot uses AI. Check for mistakes.
return result.CleanedHTML, result, nil
}
return raw, result, nil
}

func addLintReport(out map[string]interface{}, report htmllint.Result) map[string]interface{} {
if out == nil {
out = map[string]interface{}{}
}
if report.Warnings == nil {
report.Warnings = []htmllint.Finding{}
}
if report.Errors == nil {
report.Errors = []htmllint.Finding{}
}
out["lint_applied"] = report.Warnings
out["original_blocked"] = report.Errors
return out
}

// normalizeInlineCID strips angle brackets from a Content-ID so it can be
// referenced in <img src="cid:..."> and emlbuilder.AddFileInline
// consistently (both expect the bare CID).
Expand Down
237 changes: 237 additions & 0 deletions shortcuts/mail/htmllint/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package htmllint

import (
"bytes"
"fmt"
"strings"

xhtml "golang.org/x/net/html"
)

type Finding struct {
RuleID string `json:"rule_id"`
Severity string `json:"severity"`
TagOrAttr string `json:"tag_or_attr"`
Excerpt string `json:"excerpt,omitempty"`
Hint string `json:"hint"`
}

type Result struct {
Warnings []Finding `json:"warnings"`
Errors []Finding `json:"errors"`
CleanedHTML string `json:"cleaned_html,omitempty"`
}

func (r Result) HasFindings() bool {
return len(r.Warnings) > 0 || len(r.Errors) > 0
}

func Lint(raw string, autoFix bool) (Result, error) {
result := Result{}
nodes, err := xhtml.ParseFragment(strings.NewReader(raw), &xhtml.Node{Type: xhtml.ElementNode, Data: "body"})
if err != nil {
result.Errors = append(result.Errors, Finding{
Comment on lines +32 to +36
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

New HTML sanitization/linting logic is introduced here (tag conversions, blocked tags, URL/style filtering), but there are no accompanying unit tests in this PR. Since shortcuts/mail already has extensive test coverage, adding tests for key cases (void tags, blocked tags/attrs, javascript: URLs, style property filtering, parse failure) would help prevent regressions.

Copilot uses AI. Check for mistakes.
RuleID: "HTML_PARSE_FAILED", Severity: "error", TagOrAttr: "html",
Excerpt: truncate(raw), Hint: "HTML cannot be parsed reliably",
})
if autoFix {
result.CleanedHTML = raw
}
return result, nil
}
var out bytes.Buffer
for _, n := range nodes {
renderNode(&out, n, &result, autoFix)
}
if autoFix {
result.CleanedHTML = out.String()
}
return result, nil
}
Comment on lines +32 to +53
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 | 🏗️ Heavy lift

Add regression tests for the new lint engine before merge

This introduces substantial behavior changes (HTML parse handling, tag/attr stripping, CSS sanitization, auto-fix output) but no accompanying tests are present in the provided changes. Please add coverage for core rules and edge cases before release.

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

Also applies to: 55-238

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/htmllint/lint.go` around lines 32 - 53, The PR adds a new lint
engine (Lint, renderNode, Result, Finding) with changed behavior for HTML parse
handling, tag/attr stripping, CSS sanitization and auto-fix output but no
tests—add regression tests that cover: 1) parsing failure path (simulate invalid
HTML -> Lint returns Finding with RuleID "HTML_PARSE_FAILED", Severity "error",
Excerpt/truncate and CleanedHTML behavior when autoFix true/false); 2) normal
parsing path (valid fragment -> renderNode produces expected Result.Errors and
Result.CleanedHTML for both autoFix=true/false); 3) core rule behaviors
(tag/attr stripping, CSS sanitization) with representative inputs and expected
cleaned outputs; and 4) edge cases mentioned in the diff (see functions and
logic between lines ~55-238) to ensure stable behavior; implement these as unit
tests asserting RuleID, Severity, TagOrAttr, Excerpt, Hint, and exact
CleanedHTML so future regressions are caught.


func renderNode(out *bytes.Buffer, n *xhtml.Node, result *Result, autoFix bool) {
if n == nil {
return
}
switch n.Type {
case xhtml.TextNode:
_ = xhtml.Render(out, n)
case xhtml.CommentNode, xhtml.DoctypeNode:
return
case xhtml.ElementNode:
tag := strings.ToLower(n.Data)
if blockedTags[tag] {
result.Errors = append(result.Errors, Finding{
RuleID: "TAG_BLOCKED", Severity: "error", TagOrAttr: tag,
Excerpt: "<" + tag + ">", Hint: "removed before writing mail HTML",
})
return
}
convertedCenter := false
if tag == "font" {
result.Warnings = append(result.Warnings, Finding{
RuleID: "TAG_FONT_TO_SPAN", Severity: "warning", TagOrAttr: "font",
Excerpt: "<font>", Hint: "converted to <span>",
})
tag = "span"
} else if tag == "center" {
result.Warnings = append(result.Warnings, Finding{
RuleID: "TAG_CENTER_TO_DIV", Severity: "warning", TagOrAttr: "center",
Excerpt: "<center>", Hint: "converted to <div style=\"text-align:center\">",
})
tag = "div"
convertedCenter = true
} else if tag == "marquee" || tag == "blink" {
result.Warnings = append(result.Warnings, Finding{
RuleID: "TAG_DEPRECATED_TO_TEXT", Severity: "warning", TagOrAttr: tag,
Excerpt: "<" + tag + ">", Hint: "kept inner text and removed decorative tag",
})
renderChildren(out, n, result, autoFix)
return
}
attrs := cleanAttrs(n.Attr, result, convertedCenter)
out.WriteByte('<')
out.WriteString(tag)
for _, a := range attrs {
out.WriteByte(' ')
out.WriteString(a.Key)
out.WriteString(`="`)
out.WriteString(xhtml.EscapeString(a.Val))
out.WriteByte('"')
}
out.WriteByte('>')
renderChildren(out, n, result, autoFix)
out.WriteString("</")
out.WriteString(tag)
out.WriteByte('>')
Comment on lines +105 to +109
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

htmllint.renderNode unconditionally emits a closing tag for every element. This will produce invalid HTML like <br></br> / <img ...></img> for void elements, which may reduce compatibility with mail clients / Feishu editor. Consider special-casing void elements (br/hr/img/...) to render without an end tag (or use x/net/html's renderer on a rewritten node).

Copilot uses AI. Check for mistakes.
default:
for c := n.FirstChild; c != nil; c = c.NextSibling {
renderNode(out, c, result, autoFix)
}
}
}

func renderChildren(out *bytes.Buffer, n *xhtml.Node, result *Result, autoFix bool) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
renderNode(out, c, result, autoFix)
}
}

func cleanAttrs(attrs []xhtml.Attribute, result *Result, convertedCenter bool) []xhtml.Attribute {
cleaned := make([]xhtml.Attribute, 0, len(attrs))
needsCenterStyle := convertedCenter
for _, a := range attrs {
key := strings.ToLower(strings.TrimSpace(a.Key))
val := strings.TrimSpace(a.Val)
if key == "" {
continue
}
if strings.HasPrefix(key, "on") {
result.Errors = append(result.Errors, Finding{
RuleID: "ATTR_EVENT_BLOCKED", Severity: "error", TagOrAttr: key,
Excerpt: fmt.Sprintf("%s=%q", key, truncate(val)), Hint: "removed event handler attribute",
})
continue
}
if (key == "href" || key == "src") && unsafeURL(val) {
result.Errors = append(result.Errors, Finding{
RuleID: "URL_SCHEME_BLOCKED", Severity: "error", TagOrAttr: key,
Excerpt: fmt.Sprintf("%s=%q", key, truncate(val)), Hint: "removed unsafe URL scheme",
})
continue
}
if key == "style" {
style := cleanStyle(val, result)
if needsCenterStyle {
style = mergeStyle("text-align:center", style)
needsCenterStyle = false
}
if style == "" {
continue
}
val = style
}
cleaned = append(cleaned, xhtml.Attribute{Key: key, Val: val})
}
if needsCenterStyle {
cleaned = append(cleaned, xhtml.Attribute{Key: "style", Val: "text-align:center"})
}
return cleaned
}

func cleanStyle(raw string, result *Result) string {
parts := strings.Split(raw, ";")
kept := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) == "" {
continue
}
name, val, ok := strings.Cut(p, ":")
if !ok {
continue
}
name = strings.ToLower(strings.TrimSpace(name))
val = strings.TrimSpace(val)
if !allowedCSS[name] {
result.Warnings = append(result.Warnings, Finding{
RuleID: "CSS_PROPERTY_REMOVED", Severity: "warning", TagOrAttr: name,
Excerpt: truncate(p), Hint: "removed unsupported inline style property",
})
continue
}
if unsafeURL(val) {
result.Errors = append(result.Errors, Finding{
RuleID: "CSS_URL_BLOCKED", Severity: "error", TagOrAttr: name,
Excerpt: truncate(p), Hint: "removed unsafe CSS URL",
})
continue
}
kept = append(kept, name+":"+val)
}
return strings.Join(kept, ";")
}

func unsafeURL(raw string) bool {
s := strings.ToLower(strings.TrimSpace(raw))
return strings.HasPrefix(s, "javascript:") ||
strings.HasPrefix(s, "vbscript:") ||
strings.Contains(s, "javascript:") ||
strings.Contains(s, "vbscript:")
}
Comment on lines +197 to +203
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

unsafeURL is overbroad and blocks safe links

Using strings.Contains(..., "javascript:") and strings.Contains(..., "vbscript:") will reject safe URLs like https://example.com/docs/javascript:guide. This can strip legitimate href/src values.

Suggested fix
 func unsafeURL(raw string) bool {
 	s := strings.ToLower(strings.TrimSpace(raw))
-	return strings.HasPrefix(s, "javascript:") ||
-		strings.HasPrefix(s, "vbscript:") ||
-		strings.Contains(s, "javascript:") ||
-		strings.Contains(s, "vbscript:")
+	if strings.HasPrefix(s, "javascript:") || strings.HasPrefix(s, "vbscript:") {
+		return true
+	}
+	compact := strings.ReplaceAll(s, " ", "")
+	return strings.Contains(compact, "url(javascript:") ||
+		strings.Contains(compact, "url(vbscript:")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/htmllint/lint.go` around lines 197 - 203, The current
unsafeURL function incorrectly rejects safe URLs by using strings.Contains for
"javascript:" and "vbscript:"; update unsafeURL to parse the input (use
net/url.Parse on the trimmed, lowercase raw) and only treat the URL as unsafe
when its scheme equals "javascript" or "vbscript" (or when the raw string has a
leading "javascript:"/ "vbscript:" prefix after trimming). Remove the
strings.Contains checks and keep only an explicit scheme/prefix check so links
like "https://example.com/docs/javascript:guide" are not rejected; refer to the
unsafeURL function and the raw parameter to locate and change the logic.


func mergeStyle(first, second string) string {
first = strings.TrimSpace(strings.TrimSuffix(first, ";"))
second = strings.TrimSpace(strings.Trim(second, ";"))
if first == "" {
return second
}
if second == "" {
return first
}
return first + ";" + second
}

func truncate(s string) string {
s = strings.TrimSpace(s)
if len([]rune(s)) <= 80 {
return s
}
r := []rune(s)
return string(r[:80]) + "..."
}

var blockedTags = map[string]bool{
"script": true, "style": true, "iframe": true, "object": true, "embed": true,
"form": true, "input": true, "link": true, "meta": true, "base": true,
}

var allowedCSS = 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, "border-top": true,
"border-right": true, "border-bottom": true, "border-left": true, "width": true,
"height": true, "display": true, "text-indent": true,
}
10 changes: 9 additions & 1 deletion shortcuts/mail/mail_draft_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
"github.com/larksuite/cli/shortcuts/mail/htmllint"
)

// draftCreateInput bundles all +draft-create user flags into a single
Expand Down Expand Up @@ -163,6 +164,13 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(input.Body) == "" {
return output.ErrValidation("effective body is empty after applying template; pass --body explicitly")
}
var lintReport htmllint.Result
if !input.PlainText && bodyIsHTML(input.Body) {
input.Body, lintReport, err = lintHTMLBeforeWrite(input.Body)
if err != nil {
return err
Comment on lines +167 to +171
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

In +draft-create, linting is only applied when the incoming body is already HTML. However buildRawEMLForDraftCreate upgrades plain text to HTML when a signature is requested (sigResult != nil), which means that write path can bypass linting entirely. Consider moving linting to the point where the final HTML body is assembled (after the plain-text→HTML upgrade and signature injection), or broadening the condition to also lint when sigResult is non-nil.

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +167 to +173
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

Lint gate misses signature-triggered HTML compose path.

On Line 168, lint only runs when bodyIsHTML(input.Body) is true. If body is plain text but signature insertion causes HTML compose, this write path skips lint.

💡 Proposed fix
-		var lintReport htmllint.Result
-		if !input.PlainText && bodyIsHTML(input.Body) {
-			input.Body, lintReport, err = lintHTMLBeforeWrite(input.Body)
-			if err != nil {
-				return err
-			}
-		}
 		sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
 		if err != nil {
 			return err
 		}
+		var lintReport htmllint.Result
+		if !input.PlainText && (bodyIsHTML(input.Body) || sigResult != nil) {
+			input.Body, lintReport, err = lintHTMLBeforeWrite(input.Body)
+			if err != nil {
+				return err
+			}
+		}

Please add a regression test for plain-text body + signature (sigResult != nil) to keep this path covered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/mail/mail_draft_create.go` around lines 167 - 173, The lint gate
currently only lints when bodyIsHTML(input.Body) is true, which misses cases
where input.PlainText is true but signature insertion (sigResult) produces an
HTML compose; update the logic to run lintHTMLBeforeWrite on the final composed
body (i.e., after signature insertion) or change the condition to also check for
sigResult/signature HTML so that lintHTMLBeforeWrite is executed when the
resulting message is HTML; update the use sites around bodyIsHTML,
lintHTMLBeforeWrite, input.PlainText and sigResult in mail_draft_create.go
accordingly and add a regression test covering a plain-text input with sigResult
!= nil that results in an HTML body to ensure the path is covered.

sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
if err != nil {
return err
Expand All @@ -176,7 +184,7 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
}
out := map[string]interface{}{"draft_id": draftResult.DraftID}
out := addLintReport(map[string]interface{}{"draft_id": draftResult.DraftID}, lintReport)
if draftResult.Reference != "" {
out["reference"] = draftResult.Reference
}
Expand Down
15 changes: 15 additions & 0 deletions shortcuts/mail/mail_draft_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/htmllint"
"github.com/larksuite/cli/shortcuts/mail/ics"
)

Expand Down Expand Up @@ -174,6 +175,19 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return err
}
var lintReport htmllint.Result
for i := range patch.Ops {
if (patch.Ops[i].Op == "set_body" || patch.Ops[i].Op == "set_reply_body" || patch.Ops[i].Op == "replace_body" || patch.Ops[i].Op == "append_body") &&
(patch.Ops[i].BodyKind == "" || strings.EqualFold(patch.Ops[i].BodyKind, "html") || bodyIsHTML(patch.Ops[i].Value)) {
cleaned, report, lintErr := lintHTMLBeforeWrite(patch.Ops[i].Value)
if lintErr != nil {
return lintErr
}
patch.Ops[i].Value = cleaned
lintReport.Warnings = append(lintReport.Warnings, report.Warnings...)
lintReport.Errors = append(lintReport.Errors, report.Errors...)
}
}
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
if len(patch.Ops) > 0 {
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
Expand All @@ -194,6 +208,7 @@ var MailDraftEdit = common.Shortcut{
"warning": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
"projection": projection,
}
out = addLintReport(out, lintReport)
if updateResult.Reference != "" {
out["reference"] = updateResult.Reference
}
Expand Down
Loading
Loading