mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +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
185 lines
6.4 KiB
Go
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)
|
|
}
|