Files
larksuite-cli/shortcuts/mail/mail_shortcut_validation_test.go
bubbmon233 bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* 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
2026-05-27 22:23:32 +08:00

185 lines
6.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// assertValidationError fails the test unless err carries the validation
// category with ExitValidation exit code and a message containing wantSubstr.
// Accepts both typed *errs.ValidationError and legacy *output.ExitError so
// the helper survives the error-contract migration.
func assertValidationError(t *testing.T, err error, wantSubstr string) {
t.Helper()
if err == nil {
t.Fatal("expected a validation error, got nil")
}
// Accept both typed *errs.ValidationError and legacy *output.ExitError —
// the helper's purpose is to assert "this is a validation-category
// error" via either contract, so the dual-path matches the docstring.
code := output.ExitCodeOf(err)
if !errs.IsValidation(err) && code != output.ExitValidation {
t.Fatalf("expected a validation-category error, got %T: %v", err, err)
}
if code != output.ExitValidation {
t.Errorf("expected exit code %d (ExitValidation), got %d", output.ExitValidation, code)
}
if wantSubstr != "" && !strings.Contains(err.Error(), wantSubstr) {
t.Errorf("expected error message to contain %q, got: %v", wantSubstr, err.Error())
}
}
// assertValidatePasses fails the test if err is a validation error; other
// errors (e.g. API call failures from missing tokens) are acceptable because
// we only care that the Validate callback passed.
func assertValidatePasses(t *testing.T, err error) {
t.Helper()
if err == nil {
return
}
if errs.IsValidation(err) || output.ExitCodeOf(err) == output.ExitValidation {
t.Fatalf("Validate callback should have passed but returned validation error: %v", err)
}
// Non-validation errors (auth/API failures) are expected without HTTP mocks.
}
func TestRequiredBodyRejectsWhitespaceBodyFile(t *testing.T) {
for _, tc := range []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "send",
shortcut: MailSend,
args: []string{
"+send", "--as", "user", "--to", "alice@example.com",
"--subject", "blank body-file", "--body-file", "blank.html",
},
},
{
name: "draft-create",
shortcut: MailDraftCreate,
args: []string{
"+draft-create", "--as", "user",
"--subject", "blank body-file", "--body-file", "blank.html",
},
},
{
name: "reply",
shortcut: MailReply,
args: []string{
"+reply", "--as", "user", "--message-id", "msg_001",
"--body-file", "blank.html",
},
},
{
name: "reply-all",
shortcut: MailReplyAll,
args: []string{
"+reply-all", "--as", "user", "--message-id", "msg_001",
"--body-file", "blank.html",
},
},
} {
t.Run(tc.name, func(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("blank.html", []byte(" \n\t"), 0o644); err != nil {
t.Fatal(err)
}
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
assertValidationError(t, err, "--body or --body-file is required")
})
}
}
// TC-1: +message --as bot --mailbox me → ErrValidation
func TestMailMessageBotMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "bot", "--mailbox", "me", "--message-id", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-2: +message --as bot --mailbox explicit → Validate passes
func TestMailMessageBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "bot", "--mailbox", "alice@example.com", "--message-id", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-3: +message --as user --mailbox me → Validate passes
func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessage, []string{
"+message", "--as", "user", "--mailbox", "me", "--message-id", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-4: +messages --as bot (default mailbox=me) → ErrValidation
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-5: +messages --as bot --mailbox explicit → Validate passes
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-6: +thread --as bot (default mailbox=me) → ErrValidation
func TestMailThreadBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailThread, []string{
"+thread", "--as", "bot", "--thread-id", "thread_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-7: +thread --as bot --mailbox explicit → Validate passes
func TestMailThreadBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailThread, []string{
"+thread", "--as", "bot", "--mailbox", "alice@example.com", "--thread-id", "thread_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
// TC-8: +triage --as bot (default mailbox=me) → ErrValidation
func TestMailTriageBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--as", "bot",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
// TC-9: +triage --as bot --mailbox explicit → Validate passes
func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--as", "bot", "--mailbox", "alice@example.com",
}, f, stdout)
assertValidatePasses(t, err)
}