mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill 为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、 安全性与 Larksuite-native 格式适配。 lint 库(shortcuts/mail/lint/): - 四档分类:pass / native-autofix / warn-autofix / error-strip - 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它 危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error - Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、 灰边引用、Larksuite 蓝链接 - cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于 golden-file 测试与缓存 +lint-html 独立预检 shortcut: - 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果 写入路径内置 lint(6 个 compose shortcut): - +send / +draft-create / +draft-edit / +reply / +reply-all / +forward 在 emlbuilder 之前强制 lint 净化 HTML - 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费; --show-lint-details 显式取证返回 lint_applied[] / original_blocked[] - --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥 预制 HTML 邮件模板(skills/lark-mail/assets/templates/): - 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套 - 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构 lark-mail skill 文档: - references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section + 颜色调色盘 + URL scheme + 官方模板套用流程) - references/lark-mail-lint-html.md:+lint-html 用法 - SKILL.md 顶部 CRITICAL 引导 * fix(mail): remove unused readAttr func and apply gofmt Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go that was flagged by golangci-lint (unused linter). Apply gofmt to linter.go and rules.go which had minor formatting issues. * fix(mail): address compose lint and guidance
275 lines
8.8 KiB
Go
275 lines
8.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// =====================================================================
|
|
// +lint-html Shortcut tests — public stdout envelope contract checks.
|
|
//
|
|
// These exercise the full cobra Mount → Execute pipeline (parse args →
|
|
// Validate → Execute → OutFormat) so they catch any regression in flag
|
|
// declaration, mutual-exclusion validation, path safety, and the JSON
|
|
// envelope shape.
|
|
// =====================================================================
|
|
|
|
// TestMailLintHTML_RequiresExactlyOneOfBodyOrFile verifies the mutual-
|
|
// exclusion + at-least-one-of constraint surfaces ErrValidation.
|
|
func TestMailLintHTML_RequiresExactlyOneOfBodyOrFile(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
t.Run("neither flag", func(t *testing.T) {
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{"+lint-html"}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error when neither flag is set")
|
|
}
|
|
if !strings.Contains(err.Error(), "exactly one of --body or --body-file") {
|
|
t.Errorf("wrong error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("both flags", func(t *testing.T) {
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", "<p>x</p>",
|
|
"--body-file", "fake.html",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error when both flags set")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("wrong error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMailLintHTML_BodyFilePathSafetyRejected verifies absolute paths /
|
|
// `..` traversal are rejected by the path safety check.
|
|
func TestMailLintHTML_BodyFilePathSafetyRejected(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
chdirTemp(t)
|
|
|
|
t.Run("absolute path", func(t *testing.T) {
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body-file", "/etc/passwd",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for absolute path")
|
|
}
|
|
})
|
|
|
|
t.Run("dotdot traversal", func(t *testing.T) {
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body-file", "../../../etc/passwd",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for traversal")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMailLintHTML_BodyFileReadsCwdSubpath verifies a legitimate cwd-subtree
|
|
// path loads HTML correctly.
|
|
func TestMailLintHTML_BodyFileReadsCwdSubpath(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
chdirTemp(t)
|
|
if err := os.WriteFile("input.html", []byte(`<p>safe</p><script>1</script>`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body-file", "input.html",
|
|
"--show-lint-details",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
errors, _ := data["errors"].([]interface{})
|
|
if len(errors) != 1 {
|
|
t.Errorf("expected 1 error finding (script), got %d: %+v", len(errors), errors)
|
|
}
|
|
cleaned, _ := data["cleaned_html"].(string)
|
|
if strings.Contains(cleaned, "<script") {
|
|
t.Errorf("cleaned_html should not contain <script>, got %q", cleaned)
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_DefaultEnvelopeShape verifies the default envelope only
|
|
// contains cleaned_html — warnings[] / errors[] are token-frugally suppressed
|
|
// unless --show-lint-details is passed.
|
|
func TestMailLintHTML_DefaultEnvelopeShape(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", `<p>safe content</p>`,
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
if _, ok := data["cleaned_html"]; !ok {
|
|
t.Error("cleaned_html key missing from envelope (default --auto-fix=true)")
|
|
}
|
|
if _, ok := data["warnings"]; ok {
|
|
t.Error("warnings[] must be hidden in default mode (use --show-lint-details to surface)")
|
|
}
|
|
if _, ok := data["errors"]; ok {
|
|
t.Error("errors[] must be hidden in default mode (use --show-lint-details to surface)")
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_ShowLintDetailsExposesArrays verifies --show-lint-details
|
|
// surfaces the full warnings[] / errors[] arrays alongside cleaned_html.
|
|
func TestMailLintHTML_ShowLintDetailsExposesArrays(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", `<p>safe content</p>`,
|
|
"--show-lint-details",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
if _, ok := data["warnings"]; !ok {
|
|
t.Error("warnings[] missing in --show-lint-details mode")
|
|
}
|
|
if _, ok := data["errors"]; !ok {
|
|
t.Error("errors[] missing in --show-lint-details mode")
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_PlainTextBodyShortCircuits verifies plain-text input
|
|
// produces empty arrays (lib short-circuit path) when --show-lint-details is
|
|
// set; without the flag, the arrays are omitted entirely.
|
|
func TestMailLintHTML_PlainTextBodyShortCircuits(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", "just plain text, no markup",
|
|
"--show-lint-details",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
w, _ := data["warnings"].([]interface{})
|
|
e, _ := data["errors"].([]interface{})
|
|
if len(w) != 0 || len(e) != 0 {
|
|
t.Errorf("plain text should produce no findings, got w=%v e=%v", w, e)
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_FindingShape verifies each finding entry has the
|
|
// contract-required keys (rule_id / severity / tag_or_attr / excerpt / hint).
|
|
func TestMailLintHTML_FindingShape(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", `<p>x</p><script>alert(1)</script>`,
|
|
"--show-lint-details",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
errors, _ := data["errors"].([]interface{})
|
|
if len(errors) == 0 {
|
|
t.Fatal("expected at least 1 error finding")
|
|
}
|
|
first, _ := errors[0].(map[string]interface{})
|
|
for _, key := range []string{"rule_id", "severity", "tag_or_attr", "excerpt", "hint"} {
|
|
if _, ok := first[key]; !ok {
|
|
t.Errorf("finding missing required key %q: %+v", key, first)
|
|
}
|
|
}
|
|
if first["severity"] != "error" {
|
|
t.Errorf("severity = %v, want error", first["severity"])
|
|
}
|
|
if !strings.HasPrefix(first["rule_id"].(string), "TAG_") &&
|
|
!strings.HasPrefix(first["rule_id"].(string), "ATTR_") &&
|
|
!strings.HasPrefix(first["rule_id"].(string), "STYLE_") {
|
|
t.Errorf("rule_id must be UPPER_SNAKE_CASE prefix, got %v", first["rule_id"])
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_DryRun verifies dry-run mode doesn't execute lint and
|
|
// surfaces the read-only / no-network annotation.
|
|
func TestMailLintHTML_DryRun(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", `<p>x</p>`,
|
|
"--dry-run",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Dry-run output is JSON containing "mode":"local-lint-only".
|
|
if !strings.Contains(stdout.String(), "local-lint-only") {
|
|
t.Errorf("expected dry-run mode marker, stdout=%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_BlockedTagAndWarningAccumulate verifies the report
|
|
// surfaces both warning + error findings simultaneously.
|
|
func TestMailLintHTML_BlockedTagAndWarningAccumulate(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
body := `<font color="red">warn-tag</font><script>err-tag</script>` +
|
|
`<a href="javascript:0">err-url</a>`
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", body,
|
|
"--show-lint-details",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
w, _ := data["warnings"].([]interface{})
|
|
e, _ := data["errors"].([]interface{})
|
|
if len(w) < 1 {
|
|
t.Errorf("expected ≥ 1 warning, got %d", len(w))
|
|
}
|
|
if len(e) < 2 {
|
|
t.Errorf("expected ≥ 2 errors (script + js URL), got %d", len(e))
|
|
}
|
|
}
|
|
|
|
// TestMailLintHTML_FindingsAreJSONSerialisable confirms the cleaned envelope
|
|
// can round-trip through json (no nil / function values leak in).
|
|
func TestMailLintHTML_FindingsAreJSONSerialisable(t *testing.T) {
|
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
|
|
|
err := runMountedMailShortcut(t, MailLintHTML, []string{
|
|
"+lint-html",
|
|
"--body", `<font color="red">x</font>`,
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Re-encode the data back to JSON to confirm it's serialisable.
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
if _, err := json.Marshal(data); err != nil {
|
|
t.Errorf("envelope not JSON-serialisable: %v", err)
|
|
}
|
|
}
|