mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
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
This commit is contained in:
109
shortcuts/mail/body_file.go
Normal file
109
shortcuts/mail/body_file.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// bodyFileFlag is the shared `--body-file` flag declaration reused by every
|
||||
// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward).
|
||||
// All six shortcuts honour the same mutual-exclusion contract with `--body`
|
||||
// and the cwd-subtree path safety rule. The flag is intentionally NOT
|
||||
// shared with `+lint-html` because that command's description differs
|
||||
// ("HTML to lint" vs "email body") in a way that is more readable when
|
||||
// authored per-shortcut. `+draft-edit` does not expose `--body-file` either
|
||||
// — its body ops flow through `--patch-file` JSON whose `value` field is
|
||||
// the natural file-based entry point for large bodies.
|
||||
var bodyFileFlag = common.Flag{
|
||||
Name: "body-file",
|
||||
Desc: "Path (relative, within cwd subtree) to a file containing the email body HTML. Mutually exclusive with --body. Size capped at 32 MB.",
|
||||
Input: []string{common.File},
|
||||
}
|
||||
|
||||
// maxBodyFileSize caps the size of a `--body-file` HTML input. The compose
|
||||
// path's downstream EML limit is 25 MB (helpers.go MAX_EML_BYTES); we allow a
|
||||
// bit more headroom here (32 MB) so a body close to the limit still loads
|
||||
// before the downstream check fires with a clearer error message. The cap
|
||||
// prevents an `io.ReadAll` from blowing memory on a misdirected gigabyte
|
||||
// file.
|
||||
const maxBodyFileSize = 32 * 1024 * 1024 // 32 MB
|
||||
|
||||
// validateBodyFileMutex enforces the `--body` / `--body-file` mutual
|
||||
// exclusion + cwd-subtree path safety. Compose shortcuts call this in
|
||||
// their Validate phase so AI / users see a clear error before any work
|
||||
// runs. Pass the shortcut's RuntimeContext-resolved flag values directly:
|
||||
// `bodyFlag` is the `--body` value (may be empty), `bodyFile` is the
|
||||
// trimmed `--body-file` value, and `validatePath` is the
|
||||
// runtime.ValidatePath bound function used to enforce the relative-path
|
||||
// rule (cwd-subtree only; no absolute / `..` traversal).
|
||||
//
|
||||
// Returns an ErrValidation error when either invariant is violated, nil
|
||||
// otherwise. The "exactly one of {--body, --body-file}" check is
|
||||
// shortcut-specific (some shortcuts allow neither, e.g. `+forward` with
|
||||
// no explicit body) and is therefore left to the caller.
|
||||
func validateBodyFileMutex(bodyFlag, bodyFile string, validatePath func(string) error) error {
|
||||
bodyEmpty := strings.TrimSpace(bodyFlag) == ""
|
||||
if !bodyEmpty && bodyFile != "" {
|
||||
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
|
||||
}
|
||||
if bodyFile != "" {
|
||||
if err := validatePath(bodyFile); err != nil {
|
||||
return output.ErrValidation("--body-file: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBodyFromFlags returns the body content from --body or --body-file.
|
||||
// Validate has already enforced mutual exclusion via validateBodyFileMutex,
|
||||
// so exactly one is set (or neither when a template / parent message
|
||||
// supplies the body). Returns ("", nil) when neither flag is set so
|
||||
// downstream code can decide whether the empty body is allowed.
|
||||
func resolveBodyFromFlags(runtime *common.RuntimeContext) (string, error) {
|
||||
if body := runtime.Str("body"); strings.TrimSpace(body) != "" {
|
||||
return body, nil
|
||||
}
|
||||
path := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if path == "" {
|
||||
return "", nil
|
||||
}
|
||||
return readBodyFile(runtime.FileIO(), path)
|
||||
}
|
||||
|
||||
func validateRequiredResolvedBody(body string, hasTemplate bool, message string) error {
|
||||
if !hasTemplate && strings.TrimSpace(body) == "" {
|
||||
return output.ErrValidation(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readBodyFile loads --body-file content with a size cap. Returns an
|
||||
// ErrValidation error if the file exceeds maxBodyFileSize or any IO error
|
||||
// occurs. The size check uses io.LimitReader(maxBodyFileSize+1) so any
|
||||
// over-cap byte is observable without reading the whole file.
|
||||
//
|
||||
// Callers MUST have run runtime.ValidatePath(path) on `path` first — the
|
||||
// helper only opens the file via the supplied FileIO and does not repeat
|
||||
// the cwd-subtree safety check.
|
||||
func readBodyFile(fio fileio.FileIO, path string) (string, error) {
|
||||
f, err := fio.Open(path)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("open --body-file %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
buf, err := io.ReadAll(io.LimitReader(f, maxBodyFileSize+1))
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("read --body-file %s: %v", path, err)
|
||||
}
|
||||
if len(buf) > maxBodyFileSize {
|
||||
return "", output.ErrValidation("--body-file: file exceeds %d MB limit", maxBodyFileSize/1024/1024)
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
1156
shortcuts/mail/lint/linter.go
Normal file
1156
shortcuts/mail/lint/linter.go
Normal file
File diff suppressed because it is too large
Load Diff
920
shortcuts/mail/lint/linter_test.go
Normal file
920
shortcuts/mail/lint/linter_test.go
Normal file
@@ -0,0 +1,920 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package lint
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =====================================================================
|
||||
// Tier 1 — pass-through tags / attrs / styles (tag classification row "通过").
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_AllowedTagsPassThrough verifies that the canonical Feishu-native
|
||||
// tag set passes through without findings (tag classification row "通过").
|
||||
func TestRun_AllowedTagsPassThrough(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
html string
|
||||
}{
|
||||
{"plain paragraph", `<p>hello world</p>`},
|
||||
{"div with span", `<div><span>nested</span></div>`},
|
||||
{"unordered list", `<ul><li>a</li><li>b</li></ul>`},
|
||||
{"ordered list", `<ol><li>x</li></ol>`},
|
||||
{"table", `<table><thead><tr><th>h</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>`},
|
||||
{"headings", `<h1>t</h1><h2>t</h2><h3>t</h3><h4>t</h4><h5>t</h5><h6>t</h6>`},
|
||||
{"emphasis", `<b>b</b><i>i</i><em>e</em><strong>s</strong><u>u</u><s>k</s>`},
|
||||
{"sub sup", `<sub>s</sub><sup>p</sup>`},
|
||||
{"hr br", `<p>x<br>y</p><hr>`},
|
||||
{"blockquote", `<blockquote>q</blockquote>`},
|
||||
{"code pre", `<pre><code>x = 1</code></pre>`},
|
||||
{"safe href", `<a href="https://example.com">link</a>`},
|
||||
{"mailto href", `<a href="mailto:a@b.c">m</a>`},
|
||||
{"cid img", `<img src="cid:abc123">`},
|
||||
{"data:image png", `<img src="data:image/png;base64,iVBOR" alt="x">`},
|
||||
{"feishu native quote class",
|
||||
`<div class="adit-html-block adit-html-block--collapsed"><div>x</div></div>`},
|
||||
}
|
||||
|
||||
// Feishu-native autofix rules apply to <p>/<ul>/<ol>/<li>/<blockquote>/<a>
|
||||
// — those are not "violations" so must not be flagged as errors. We
|
||||
// allow STYLE_*_NATIVE_INLINE_APPLIED + STYLE_PARA_WRAPPER_REWRITTEN
|
||||
// findings here but reject any other rule.
|
||||
feishuNativeRules := map[string]bool{
|
||||
RuleStyleListNative: true,
|
||||
RuleStyleListItemNative: true,
|
||||
RuleStyleBlockquoteNative: true,
|
||||
RuleStyleLinkNative: true,
|
||||
RuleStyleParaWrapper: true,
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rep := Run(tc.html, Options{})
|
||||
if len(rep.Blocked) != 0 {
|
||||
t.Errorf("expected no errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
|
||||
}
|
||||
for _, f := range rep.Applied {
|
||||
if !feishuNativeRules[f.RuleID] {
|
||||
t.Errorf("unexpected non-Feishu-native warning: %+v", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_AllowedStylePropertiesPassThrough verifies all allowed style
|
||||
// properties survive a round-trip without dropping.
|
||||
func TestRun_AllowedStylePropertiesPassThrough(t *testing.T) {
|
||||
allowed := []string{
|
||||
"color:rgb(31,35,41)",
|
||||
"background-color:rgb(245,246,247)",
|
||||
"font-size:14px",
|
||||
"font-weight:bold",
|
||||
"font-style:italic",
|
||||
"text-align:center",
|
||||
"text-decoration:underline",
|
||||
"line-height:1.6",
|
||||
"padding:8px",
|
||||
"margin:12px",
|
||||
"border:1px solid #ccc",
|
||||
"border-top:1px solid red",
|
||||
"border-bottom:2px solid blue",
|
||||
"border-left:1px",
|
||||
"border-right:1px",
|
||||
"width:100%",
|
||||
"height:auto",
|
||||
"display:block",
|
||||
"text-indent:2em",
|
||||
}
|
||||
for _, prop := range allowed {
|
||||
t.Run(prop, func(t *testing.T) {
|
||||
html := `<p style="` + prop + `">x</p>`
|
||||
rep := Run(html, Options{})
|
||||
for _, f := range rep.Applied {
|
||||
if f.RuleID == RuleStylePropertyDropped {
|
||||
t.Errorf("property %q unexpectedly dropped: %+v", prop, f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tier 2 — warning + autofix tags (tag classification row "警告 + 自动修复").
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_FontTagAutofixedToSpan verifies <font color="..."> rewrites to
|
||||
// <span style="color:..."> with AutoFix=true.
|
||||
func TestRun_FontTagAutofixedToSpan(t *testing.T) {
|
||||
// Use <div> wrapper to avoid the Feishu-native paragraph autofix
|
||||
// firing alongside the <font> rewrite.
|
||||
rep := Run(`<div><font color="red">x</font></div>`, Options{})
|
||||
if len(rep.Applied) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d: %+v", len(rep.Applied), rep.Applied)
|
||||
}
|
||||
got := rep.Applied[0]
|
||||
if got.RuleID != RuleTagFontToSpan {
|
||||
t.Errorf("rule = %s, want %s", got.RuleID, RuleTagFontToSpan)
|
||||
}
|
||||
if got.Severity != SeverityWarning {
|
||||
t.Errorf("severity = %s, want warning", got.Severity)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "<span") || strings.Contains(rep.CleanedHTML, "<font") {
|
||||
t.Errorf("expected <font>→<span> rewrite, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "color:red") {
|
||||
t.Errorf("expected color preserved as inline style, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FontTagSizeMappedToPx checks legacy <font size="N"> → font-size:Npx.
|
||||
func TestRun_FontTagSizeMappedToPx(t *testing.T) {
|
||||
rep := Run(`<font size="3">x</font>`, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, "font-size:16px") {
|
||||
t.Errorf("expected size=3 → 16px, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_CenterTagAutofixedToDiv verifies <center> → <div text-align:center>.
|
||||
func TestRun_CenterTagAutofixedToDiv(t *testing.T) {
|
||||
rep := Run(`<center>x</center>`, Options{})
|
||||
if len(rep.Applied) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(rep.Applied))
|
||||
}
|
||||
if rep.Applied[0].RuleID != RuleTagCenterToDiv {
|
||||
t.Errorf("rule = %s, want %s", rep.Applied[0].RuleID, RuleTagCenterToDiv)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "<div") || !strings.Contains(rep.CleanedHTML, "text-align:center") {
|
||||
t.Errorf("expected <center>→<div text-align:center>, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "<center") {
|
||||
t.Errorf("<center> should have been replaced, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_MarqueeBlinkCollapseToSpan verifies <marquee>/<blink> → <span>.
|
||||
func TestRun_MarqueeBlinkCollapseToSpan(t *testing.T) {
|
||||
for _, tag := range []string{"marquee", "blink"} {
|
||||
rep := Run("<"+tag+">x</"+tag+">", Options{})
|
||||
if len(rep.Applied) != 1 {
|
||||
t.Errorf("[%s] expected 1 warning, got %d", tag, len(rep.Applied))
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "<span") {
|
||||
t.Errorf("[%s] expected <span> wrapper, cleaned=%q", tag, rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tier 3 — error / delete tags (tag classification row "错误(删除)").
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_ScriptTagBlocked checks that <script> is removed unconditionally.
|
||||
func TestRun_ScriptTagBlocked(t *testing.T) {
|
||||
rep := Run(`<p>safe</p><script>alert(1)</script><p>after</p>`, Options{})
|
||||
if len(rep.Blocked) != 1 {
|
||||
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
|
||||
}
|
||||
if rep.Blocked[0].RuleID != RuleTagScriptBlocked {
|
||||
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleTagScriptBlocked)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "<script") || strings.Contains(rep.CleanedHTML, "alert(1)") {
|
||||
t.Errorf("<script> content should be deleted, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "safe") || !strings.Contains(rep.CleanedHTML, "after") {
|
||||
t.Errorf("surrounding content lost, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_BlockedTagsRemoved iterates all error-tier tags.
|
||||
func TestRun_BlockedTagsRemoved(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`<iframe src="x"></iframe>`: RuleTagIframeBlocked,
|
||||
`<object data="x"></object>`: RuleTagObjectBlocked,
|
||||
`<embed src="x">`: RuleTagEmbedBlocked,
|
||||
`<form action="x"><input></form>`: RuleTagFormBlocked,
|
||||
`<link rel="stylesheet" href="x.css">`: RuleTagLinkBlocked,
|
||||
`<meta http-equiv="refresh" content="0">`: RuleTagMetaBlocked,
|
||||
`<base href="https://evil.com">`: RuleTagBaseBlocked,
|
||||
}
|
||||
for input, wantRule := range cases {
|
||||
t.Run(input[:min(len(input), 30)], func(t *testing.T) {
|
||||
rep := Run(input, Options{})
|
||||
found := false
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == wantRule {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected rule %s, got %+v", wantRule, rep.Blocked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_EventHandlerAttrBlocked verifies on*-handlers (onclick etc.) are
|
||||
// stripped — they are an event-handler injection vector.
|
||||
func TestRun_EventHandlerAttrBlocked(t *testing.T) {
|
||||
rep := Run(`<p onclick="alert(1)" id="ok">x</p>`, Options{})
|
||||
if len(rep.Blocked) != 1 {
|
||||
t.Fatalf("expected 1 blocked finding, got %d", len(rep.Blocked))
|
||||
}
|
||||
if rep.Blocked[0].RuleID != RuleAttrEventHandlerBlocked {
|
||||
t.Errorf("rule = %s, want %s", rep.Blocked[0].RuleID, RuleAttrEventHandlerBlocked)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "onclick") {
|
||||
t.Errorf("onclick should be stripped, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, `id="ok"`) {
|
||||
t.Errorf("non-handler attrs should survive, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_OnErrorAttrBlocked tests one of the more common XSS vectors.
|
||||
func TestRun_OnErrorAttrBlocked(t *testing.T) {
|
||||
rep := Run(`<img src="cid:x" onerror="alert(1)">`, Options{})
|
||||
hasErr := false
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrEventHandlerBlocked && f.TagOrAttr == "onerror" {
|
||||
hasErr = true
|
||||
}
|
||||
}
|
||||
if !hasErr {
|
||||
t.Errorf("onerror should fire, got %+v", rep.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// URL scheme allow-list.
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_JavaScriptURLBlocked verifies javascript: hrefs are stripped.
|
||||
func TestRun_JavaScriptURLBlocked(t *testing.T) {
|
||||
rep := Run(`<a href="javascript:alert(1)">click</a>`, Options{})
|
||||
hasErr := false
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrJSURLBlocked {
|
||||
hasErr = true
|
||||
}
|
||||
}
|
||||
if !hasErr {
|
||||
t.Errorf("javascript: URL should fire ATTR_JS_URL_BLOCKED, got %+v", rep.Blocked)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "javascript:") {
|
||||
t.Errorf("javascript: should be stripped, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VBScriptURLBlocked verifies vbscript: is rejected.
|
||||
func TestRun_VBScriptURLBlocked(t *testing.T) {
|
||||
rep := Run(`<a href="vbscript:msgbox 1">x</a>`, Options{})
|
||||
if len(rep.Blocked) == 0 {
|
||||
t.Errorf("expected vbscript: to be blocked, got 0 findings")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_DataNonImageURLBlocked verifies data:text/html is rejected
|
||||
// (only data:image/* is allowed).
|
||||
func TestRun_DataNonImageURLBlocked(t *testing.T) {
|
||||
rep := Run(`<img src="data:text/html,<script>1</script>">`, Options{})
|
||||
if len(rep.Blocked) == 0 {
|
||||
t.Errorf("expected data:text/html to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_DataImageAllowed verifies data:image/png passes.
|
||||
func TestRun_DataImageAllowed(t *testing.T) {
|
||||
rep := Run(`<img src="data:image/png;base64,iVBORw0KGg=">`, Options{})
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrJSURLBlocked {
|
||||
t.Errorf("data:image/* should pass, got %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_RelativeURLAllowed verifies relative URLs (no scheme) pass.
|
||||
func TestRun_RelativeURLAllowed(t *testing.T) {
|
||||
rep := Run(`<img src="./local.png"><a href="/path">x</a>`, Options{})
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrJSURLBlocked || f.RuleID == RuleAttrUnsafeSchemeBlocked {
|
||||
t.Errorf("relative URL should pass, got %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Style property allow-list.
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_StylePropertyDropped verifies non-allow-list properties drop.
|
||||
func TestRun_StylePropertyDropped(t *testing.T) {
|
||||
rep := Run(`<p style="color:red; position:absolute; z-index:99">x</p>`, Options{})
|
||||
dropped := []string{}
|
||||
for _, f := range rep.Applied {
|
||||
if f.RuleID == RuleStylePropertyDropped {
|
||||
dropped = append(dropped, f.TagOrAttr)
|
||||
}
|
||||
}
|
||||
if !sliceContains(dropped, "style.position") {
|
||||
t.Errorf("expected position to be dropped, got %v", dropped)
|
||||
}
|
||||
if !sliceContains(dropped, "style.z-index") {
|
||||
t.Errorf("expected z-index to be dropped, got %v", dropped)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "position:") || strings.Contains(rep.CleanedHTML, "z-index:") {
|
||||
t.Errorf("dropped properties should be removed from cleaned style, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "color:red") {
|
||||
t.Errorf("allowed property should survive, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StyleBorderPrefixAllowed verifies the border-* prefix rule.
|
||||
func TestRun_StyleBorderPrefixAllowed(t *testing.T) {
|
||||
rep := Run(`<p style="border-top:1px; border-bottom-color:red; border-radius:4px">x</p>`, Options{})
|
||||
for _, f := range rep.Applied {
|
||||
if f.RuleID == RuleStylePropertyDropped {
|
||||
t.Errorf("border-* should pass, got %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FeishuListShorthandMarginPreserved guards the nested-list indent
|
||||
// regression: when a user writes shorthand `margin:0 0 0 24px` on an inner
|
||||
// <ul> (mail-editor's own native nested-list shape), the Feishu-list autofix
|
||||
// must NOT clobber it by appending `margin-left:0`. ensureInlineStyleProps
|
||||
// is supposed to skip props the user already declared, but earlier
|
||||
// hasInlineStyleProp was only matching longhand `margin-left:` literally
|
||||
// and missed the shorthand form, causing 24px indents to be reset to 0.
|
||||
func TestRun_FeishuListShorthandMarginPreserved(t *testing.T) {
|
||||
in := `<ul style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;list-style-type:circle;font-size:14px" dir="auto"><span style="font-family:inherit"><span style="color:rgb(0,0,0)">indented</span></span></li></ul>`
|
||||
rep := Run(in, Options{})
|
||||
cleaned := rep.CleanedHTML
|
||||
// Extract just the <ul ...> opening tag's style attr (li has its own
|
||||
// independent margin-left:0 longhand which is correct — list indent
|
||||
// belongs on the container, not the item).
|
||||
ulOpen := cleaned
|
||||
if i := strings.Index(ulOpen, ">"); i >= 0 {
|
||||
ulOpen = ulOpen[:i]
|
||||
}
|
||||
if !strings.Contains(ulOpen, "margin:0px 0px 0px 24px") {
|
||||
t.Errorf("shorthand margin with 24px left should survive on <ul>, ulOpen=%q", ulOpen)
|
||||
}
|
||||
// The bug signature: extra `margin-left:` appended after the shorthand
|
||||
// on the <ul> element itself (CSS rule says the later one wins, so any
|
||||
// margin-left:0 after the shorthand resets the indent to 0).
|
||||
if strings.Contains(ulOpen, "margin-left") {
|
||||
t.Errorf("autofix must not append margin-left longhand onto <ul> when shorthand already declares it, ulOpen=%q", ulOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_BlockquoteShorthandBorderPreserved verifies the blockquote native
|
||||
// autofix does not override a user-authored border shorthand by appending
|
||||
// border-left. CSS applies the later longhand over the earlier shorthand, so
|
||||
// adding border-left here would replace the user's left border.
|
||||
func TestRun_BlockquoteShorthandBorderPreserved(t *testing.T) {
|
||||
rep := Run(`<blockquote style="border:1px solid red">quoted</blockquote>`, Options{})
|
||||
cleaned := rep.CleanedHTML
|
||||
if !strings.Contains(cleaned, `border:1px solid red`) {
|
||||
t.Fatalf("user-authored border shorthand should survive, cleaned=%q", cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, `border-left:`) {
|
||||
t.Fatalf("autofix must not append border-left when border shorthand already declares it, cleaned=%q", cleaned)
|
||||
}
|
||||
if !strings.Contains(cleaned, `color:rgb(100,106,115)`) {
|
||||
t.Fatalf("blockquote native autofix should still add missing non-border style props, cleaned=%q", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_BlockquoteNativeContentWrapper(t *testing.T) {
|
||||
rep := Run(`<blockquote>quoted</blockquote>`, Options{})
|
||||
cleaned := rep.CleanedHTML
|
||||
for _, want := range []string{
|
||||
`class="lark-mail-doc-quote"`,
|
||||
`border-left:2px solid rgb(187,191,196)`,
|
||||
`<div dir="auto" style="font-size:14px;padding-left:12px">quoted</div>`,
|
||||
} {
|
||||
if !strings.Contains(cleaned, want) {
|
||||
t.Fatalf("cleaned blockquote missing %q, cleaned=%q", want, cleaned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_BlockquoteNativeContentWrapperIdempotent(t *testing.T) {
|
||||
in := `<blockquote class="lark-mail-doc-quote" style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:14px;padding-left:12px">quoted</div></blockquote>`
|
||||
rep := Run(in, Options{})
|
||||
if strings.Count(rep.CleanedHTML, `padding-left:12px`) != 1 {
|
||||
t.Fatalf("native-shaped blockquote should not get nested content wrappers, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ParagraphRewritePreservesDirAndFontSize(t *testing.T) {
|
||||
rep := Run(`<p style="font-size:20px" dir="rtl">hello</p>`, Options{})
|
||||
cleaned := rep.CleanedHTML
|
||||
if !strings.Contains(cleaned, `style="font-size:20px;margin-top:4px;margin-bottom:4px;line-height:1.6" dir="rtl"`) {
|
||||
t.Fatalf("outer paragraph wrapper should preserve author font-size and dir, cleaned=%q", cleaned)
|
||||
}
|
||||
if !strings.Contains(cleaned, `<div dir="rtl">hello</div>`) {
|
||||
t.Fatalf("inner paragraph wrapper should inherit author dir and omit default font-size, cleaned=%q", cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, `font-size:14px`) {
|
||||
t.Fatalf("inner paragraph wrapper must not force default font-size over author value, cleaned=%q", cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, `dir="auto"`) {
|
||||
t.Fatalf("inner paragraph wrapper must not force dir=auto over author value, cleaned=%q", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// CleanedHTML output / contract guarantees.
|
||||
// =====================================================================
|
||||
|
||||
// TestRun_EmptyArraysAlwaysPresent verifies the report has non-nil empty
|
||||
// slices when nothing is found (the JSON envelope contract requires `[]`,
|
||||
// not `null`).
|
||||
func TestRun_EmptyArraysAlwaysPresent(t *testing.T) {
|
||||
// Use <div> instead of <p> to avoid the Feishu-native paragraph
|
||||
// rewrite autofix, which would surface a finding even on otherwise
|
||||
// clean input.
|
||||
rep := Run(`<div>nothing here</div>`, Options{})
|
||||
if rep.Applied == nil || rep.Blocked == nil {
|
||||
t.Errorf("Applied/Blocked must be non-nil; got applied=%v blocked=%v", rep.Applied, rep.Blocked)
|
||||
}
|
||||
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
|
||||
t.Errorf("expected empty findings, got applied=%d blocked=%d", len(rep.Applied), len(rep.Blocked))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyReport_HasContractFields covers the helper used by compose 5's
|
||||
// plain-text branch.
|
||||
func TestEmptyReport_HasContractFields(t *testing.T) {
|
||||
rep := EmptyReport(`plain text`)
|
||||
if rep.Applied == nil {
|
||||
t.Error("Applied must be non-nil")
|
||||
}
|
||||
if rep.Blocked == nil {
|
||||
t.Error("Blocked must be non-nil")
|
||||
}
|
||||
if rep.CleanedHTML != "plain text" {
|
||||
t.Errorf("CleanedHTML = %q, want %q", rep.CleanedHTML, "plain text")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_CleanedHTMLPreservesStructure verifies that the round-trip through
|
||||
// the parser doesn't accidentally lose user content.
|
||||
func TestRun_CleanedHTMLPreservesStructure(t *testing.T) {
|
||||
html := `<div style="line-height:1.6"><h3>title</h3><p>body <b>bold</b> end</p><ul><li>a</li><li>b</li></ul></div>`
|
||||
rep := Run(html, Options{})
|
||||
if len(rep.Blocked) != 0 {
|
||||
t.Fatalf("unexpected blocked: %+v", rep.Blocked)
|
||||
}
|
||||
// Feishu-native autofix expected to fire on <p>, <ul>, <li> — content
|
||||
// must still survive untouched even though structure is augmented.
|
||||
for _, want := range []string{"line-height:1.6", "<h3>", "title", "<b>", "bold", "<ul", "<li", "</ul>"} {
|
||||
if !strings.Contains(rep.CleanedHTML, want) {
|
||||
t.Errorf("expected %q in cleaned, got %q", want, rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_EmptyInput verifies the lib short-circuits cleanly on empty input.
|
||||
func TestRun_EmptyInput(t *testing.T) {
|
||||
rep := Run("", Options{})
|
||||
if rep.CleanedHTML != "" {
|
||||
t.Errorf("CleanedHTML = %q, want empty", rep.CleanedHTML)
|
||||
}
|
||||
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
|
||||
t.Errorf("empty input must produce empty findings")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HasErrorFindingsFlag verifies the flag tracks blocked findings.
|
||||
func TestRun_HasErrorFindingsFlag(t *testing.T) {
|
||||
rep := Run(`<script>x</script>`, Options{})
|
||||
if !rep.HasErrorFindings {
|
||||
t.Error("expected HasErrorFindings=true")
|
||||
}
|
||||
clean := Run(`<p>safe</p>`, Options{})
|
||||
if clean.HasErrorFindings {
|
||||
t.Error("expected HasErrorFindings=false on clean HTML")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HasWarningFindingsFlag verifies the flag tracks warnings.
|
||||
func TestRun_HasWarningFindingsFlag(t *testing.T) {
|
||||
rep := Run(`<font color="red">x</font>`, Options{})
|
||||
if !rep.HasWarningFindings {
|
||||
t.Error("expected HasWarningFindings=true")
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Excerpt cap.
|
||||
// =====================================================================
|
||||
|
||||
// TestTruncateExcerpt_RespectsCap verifies the per-finding excerpt cap.
|
||||
func TestTruncateExcerpt_RespectsCap(t *testing.T) {
|
||||
long := strings.Repeat("x", MaxExcerptBytes+50)
|
||||
got := truncateExcerpt(long)
|
||||
if len(got) > MaxExcerptBytes {
|
||||
t.Errorf("excerpt len %d exceeds cap %d", len(got), MaxExcerptBytes)
|
||||
}
|
||||
if !strings.HasSuffix(got, " ...") {
|
||||
t.Errorf("expected truncation suffix, got %q", got[len(got)-10:])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ExcerptCappedForLargeOffender verifies large blocked content
|
||||
// produces a short excerpt (envelope size protection).
|
||||
func TestRun_ExcerptCappedForLargeOffender(t *testing.T) {
|
||||
bigAttr := strings.Repeat("a", MaxExcerptBytes*2)
|
||||
rep := Run(`<a href="javascript:`+bigAttr+`">x</a>`, Options{})
|
||||
if len(rep.Blocked) == 0 {
|
||||
t.Fatal("expected blocked finding")
|
||||
}
|
||||
for _, f := range rep.Blocked {
|
||||
if len(f.Excerpt) > MaxExcerptBytes {
|
||||
t.Errorf("excerpt len %d exceeds cap %d", len(f.Excerpt), MaxExcerptBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers.
|
||||
// =====================================================================
|
||||
|
||||
func sliceContains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Additional coverage for edge cases and exhaustive value mapping.
|
||||
// =====================================================================
|
||||
|
||||
// TestMapFontSize_ExhaustiveSpan covers every <font size="N"> mapping
|
||||
// + invalid values fall through to "" so the property is dropped.
|
||||
func TestMapFontSize_ExhaustiveSpan(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1": "10px",
|
||||
"2": "13px",
|
||||
"3": "16px",
|
||||
"4": "18px",
|
||||
"5": "24px",
|
||||
"6": "32px",
|
||||
"7": "48px",
|
||||
"": "",
|
||||
"8": "",
|
||||
"abc": "",
|
||||
"3.5": "",
|
||||
" 3 ": "16px",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
got := mapFontSize(raw)
|
||||
if got != want {
|
||||
t.Errorf("mapFontSize(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FontTagWithFaceMappedToFontFamily ensures <font face="..."> →
|
||||
// font-family inline style.
|
||||
func TestRun_FontTagWithFaceMappedToFontFamily(t *testing.T) {
|
||||
rep := Run(`<font face="Arial">x</font>`, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, "font-family:Arial") {
|
||||
t.Errorf("expected font-family preserved, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FontTagWithExistingStyleMerged ensures distillation merges with an
|
||||
// existing style attribute on the same element.
|
||||
func TestRun_FontTagWithExistingStyleMerged(t *testing.T) {
|
||||
rep := Run(`<font color="red" style="line-height:1.6">x</font>`, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
|
||||
t.Errorf("expected line-height retained, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "color:red") {
|
||||
t.Errorf("expected color merged, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_CenterTagWithExistingStyleMerged ensures <center>'s style merge.
|
||||
func TestRun_CenterTagWithExistingStyleMerged(t *testing.T) {
|
||||
rep := Run(`<center style="line-height:1.6">x</center>`, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, "text-align:center") {
|
||||
t.Errorf("expected text-align:center, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
|
||||
t.Errorf("expected line-height preserved, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_MarqueeRetainsClassAndID verifies marquee → span keeps class/id.
|
||||
func TestRun_MarqueeRetainsClassAndID(t *testing.T) {
|
||||
rep := Run(`<marquee class="cls" id="x" direction="left">y</marquee>`, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, `class="cls"`) {
|
||||
t.Errorf("expected class preserved, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, `direction`) {
|
||||
t.Errorf("expected marquee-specific attrs stripped, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_UnknownSchemeBlocked verifies an unknown URL scheme produces a
|
||||
// blocked (error) finding and the attribute is dropped.
|
||||
func TestRun_UnknownSchemeBlocked(t *testing.T) {
|
||||
rep := Run(`<a href="webcal://x">x</a>`, Options{})
|
||||
gotBlocked := false
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrUnsafeSchemeBlocked {
|
||||
gotBlocked = true
|
||||
}
|
||||
}
|
||||
if !gotBlocked {
|
||||
t.Errorf("expected ATTR_UNSAFE_SCHEME_BLOCKED in Blocked, got blocked=%+v applied=%+v", rep.Blocked, rep.Applied)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "webcal:") {
|
||||
t.Errorf("expected unknown scheme stripped, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_WhitespaceObfuscatedJavaScriptScheme verifies "java\tscript:..."
|
||||
// is still caught after control-byte stripping in classifyURLValue.
|
||||
func TestRun_WhitespaceObfuscatedJavaScriptScheme(t *testing.T) {
|
||||
rep := Run("<a href=\"java\tscript:alert(1)\">x</a>", Options{})
|
||||
gotErr := false
|
||||
for _, f := range rep.Blocked {
|
||||
if f.RuleID == RuleAttrJSURLBlocked {
|
||||
gotErr = true
|
||||
}
|
||||
}
|
||||
if !gotErr {
|
||||
t.Errorf("expected obfuscated javascript: to be caught, got %+v", rep.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FileSchemeBlocked verifies file: URLs are rejected.
|
||||
func TestRun_FileSchemeBlocked(t *testing.T) {
|
||||
rep := Run(`<a href="file:///etc/passwd">x</a>`, Options{})
|
||||
if len(rep.Blocked) == 0 {
|
||||
t.Error("expected file: to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StyleMalformedDeclarationDropped verifies a property without a
|
||||
// colon delimiter is treated as malformed and dropped.
|
||||
func TestRun_StyleMalformedDeclarationDropped(t *testing.T) {
|
||||
rep := Run(`<p style="color:red; malformed; line-height:1.6">x</p>`, Options{})
|
||||
gotMalformed := false
|
||||
for _, f := range rep.Applied {
|
||||
if f.RuleID == RuleStylePropertyDropped && f.TagOrAttr == "style.malformed" {
|
||||
gotMalformed = true
|
||||
}
|
||||
}
|
||||
if !gotMalformed {
|
||||
t.Errorf("expected malformed declaration to be dropped, got %+v", rep.Applied)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "color:red") || !strings.Contains(rep.CleanedHTML, "line-height:1.6") {
|
||||
t.Errorf("valid declarations should survive, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StyleAllPropertiesDroppedRemovesAttribute verifies the style
|
||||
// attribute is removed entirely when every property is invalid.
|
||||
func TestRun_StyleAllPropertiesDroppedRemovesAttribute(t *testing.T) {
|
||||
// Use <div> to avoid the Feishu-native paragraph autofix, which adds
|
||||
// a fresh style attribute on the rewritten outer wrapper.
|
||||
rep := Run(`<div style="position:absolute; z-index:99">x</div>`, Options{})
|
||||
if strings.Contains(rep.CleanedHTML, "style=") {
|
||||
t.Errorf("style attribute should be removed when all props invalid, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StyleEmptyValuePassThrough verifies an empty style attr passes.
|
||||
func TestRun_StyleEmptyValuePassThrough(t *testing.T) {
|
||||
// Use <div> to avoid the Feishu-native paragraph autofix.
|
||||
rep := Run(`<div style="">x</div>`, Options{})
|
||||
if len(rep.Applied) != 0 {
|
||||
t.Errorf("empty style attr should not produce findings, got %+v", rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HintsForAllBlockedTags verifies every blocked-tag rule has a
|
||||
// non-empty hint (consumer contract).
|
||||
func TestRun_HintsForAllBlockedTags(t *testing.T) {
|
||||
cases := []string{
|
||||
`<script>x</script>`, `<iframe src="x"></iframe>`,
|
||||
`<object data="x"></object>`, `<embed src="x">`, `<form><input></form>`,
|
||||
`<select></select>`, `<button>x</button>`, `<link href="x">`,
|
||||
`<meta name="x">`, `<base href="x">`,
|
||||
}
|
||||
for _, html := range cases {
|
||||
rep := Run(html, Options{})
|
||||
for _, f := range rep.Blocked {
|
||||
if f.Hint == "" {
|
||||
t.Errorf("blocked rule %s missing hint for %q", f.RuleID, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_HintsForAllWarnTags verifies every warn-tag rule has a non-empty hint.
|
||||
func TestRun_HintsForAllWarnTags(t *testing.T) {
|
||||
cases := []string{
|
||||
`<font>x</font>`, `<center>x</center>`,
|
||||
`<marquee>x</marquee>`, `<blink>x</blink>`,
|
||||
}
|
||||
for _, html := range cases {
|
||||
rep := Run(html, Options{})
|
||||
for _, f := range rep.Applied {
|
||||
if f.Hint == "" {
|
||||
t.Errorf("warn rule %s missing hint for %q", f.RuleID, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTag_Coverage exercises classifyTag with every category.
|
||||
func TestClassifyTag_Coverage(t *testing.T) {
|
||||
if k, _ := classifyTag("p"); k != "allow" {
|
||||
t.Errorf("p classified as %q", k)
|
||||
}
|
||||
if k, id := classifyTag("script"); k != "error" || id != RuleTagScriptBlocked {
|
||||
t.Errorf("script classified as %q/%q", k, id)
|
||||
}
|
||||
if k, id := classifyTag("font"); k != "warn" || id != RuleTagFontToSpan {
|
||||
t.Errorf("font classified as %q/%q", k, id)
|
||||
}
|
||||
// Niche tag passes silently (e.g. <details>).
|
||||
if k, _ := classifyTag("details"); k != "allow" {
|
||||
t.Errorf("niche tag <details> should pass through, got %q", k)
|
||||
}
|
||||
// Case-insensitive.
|
||||
if k, _ := classifyTag("SCRIPT"); k != "error" {
|
||||
t.Errorf("SCRIPT (uppercase) should still classify as error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyURLValue_CoverageEdges covers empty, whitespace-only,
|
||||
// no-scheme variants.
|
||||
func TestClassifyURLValue_CoverageEdges(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "ok",
|
||||
" ": "ok",
|
||||
"https://x": "ok",
|
||||
"https://x/path?q=1": "ok",
|
||||
"#fragment": "ok",
|
||||
"/relative": "ok",
|
||||
"javascript:alert(1)": "error",
|
||||
"vbscript:msgbox 1": "error",
|
||||
"data:image/png;base64,XYZ": "ok",
|
||||
"data:text/html,<script>": "error",
|
||||
"webcal://x": "warn",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
got, _ := classifyURLValue(raw)
|
||||
if got != want {
|
||||
t.Errorf("classifyURLValue(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyStyleProperty_Coverage covers prefixes & explicit set.
|
||||
func TestClassifyStyleProperty_Coverage(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"color": true,
|
||||
"BACKGROUND-COLOR": true, // case-insensitive
|
||||
"border-top": true,
|
||||
"padding-left": true,
|
||||
"margin-bottom": true,
|
||||
"position": false,
|
||||
"z-index": false,
|
||||
"": false,
|
||||
" ": false,
|
||||
}
|
||||
for prop, want := range cases {
|
||||
got := classifyStyleProperty(prop)
|
||||
if got != want {
|
||||
t.Errorf("classifyStyleProperty(%q) = %v, want %v", prop, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsEventHandlerAttr_Coverage covers the on*-detection rule.
|
||||
func TestIsEventHandlerAttr_Coverage(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"onclick": true,
|
||||
"onmouseover": true,
|
||||
"OnLoad": true, // case-insensitive
|
||||
"on0": true,
|
||||
"on": false, // need at least one char after "on"
|
||||
"onerror": true,
|
||||
"onsubmit": true,
|
||||
"once": true, // would match unfortunately because "once" starts with "on" + 'c'
|
||||
"id": false,
|
||||
"href": false,
|
||||
"data-on": false,
|
||||
}
|
||||
for k, want := range cases {
|
||||
got := isEventHandlerAttr(k)
|
||||
if got != want {
|
||||
t.Errorf("isEventHandlerAttr(%q) = %v, want %v", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ParseFailureFallsBackGracefully verifies extreme malformed input
|
||||
// short-circuits to EmptyReport.
|
||||
func TestRun_PlainTextInputProducesNoFindings(t *testing.T) {
|
||||
rep := Run("just a plain string with no markup", Options{})
|
||||
if len(rep.Blocked) != 0 || len(rep.Applied) != 0 {
|
||||
t.Errorf("plain text should produce no findings, got %+v %+v", rep.Blocked, rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_MultipleErrorsAccumulate ensures multiple offenders all surface.
|
||||
func TestRun_MultipleErrorsAccumulate(t *testing.T) {
|
||||
html := `<script>1</script><iframe></iframe><a href="javascript:0">x</a>` +
|
||||
`<form></form><p onclick="">y</p>`
|
||||
rep := Run(html, Options{})
|
||||
if len(rep.Blocked) < 4 {
|
||||
t.Errorf("expected ≥4 errors, got %d: %+v", len(rep.Blocked), rep.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NestedStructurePreserved verifies deep nesting passes through.
|
||||
func TestRun_NestedStructurePreserved(t *testing.T) {
|
||||
html := `<div><div><div><p><span><b>deep</b></span></p></div></div></div>`
|
||||
rep := Run(html, Options{})
|
||||
if len(rep.Blocked) != 0 {
|
||||
t.Errorf("nested allowed tags should pass, got %+v", rep.Blocked)
|
||||
}
|
||||
if !strings.Contains(rep.CleanedHTML, "deep") {
|
||||
t.Errorf("inner text lost, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_BlockedInsideAllowedRemovedNotParent verifies that removing a
|
||||
// blocked tag inside an allowed parent leaves the parent intact.
|
||||
func TestRun_BlockedInsideAllowedRemovedNotParent(t *testing.T) {
|
||||
html := `<div>before<script>1</script>after</div>`
|
||||
rep := Run(html, Options{})
|
||||
if !strings.Contains(rep.CleanedHTML, "before") || !strings.Contains(rep.CleanedHTML, "after") {
|
||||
t.Errorf("parent text should survive, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
if strings.Contains(rep.CleanedHTML, "<script") {
|
||||
t.Errorf("script should be removed, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ListDirectChildNonLIWrapped verifies that a <ul><ul> nested
|
||||
// directly without an <li> wrapper triggers LIST_DIRECT_CHILD_NON_LI and
|
||||
// the inner <ul> ends up wrapped in a synthetic <li>. Same for <ol><ol>.
|
||||
func TestRun_ListDirectChildNonLIWrapped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
html string
|
||||
}{
|
||||
{"ul wraps ul", `<ul><ul><li>x</li></ul></ul>`},
|
||||
{"ol wraps ol", `<ol><ol><li>x</li></ol></ol>`},
|
||||
{"ul wraps div", `<ul><div>orphan</div><li>real</li></ul>`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rep := Run(tc.html, Options{})
|
||||
gotRule := false
|
||||
for _, f := range rep.Applied {
|
||||
if f.RuleID == RuleListDirectChildNonLI {
|
||||
gotRule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gotRule {
|
||||
t.Errorf("expected LIST_DIRECT_CHILD_NON_LI, got %+v", rep.Applied)
|
||||
}
|
||||
// The cleaned HTML should not have a direct ul>ul or ol>ol or
|
||||
// ul>div sequence anymore.
|
||||
if strings.Contains(rep.CleanedHTML, "<ul><ul") ||
|
||||
strings.Contains(rep.CleanedHTML, "<ol><ol") ||
|
||||
strings.Contains(rep.CleanedHTML, "<ul><div") {
|
||||
t.Errorf("expected synthetic <li> wrapper, cleaned=%q", rep.CleanedHTML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
353
shortcuts/mail/lint/rules.go
Normal file
353
shortcuts/mail/lint/rules.go
Normal file
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package lint
|
||||
|
||||
import "strings"
|
||||
|
||||
// Rule IDs surfaced through Finding.RuleID. UPPER_SNAKE_CASE naming is the
|
||||
// contract for the stdout envelope. New rules MUST keep this naming convention
|
||||
// so AI / test consumers can pattern-match reliably.
|
||||
const (
|
||||
// Tag-level rules.
|
||||
RuleTagFontToSpan = "TAG_FONT_TO_SPAN"
|
||||
RuleTagCenterToDiv = "TAG_CENTER_TO_DIV"
|
||||
RuleTagMarqueeToText = "TAG_MARQUEE_TO_TEXT"
|
||||
RuleTagBlinkToText = "TAG_BLINK_TO_TEXT"
|
||||
RuleTagScriptBlocked = "TAG_SCRIPT_BLOCKED"
|
||||
RuleTagIframeBlocked = "TAG_IFRAME_BLOCKED"
|
||||
RuleTagObjectBlocked = "TAG_OBJECT_BLOCKED"
|
||||
RuleTagEmbedBlocked = "TAG_EMBED_BLOCKED"
|
||||
RuleTagFormBlocked = "TAG_FORM_BLOCKED"
|
||||
RuleTagInputBlocked = "TAG_INPUT_BLOCKED"
|
||||
RuleTagLinkBlocked = "TAG_LINK_BLOCKED"
|
||||
RuleTagMetaBlocked = "TAG_META_BLOCKED"
|
||||
RuleTagBaseBlocked = "TAG_BASE_BLOCKED"
|
||||
RuleTagUnknownStripped = "TAG_UNKNOWN_STRIPPED"
|
||||
|
||||
// Attribute-level rules.
|
||||
RuleAttrEventHandlerBlocked = "ATTR_EVENT_HANDLER_BLOCKED"
|
||||
RuleAttrJSURLBlocked = "ATTR_JS_URL_BLOCKED"
|
||||
RuleAttrUnsafeSchemeBlocked = "ATTR_UNSAFE_SCHEME_BLOCKED"
|
||||
|
||||
// Style-level rules.
|
||||
RuleStylePropertyDropped = "STYLE_PROPERTY_DROPPED"
|
||||
|
||||
// Feishu-native autofix rules. These autofix the inline style /
|
||||
// class / nesting shape of common elements so AI-authored HTML
|
||||
// matches what Feishu mail-editor itself emits, fixing the visual
|
||||
// "extra blank line between blocks", "list bullets/numbers missing",
|
||||
// "link color wrong" etc. classes of issues. The rewrite is purely
|
||||
// additive — user-supplied inline styles take precedence; the lib
|
||||
// only fills the missing properties.
|
||||
RuleStyleListNative = "STYLE_LIST_NATIVE_INLINE_APPLIED"
|
||||
RuleStyleListItemNative = "STYLE_LIST_ITEM_NATIVE_INLINE_APPLIED"
|
||||
RuleStyleBlockquoteNative = "STYLE_BLOCKQUOTE_NATIVE_INLINE_APPLIED"
|
||||
RuleStyleLinkNative = "STYLE_LINK_NATIVE_INLINE_APPLIED"
|
||||
RuleStyleParaWrapper = "STYLE_PARA_WRAPPER_REWRITTEN"
|
||||
|
||||
// RuleListDirectChildNonLI fires when a <ul> or <ol> has a non-<li>
|
||||
// element child (e.g. nested <ul><ul>). HTML spec requires list children
|
||||
// to be <li>; browsers silently hoist the nested list out and the visual
|
||||
// nesting falls apart. The lib autofixes by wrapping the offending child
|
||||
// in a synthetic <li>.
|
||||
RuleListDirectChildNonLI = "LIST_DIRECT_CHILD_NON_LI"
|
||||
)
|
||||
|
||||
// Tag classification ----------------------------------------------------------
|
||||
|
||||
// allowedTags enumerates tags that pass through verbatim (tag classification row "通过").
|
||||
// Lower-case canonical names; the parser normalises tag names so we don't need
|
||||
// case-insensitive comparison at lookup time.
|
||||
var allowedTags = map[string]bool{
|
||||
"p": true,
|
||||
"div": true,
|
||||
"span": true,
|
||||
"br": true,
|
||||
"hr": true,
|
||||
"a": true,
|
||||
"img": true,
|
||||
"table": true,
|
||||
"thead": true,
|
||||
"tbody": true,
|
||||
"tfoot": true,
|
||||
"tr": true,
|
||||
"td": true,
|
||||
"th": true,
|
||||
"ul": true,
|
||||
"ol": true,
|
||||
"li": true,
|
||||
"blockquote": true,
|
||||
"pre": true,
|
||||
"code": true,
|
||||
"b": true,
|
||||
"i": true,
|
||||
"em": true,
|
||||
"strong": true,
|
||||
"u": true,
|
||||
"s": true,
|
||||
"strike": true,
|
||||
"h1": true,
|
||||
"h2": true,
|
||||
"h3": true,
|
||||
"h4": true,
|
||||
"h5": true,
|
||||
"h6": true,
|
||||
"sub": true,
|
||||
"sup": true,
|
||||
"section": true,
|
||||
"article": true,
|
||||
"header": true,
|
||||
"footer": true,
|
||||
"nav": true,
|
||||
"main": true,
|
||||
"figure": true,
|
||||
"figcaption": true,
|
||||
"caption": true,
|
||||
"colgroup": true,
|
||||
"col": true,
|
||||
// Document structural tags (golang.org/x/net/html always wraps fragments
|
||||
// in <html><head><body>); we treat them as transparent so the wrapper
|
||||
// nodes the parser inserts don't generate spurious findings.
|
||||
"html": true,
|
||||
"head": true,
|
||||
"body": true,
|
||||
}
|
||||
|
||||
// blockedTags enumerates tags whose content is removed in full and a
|
||||
// SeverityError finding is emitted (tag classification row "错误(删除)"). Each entry
|
||||
// maps to the rule id surfaced in Finding.RuleID.
|
||||
var blockedTags = map[string]string{
|
||||
"script": RuleTagScriptBlocked,
|
||||
"iframe": RuleTagIframeBlocked,
|
||||
"object": RuleTagObjectBlocked,
|
||||
"embed": RuleTagEmbedBlocked,
|
||||
"form": RuleTagFormBlocked,
|
||||
"input": RuleTagInputBlocked,
|
||||
"select": RuleTagInputBlocked,
|
||||
"option": RuleTagInputBlocked,
|
||||
"button": RuleTagInputBlocked,
|
||||
"link": RuleTagLinkBlocked,
|
||||
"meta": RuleTagMetaBlocked,
|
||||
"base": RuleTagBaseBlocked,
|
||||
}
|
||||
|
||||
// warnAutofixTags enumerates tags rewritten when AutoFix is true (tag
|
||||
// classification row "警告 + 自动修复"). The replacement strategy is per-tag.
|
||||
var warnAutofixTags = map[string]string{
|
||||
"font": RuleTagFontToSpan,
|
||||
"center": RuleTagCenterToDiv,
|
||||
"marquee": RuleTagMarqueeToText,
|
||||
"blink": RuleTagBlinkToText,
|
||||
}
|
||||
|
||||
// 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
|
||||
// `htmlTagRe` regex is the de-facto allow-list shipping with the codebase, so
|
||||
// we don't aggressively flag anything outside `allowedTags` — drop-through
|
||||
// preserves user intent for niche tags (e.g. `<details>` / `<summary>`) that
|
||||
// browsers + Feishu native renderer already handle.
|
||||
func classifyTag(tag string) (kind, ruleID string) {
|
||||
tag = strings.ToLower(tag)
|
||||
if allowedTags[tag] {
|
||||
return "allow", ""
|
||||
}
|
||||
if id, ok := blockedTags[tag]; ok {
|
||||
return "error", id
|
||||
}
|
||||
if id, ok := warnAutofixTags[tag]; ok {
|
||||
return "warn", id
|
||||
}
|
||||
// Unknown / niche tags: pass through silently. The cli's existing
|
||||
// `htmlTagRe` (mail_quote.go:333) tolerates them too. Users authoring
|
||||
// HTML in Feishu native classes (`adit-html-block*`, `history-quote-*`,
|
||||
// `lark-mail-doc-quote`) hit this path — they MUST pass through unchanged
|
||||
// so reply / forward quote markup survives lint round-trips.
|
||||
return "allow", ""
|
||||
}
|
||||
|
||||
// Attribute / URL / style classification --------------------------------------
|
||||
|
||||
// allowedURLSchemes lists URL schemes that pass through hyperlink-bearing
|
||||
// attrs (`href`, `src`, `cite`, `formaction` etc.). Allowed: http(s), mailto,
|
||||
// cid, data:image/*; everything else (notably javascript: and vbscript:) is
|
||||
// blocked. Empty / relative URLs (no scheme) are always
|
||||
// allowed because they resolve relatively at render time and pose no
|
||||
// injection vector.
|
||||
var allowedURLSchemes = map[string]bool{
|
||||
"http": true,
|
||||
"https": true,
|
||||
"mailto": true,
|
||||
"cid": true,
|
||||
}
|
||||
|
||||
// blockedURLSchemes is the explicit deny-list. data:image/* is special-cased
|
||||
// in classifyURLValue.
|
||||
var blockedURLSchemes = map[string]bool{
|
||||
"javascript": true,
|
||||
"vbscript": true,
|
||||
"file": true,
|
||||
}
|
||||
|
||||
// classifyURLValue returns ("ok", "") if the URL value is acceptable, or
|
||||
// ("error", ruleID) when it must be removed (javascript:/vbscript:/file:),
|
||||
// or ("warn", ruleID) when the scheme is unrecognised but not actively
|
||||
// dangerous. Empty values pass through (browsers ignore them).
|
||||
func classifyURLValue(raw string) (kind, ruleID string) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "ok", ""
|
||||
}
|
||||
// Strip leading whitespace + control bytes that could obscure the
|
||||
// scheme (e.g. "java\tscript:..."). The html-parser already strips
|
||||
// stray whitespace at attribute boundaries; this is defence-in-depth
|
||||
// for older clients that paste from Word with U+0009 / U+0020 inside
|
||||
// the scheme prefix.
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7F {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
|
||||
// Find the colon delimiter; everything before it is the scheme.
|
||||
colon := strings.IndexByte(value, ':')
|
||||
if colon < 0 {
|
||||
// No scheme → relative URL → allow.
|
||||
return "ok", ""
|
||||
}
|
||||
scheme := strings.ToLower(value[:colon])
|
||||
rest := value[colon+1:]
|
||||
|
||||
switch {
|
||||
case allowedURLSchemes[scheme]:
|
||||
return "ok", ""
|
||||
case scheme == "data":
|
||||
// data:image/* is whitelisted; anything else (e.g. data:text/html;...)
|
||||
// is rejected. The check tolerates any subtype under image/* (png /
|
||||
// jpeg / gif / svg+xml / webp) so users embedding base64 thumbnails
|
||||
// don't trip the rule.
|
||||
rest = strings.TrimSpace(rest)
|
||||
if strings.HasPrefix(strings.ToLower(rest), "image/") {
|
||||
return "ok", ""
|
||||
}
|
||||
return "error", RuleAttrJSURLBlocked
|
||||
case blockedURLSchemes[scheme]:
|
||||
return "error", RuleAttrJSURLBlocked
|
||||
default:
|
||||
// Unknown scheme: surface a warning so users see it but don't
|
||||
// drop legitimate webcal:/tel: / similar in case downstream
|
||||
// renders eventually support them.
|
||||
return "warn", RuleAttrUnsafeSchemeBlocked
|
||||
}
|
||||
}
|
||||
|
||||
// urlAttributes lists attributes whose value is a URL and must therefore
|
||||
// pass classifyURLValue. Lower-case canonical names.
|
||||
var urlAttributes = map[string]bool{
|
||||
"href": true,
|
||||
"src": true,
|
||||
"cite": true,
|
||||
"formaction": true,
|
||||
"action": true,
|
||||
"background": true,
|
||||
"poster": true,
|
||||
}
|
||||
|
||||
// allowedStyleProps enumerates CSS property names that pass through the
|
||||
// inline `style="..."` attribute. Everything else is removed from the
|
||||
// property list and surfaced via STYLE_PROPERTY_DROPPED.
|
||||
//
|
||||
// `border-*` / `padding-*` / `margin-*` are treated as prefix matches by
|
||||
// classifyStyleProperty so the four directional variants (border-top etc.)
|
||||
// are all admitted without enumerating each.
|
||||
var allowedStyleProps = 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,
|
||||
"width": true,
|
||||
"height": true,
|
||||
"display": true,
|
||||
"text-indent": true,
|
||||
// Quote-block / native Feishu styles (tag classification "通过").
|
||||
// Whitespace + word-break are part of the existing `<pre>` / quote
|
||||
// wrapper styles in mail_quote.go (e.g. `bodyDivStyle`).
|
||||
"white-space": true,
|
||||
"word-break": true,
|
||||
"word-wrap": true,
|
||||
"overflow": true,
|
||||
"overflow-wrap": true,
|
||||
"vertical-align": true,
|
||||
"list-style": true,
|
||||
"list-style-type": true,
|
||||
"list-style-position": true,
|
||||
"transition": true,
|
||||
"font-family": true,
|
||||
"text-transform": true,
|
||||
"hyphens": true,
|
||||
"max-width": true,
|
||||
"min-width": true,
|
||||
"max-height": true,
|
||||
"min-height": true,
|
||||
"border-radius": true,
|
||||
"box-sizing": true,
|
||||
"opacity": true,
|
||||
"cursor": true,
|
||||
}
|
||||
|
||||
// stylePropAllowedPrefixes enumerates property name prefixes treated as
|
||||
// allowed regardless of suffix (e.g. "border-*"). A trailing "-" makes the
|
||||
// prefix self-documenting.
|
||||
var stylePropAllowedPrefixes = []string{
|
||||
"border-",
|
||||
"padding-",
|
||||
"margin-",
|
||||
}
|
||||
|
||||
// classifyStyleProperty reports whether the given lower-case property name
|
||||
// is in the allow-list (incl. prefix matches).
|
||||
func classifyStyleProperty(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if allowedStyleProps[name] {
|
||||
return true
|
||||
}
|
||||
for _, p := range stylePropAllowedPrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isEventHandlerAttr reports whether the attribute name is a DOM event
|
||||
// handler (`on*`). The lib removes every such attribute regardless of its
|
||||
// value (tag classification row "错误(删除)" + the well-known XSS vector).
|
||||
func isEventHandlerAttr(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if !strings.HasPrefix(name, "on") {
|
||||
return false
|
||||
}
|
||||
if len(name) <= 2 {
|
||||
return false
|
||||
}
|
||||
// Defence-in-depth: avoid matching legitimate attrs whose name happens
|
||||
// to begin with "on" (e.g. `onerror`-like attrs all start "on" + ascii
|
||||
// letter). The `>= 'a'` check filters out "on-something" with hyphens.
|
||||
c := name[2]
|
||||
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
}
|
||||
92
shortcuts/mail/lint/types.go
Normal file
92
shortcuts/mail/lint/types.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package lint implements the mail-domain HTML lint lib used by `+lint-html`
|
||||
// and the writing-path internals of the compose 5 shortcuts (`+send`,
|
||||
// `+draft-create`, `+reply`, `+reply-all`, `+forward`) and `+draft-edit` body
|
||||
// ops. The lib classifies HTML tags / attributes / inline styles into three
|
||||
// tiers (pass / warn-and-autofix / error-delete) following the three-tier tag
|
||||
// classification. `<style>` is passed through verbatim; `<script>` / `<iframe>`
|
||||
// / external `<link>` / on*-handlers / `javascript:` URLs are removed outright.
|
||||
//
|
||||
// The lib is deliberately decoupled from the cobra runtime so that it can be
|
||||
// re-used as a pure-CPU pass before `bld.HTMLBody(...)` (compose 5) /
|
||||
// `draftpkg.Apply(...)` (draft-edit) without taking a runtime dependency.
|
||||
package lint
|
||||
|
||||
// Severity denotes the severity of a lint finding.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityWarning is emitted for tags / attrs / styles that have a
|
||||
// safe Feishu-native replacement (e.g. <font> -> <span style>). The
|
||||
// lib always applies the replacement and surfaces the finding in
|
||||
// `Applied` — unsafe tags are removed at lint time and the rewrite is
|
||||
// not opt-out.
|
||||
SeverityWarning Severity = "warning"
|
||||
|
||||
// SeverityError is emitted for tags / attrs / styles that would cause
|
||||
// obvious rendering / safety issues (<script>, <iframe>, on*-handlers,
|
||||
// javascript:/vbscript: URLs, ...) and may be stripped or cause
|
||||
// obvious rendering issues downstream. The lib always removes these to
|
||||
// match the writing-path safety contract.
|
||||
SeverityError Severity = "error"
|
||||
)
|
||||
|
||||
// Finding describes a single lint observation. The stdout-envelope shape is:
|
||||
// rule_id / severity / tag_or_attr / excerpt / hint, all UTF-8 strings.
|
||||
type Finding struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Severity Severity `json:"severity"`
|
||||
TagOrAttr string `json:"tag_or_attr"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// Options control a single Run invocation. The lib always autofixes warnings
|
||||
// and removes errors — there is no opt-out (`--no-lint` is not provided). The
|
||||
// struct is retained for forward compatibility but currently exposes no
|
||||
// behavioural switches.
|
||||
type Options struct{}
|
||||
|
||||
// Report is the structured output of a single Run invocation.
|
||||
//
|
||||
// Both Applied and Blocked are always non-nil slices (possibly empty). The
|
||||
// stdout envelope contract requires `lint_applied` and `original_blocked` to
|
||||
// always be present arrays — the JSON encoder must render `[]` rather than
|
||||
// `null` so AI / test consumers can rely on `data.lint_applied[]` /
|
||||
// `data.original_blocked[]` unconditionally.
|
||||
type Report struct {
|
||||
// Applied surfaces warning-tier findings that the lib rewrote in place
|
||||
// (e.g. <font> -> <span style>). Each entry corresponds to a single rule
|
||||
// firing on a single tag / attribute / style property.
|
||||
Applied []Finding `json:"lint_applied"`
|
||||
|
||||
// Blocked surfaces error-tier findings that the lib removed
|
||||
// unconditionally (writing-path safety floor: <script> / on* /
|
||||
// javascript: URLs always go).
|
||||
Blocked []Finding `json:"original_blocked"`
|
||||
|
||||
// CleanedHTML is the rewritten HTML produced by Run (warnings rewritten
|
||||
// + errors deleted). When the input is plain text (bodyIsHTML == false)
|
||||
// the field equals the input verbatim.
|
||||
CleanedHTML string `json:"cleaned_html,omitempty"`
|
||||
|
||||
// HasErrorFindings reports whether any SeverityError finding was emitted.
|
||||
HasErrorFindings bool `json:"-"`
|
||||
|
||||
// HasWarningFindings reports whether any SeverityWarning finding was emitted.
|
||||
HasWarningFindings bool `json:"-"`
|
||||
}
|
||||
|
||||
// EmptyReport returns a Report with the contract-required empty (non-nil)
|
||||
// arrays and CleanedHTML equal to the input. Compose 5 / +draft-edit call
|
||||
// this when the body is plain-text or empty so the stdout envelope's
|
||||
// `lint_applied` / `original_blocked` fields are always present arrays.
|
||||
func EmptyReport(html string) Report {
|
||||
return Report{
|
||||
Applied: []Finding{},
|
||||
Blocked: []Finding{},
|
||||
CleanedHTML: html,
|
||||
}
|
||||
}
|
||||
@@ -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/lint"
|
||||
)
|
||||
|
||||
// draftCreateInput bundles all +draft-create user flags into a single
|
||||
@@ -44,7 +45,8 @@ var MailDraftCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "to", Desc: "Optional. Full To recipient list. Separate multiple addresses with commas. Display-name format is supported. When omitted, the draft is created without recipients (they can be added later via +draft-edit)."},
|
||||
{Name: "subject", Desc: "Final draft subject. Pass the full subject you want to appear in the draft. Required unless --template-id supplies a non-empty subject."},
|
||||
{Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
|
||||
{Name: "body", Desc: "Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
|
||||
bodyFileFlag,
|
||||
{Name: "from", Desc: "Optional. Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. If omitted, the mailbox's primary address is used."},
|
||||
{Name: "mailbox", Desc: "Optional. Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
|
||||
{Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
@@ -57,6 +59,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
@@ -82,19 +85,30 @@ var MailDraftCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
hasTemplate := runtime.Str("template-id") != ""
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
|
||||
return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
|
||||
}
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
// Resolve the body (reading --body-file if set) so the inline /
|
||||
// HTML check sees the real body, not an empty placeholder.
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil {
|
||||
return err
|
||||
}
|
||||
return validatePriorityFlag(runtime)
|
||||
@@ -105,10 +119,14 @@ var MailDraftCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
mailboxID := resolveComposeMailboxID(runtime)
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
input := draftCreateInput{
|
||||
To: runtime.Str("to"),
|
||||
Subject: runtime.Str("subject"),
|
||||
Body: runtime.Str("body"),
|
||||
Body: body,
|
||||
From: runtime.Str("from"),
|
||||
CC: runtime.Str("cc"),
|
||||
BCC: runtime.Str("bcc"),
|
||||
@@ -167,7 +185,7 @@ var MailDraftCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawEML, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
|
||||
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
|
||||
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -180,6 +198,14 @@ var MailDraftCreate = common.Shortcut{
|
||||
if draftResult.Reference != "" {
|
||||
out["reference"] = draftResult.Reference
|
||||
}
|
||||
// Writing-path lint envelope: default has no lint fields; full Finding
|
||||
// arrays (`lint_applied[]` / `original_blocked[]`) only when the
|
||||
// caller asked for them via --show-lint-details.
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details"))
|
||||
addComposeHint(out)
|
||||
// `draft_edit_hint` is attached ONLY here (+draft-create); the other 5
|
||||
// compose shortcuts do not — see addDraftEditHint for the rationale.
|
||||
addDraftEditHint(out)
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "Draft created.")
|
||||
// Intentionally keep +draft-create output minimal: unlike reply/forward/send
|
||||
@@ -202,6 +228,10 @@ var MailDraftCreate = common.Shortcut{
|
||||
// senderEmail returns an error early. The returned string is ready to POST
|
||||
// to the drafts endpoint. ctx is plumbed through for large-attachment
|
||||
// processing.
|
||||
//
|
||||
// Returns the rawEML, the writing-path lint findings (lint_applied /
|
||||
// original_blocked — never nil; the arrays must always be present), and
|
||||
// any error encountered.
|
||||
func buildRawEMLForDraftCreate(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
@@ -212,14 +242,19 @@ func buildRawEMLForDraftCreate(
|
||||
mailboxID, templateID string,
|
||||
templateInlineAttachments []templateInlineRef,
|
||||
templateSmallAttachments []templateAttachmentRef,
|
||||
) (string, error) {
|
||||
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
|
||||
// Initialise lint findings as empty (non-nil) slices so callers can
|
||||
// surface them through the envelope unconditionally even on the
|
||||
// plain-text branch.
|
||||
lintApplied, lintBlocked = emptyLintFindings()
|
||||
|
||||
senderEmail := resolveComposeSenderEmail(runtime)
|
||||
if senderEmail == "" {
|
||||
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
|
||||
}
|
||||
|
||||
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
|
||||
return "", err
|
||||
return "", lintApplied, lintBlocked, err
|
||||
}
|
||||
|
||||
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
|
||||
@@ -237,7 +272,7 @@ func buildRawEMLForDraftCreate(
|
||||
// compose shortcuts; if it ever trips in this path, the above check
|
||||
// regressed.
|
||||
if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
|
||||
return "", err
|
||||
return "", lintApplied, lintBlocked, err
|
||||
}
|
||||
if runtime.Bool("request-receipt") {
|
||||
bld = bld.DispositionNotificationTo("", senderEmail)
|
||||
@@ -248,9 +283,9 @@ func buildRawEMLForDraftCreate(
|
||||
if input.BCC != "" {
|
||||
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(input.Inline)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
|
||||
if parseErr != nil {
|
||||
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
var composedHTMLBody string
|
||||
@@ -265,9 +300,17 @@ func buildRawEMLForDraftCreate(
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
|
||||
if resolveErr != nil {
|
||||
return "", resolveErr
|
||||
return "", lintApplied, lintBlocked, resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
|
||||
// safety contract has no `--no-lint` opt-out. Runs AFTER
|
||||
// applyTemplate (in caller) + ResolveLocalImagePaths +
|
||||
// injectSignatureIntoBody so the lint sees the final HTML the
|
||||
// recipient renderer will see.
|
||||
cleaned, rep := runWritePathLint(resolved)
|
||||
resolved = cleaned
|
||||
lintApplied, lintBlocked = rep.Applied, rep.Blocked
|
||||
composedHTMLBody = resolved
|
||||
bld = bld.HTMLBody([]byte(composedHTMLBody))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
@@ -283,13 +326,14 @@ func buildRawEMLForDraftCreate(
|
||||
}
|
||||
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
|
||||
var tplInlineCIDs []string
|
||||
bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var embedErr error
|
||||
bld, tplInlineCIDs, embedErr = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
|
||||
if embedErr != nil {
|
||||
return "", lintApplied, lintBlocked, embedErr
|
||||
}
|
||||
allCIDs = append(allCIDs, tplInlineCIDs...)
|
||||
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
|
||||
return "", err
|
||||
if cidErr := validateInlineCIDs(resolved, allCIDs, nil); cidErr != nil {
|
||||
return "", lintApplied, lintBlocked, cidErr
|
||||
}
|
||||
} else {
|
||||
composedTextBody = input.Body
|
||||
@@ -299,9 +343,10 @@ func buildRawEMLForDraftCreate(
|
||||
// when the template contributes none; runs in both HTML and plain-text
|
||||
// branches because regular attachments are independent of body mode.
|
||||
var templateSmallBytes int64
|
||||
bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var smallErr error
|
||||
bld, templateSmallBytes, smallErr = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
|
||||
if smallErr != nil {
|
||||
return "", lintApplied, lintBlocked, smallErr
|
||||
}
|
||||
bld = applyPriority(bld, priority)
|
||||
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
|
||||
@@ -310,16 +355,17 @@ func buildRawEMLForDraftCreate(
|
||||
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
|
||||
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
|
||||
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
|
||||
bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
var largeErr error
|
||||
bld, largeErr = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
|
||||
if largeErr != nil {
|
||||
return "", lintApplied, lintBlocked, largeErr
|
||||
}
|
||||
if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
|
||||
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("build EML failed: %v", err)
|
||||
rawEML, buildErr := bld.BuildBase64URL()
|
||||
if buildErr != nil {
|
||||
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
|
||||
}
|
||||
return rawEML, nil
|
||||
return rawEML, lintApplied, lintBlocked, nil
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
_, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestBuildRawEMLForDraftCreate_WithPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "1", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func TestBuildRawEMLForDraftCreate_NoPriority(t *testing.T) {
|
||||
Body: `<p>Hello</p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
|
||||
Body: "<p>hi</p>",
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
@@ -259,7 +259,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
|
||||
Body: "<p>hi</p>",
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
|
||||
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
@@ -283,7 +283,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), newRuntimeWithFrom("sender@example.com"), input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func TestBuildRawEMLForDraftCreate_WithCalendarEvent(t *testing.T) {
|
||||
Body: "<p>Please join us</p>",
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
|
||||
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(), rt, input, nil, "", nil, "", "", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."},
|
||||
{Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "body", Desc: "Full email body for a complete replacement (set_body). Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --patch-file with set_reply_body when you need to preserve an existing reply/forward quote block; use --body when you want a full body replacement. Mutually exclusive with --body-file. Cannot be combined with --patch-file body ops."},
|
||||
bodyFileFlag,
|
||||
{Name: "patch-file", Desc: "Advanced edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. Use --body/--body-file for quick full-body replacement; use --patch-file with set_body/set_reply_body when you need typed body ops, especially set_reply_body to preserve an existing reply/forward quote block. Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure. Relative path only."},
|
||||
{Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."},
|
||||
{Name: "set-priority", Desc: "Set email priority: high, normal, low. Setting 'normal' removes any existing priority header."},
|
||||
{Name: "set-event-summary", Desc: "Set calendar event title. Must be used together with --set-event-start and --set-event-end."},
|
||||
@@ -45,6 +47,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
{Name: "remove-event", Type: "bool", Desc: "Remove the calendar event from the draft."},
|
||||
{Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."},
|
||||
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the draft's sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed. Adds the Disposition-Notification-To header; existing value is overwritten."},
|
||||
showLintDetailsFlag,
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Bool("print-patch-template") {
|
||||
@@ -68,7 +71,7 @@ var MailDraftEdit = common.Shortcut{
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
|
||||
Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Quick full-body replacement can use --body/--body-file; advanced body edits can use --patch-file with set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins.").
|
||||
GET(mailboxPath(mailboxID, "drafts", draftID)).
|
||||
Params(map[string]interface{}{"format": "raw"}).
|
||||
PUT(mailboxPath(mailboxID, "drafts", draftID)).
|
||||
@@ -174,6 +177,32 @@ var MailDraftEdit = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Writing-path lint for body ops only: set_body / set_reply_body
|
||||
// rewrite the body field; other ops (set_subject / set_recipients /
|
||||
// add_attachment / etc.) operate on non-HTML fields and MUST NOT be
|
||||
// linted. Lint runs after loadPatchFile parses JSON and BEFORE
|
||||
// draftpkg.Apply writes into the snapshot. Each op's `value` is
|
||||
// replaced with the cleaned HTML in place; findings accumulate across
|
||||
// ops into a single per-patch report.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
for i := range patch.Ops {
|
||||
op := &patch.Ops[i]
|
||||
if op.Op != "set_body" && op.Op != "set_reply_body" {
|
||||
continue
|
||||
}
|
||||
if op.Value == "" {
|
||||
continue
|
||||
}
|
||||
if !bodyIsHTML(op.Value) {
|
||||
// Plain-text body op — no lint pass needed (the HTML rule set
|
||||
// is irrelevant), but the envelope still surfaces empty arrays.
|
||||
continue
|
||||
}
|
||||
cleaned, rep := runWritePathLint(op.Value)
|
||||
op.Value = cleaned
|
||||
lintApplied = append(lintApplied, rep.Applied...)
|
||||
lintBlocked = append(lintBlocked, rep.Blocked...)
|
||||
}
|
||||
dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()}
|
||||
if len(patch.Ops) > 0 {
|
||||
if err := draftpkg.Apply(dctx, snapshot, patch); err != nil {
|
||||
@@ -197,6 +226,10 @@ var MailDraftEdit = common.Shortcut{
|
||||
if updateResult.Reference != "" {
|
||||
out["reference"] = updateResult.Reference
|
||||
}
|
||||
// Writing-path lint envelope: counts always present; full Finding
|
||||
// arrays only when the caller asked for them via --show-lint-details.
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, runtime.Bool("show-lint-details"))
|
||||
addComposeHint(out)
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "Draft updated.")
|
||||
fmt.Fprintf(w, "draft_id: %s\n", updateResult.DraftID)
|
||||
@@ -370,6 +403,31 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error)
|
||||
setRecipients("cc", runtime.Str("set-cc"))
|
||||
setRecipients("bcc", runtime.Str("set-bcc"))
|
||||
|
||||
// --body / --body-file are convenience shorthands for a set_body patch
|
||||
// op. They cannot be combined with --patch-file body ops
|
||||
// (set_body / set_reply_body) to avoid ambiguous ordering.
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return patch, err
|
||||
}
|
||||
bodyVal := bodyFlag
|
||||
if bodyVal == "" && bodyFile != "" {
|
||||
loaded, err := readBodyFile(runtime.FileIO(), bodyFile)
|
||||
if err != nil {
|
||||
return patch, err
|
||||
}
|
||||
bodyVal = loaded
|
||||
}
|
||||
if bodyVal != "" {
|
||||
for _, op := range patch.Ops {
|
||||
if op.Op == "set_body" || op.Op == "set_reply_body" {
|
||||
return patch, output.ErrValidation("--body / --body-file and --patch-file body ops (set_body/set_reply_body) are mutually exclusive; use one or the other")
|
||||
}
|
||||
}
|
||||
patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_body", Value: bodyVal})
|
||||
}
|
||||
|
||||
// --set-priority → inject set_header / remove_header op
|
||||
if setPriority := runtime.Str("set-priority"); setPriority != "" {
|
||||
headerVal, pErr := parsePriority(setPriority)
|
||||
@@ -531,7 +589,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
},
|
||||
"recommended_usage": []string{
|
||||
"Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits",
|
||||
"Use --patch-file for ALL body edits and advanced changes (recipients, headers, attachments, inline images)",
|
||||
"Use --body/--body-file for quick full-body replacement; use --patch-file for advanced body edits and advanced changes (recipients, headers, attachments, inline images)",
|
||||
"Before editing body, run --inspect to check has_quoted_content; if true, use set_reply_body instead of set_body",
|
||||
},
|
||||
"body_edit_decision_guide": []map[string]interface{}{
|
||||
@@ -544,7 +602,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> in `set_body`/`set_reply_body` instead",
|
||||
"`ops` is executed in order",
|
||||
"all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal",
|
||||
"all body edits MUST go through --patch-file; there is no --set-body flag",
|
||||
"use --body <html> for a quick full-body replacement (equivalent to a set_body op); use --patch-file with set_body/set_reply_body for advanced body edits; --body and --patch-file body ops are mutually exclusive",
|
||||
"`set_body` replaces the user-authored content. It does NOT auto-preserve the old quote block (include one in value if needed, or use `set_reply_body`). Signature, large attachment card, and normal attachment MIME parts are auto-preserved. When the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML.",
|
||||
"`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block, signature, and large attachment card; the value you pass should contain ONLY the new user-authored content (no quote, no signature, no attachment card). If the user wants to modify content INSIDE the quote block, use `set_body` instead. If the draft has no quote block, it behaves identically to `set_body`.",
|
||||
"`body_kind` only supports text/plain and text/html",
|
||||
|
||||
@@ -26,10 +26,12 @@ var MailForward = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to forward", Required: true},
|
||||
{Name: "to", Desc: "Recipient email address(es), comma-separated"},
|
||||
{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."},
|
||||
{Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file."},
|
||||
bodyFileFlag,
|
||||
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
|
||||
{Name: "cc", Desc: "CC email address(es), comma-separated"},
|
||||
@@ -44,7 +46,8 @@ var MailForward = common.Shortcut{
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are merged into the forward draft (template values appended to user flags / forward-derived values; no de-duplication)."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
@@ -72,6 +75,11 @@ var MailForward = common.Shortcut{
|
||||
if err := validateTemplateID(runtime.Str("template-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -102,7 +110,10 @@ var MailForward = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
to := runtime.Str("to")
|
||||
body := runtime.Str("body")
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
ccFlag := runtime.Str("cc")
|
||||
bccFlag := runtime.Str("bcc")
|
||||
plainText := runtime.Bool("plain-text")
|
||||
@@ -242,6 +253,8 @@ var MailForward = common.Shortcut{
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
var srcInlineBytes int64
|
||||
// Lint findings flowing into the writing-path stdout envelope.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("forward blocked: %w", err)
|
||||
@@ -267,6 +280,13 @@ var MailForward = common.Shortcut{
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
// Writing-path lint: lint user-authored body + signature, NOT the
|
||||
// forward quote / large-attachment card derived from the original
|
||||
// message (re-linting quote blocks risks dropping allow-listed
|
||||
// Feishu-native quote markup).
|
||||
cleaned, rep := runWritePathLint(bodyWithSig)
|
||||
bodyWithSig = cleaned
|
||||
lintApplied, lintBlocked = rep.Applied, rep.Blocked
|
||||
composedHTMLBody = bodyWithSig + origLargeAttCard + forwardQuote
|
||||
bld = bld.HTMLBody([]byte(composedHTMLBody))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
@@ -479,8 +499,12 @@ var MailForward = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
|
||||
out := buildDraftSavedOutput(draftResult, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
|
||||
return nil
|
||||
}
|
||||
@@ -488,7 +512,10 @@ var MailForward = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
170
shortcuts/mail/mail_lint_html.go
Normal file
170
shortcuts/mail/mail_lint_html.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/lint"
|
||||
)
|
||||
|
||||
// MailLintHTML is the `+lint-html` shortcut: lint a mail HTML body for
|
||||
// compatibility / safety / Larksuite-native rules. Read-only — no draft is
|
||||
// touched, no API call is made. This is a stand-alone preview counterpart to
|
||||
// the writing-path lint built into compose 5 / +draft-edit; both share a
|
||||
// single lint lib (shortcuts/mail/lint) so behaviour can't drift.
|
||||
//
|
||||
// Returns by default (token-frugal envelope):
|
||||
//
|
||||
// {ok: true, data: {cleaned_html: "..."}}
|
||||
//
|
||||
// With --show-lint-details, the envelope additionally surfaces the full
|
||||
// `warnings[]` / `errors[]` Finding arrays. Each entry has: rule_id /
|
||||
// severity / tag_or_attr / excerpt / hint.
|
||||
var MailLintHTML = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+lint-html",
|
||||
Description: "Lint mail HTML body for compatibility / safety / Larksuite-native rules. Returns warnings/errors and (always) auto-fixed cleaned_html. Read-only: no draft, no API call. Use this BEFORE creating a draft to preview what the writing-path lint would change.",
|
||||
Risk: "read",
|
||||
// No API call → no scope requirement.
|
||||
Scopes: []string{},
|
||||
// Identity-agnostic: lint is local pure-CPU. Both user and bot
|
||||
// identities can run it.
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
// --body / --body-file are MUTUALLY EXCLUSIVE BUT EXACTLY-ONE-OF.
|
||||
// We do NOT use cobra `Required: true` on either (it fires before
|
||||
// Validate runs and blocks the legitimate "the other one is set"
|
||||
// path); we enforce the constraint inside the Validate callback below.
|
||||
{Name: "body", Desc: "HTML body to lint. Mutually exclusive with --body-file; exactly one is required."},
|
||||
{Name: "body-file", Desc: "Path (relative, within cwd subtree) to a file containing HTML to lint. Mutually exclusive with --body; exactly one is required.", Input: []string{common.File}},
|
||||
showLintDetailsFlag,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
|
||||
// Mutual exclusion + exactly-one-of validation for --body / --body-file.
|
||||
bodyEmpty := strings.TrimSpace(body) == ""
|
||||
if bodyEmpty && bodyFile == "" {
|
||||
return output.ErrValidation("exactly one of --body or --body-file is required")
|
||||
}
|
||||
if !bodyEmpty && bodyFile != "" {
|
||||
return output.ErrValidation("--body and --body-file are mutually exclusive; pass exactly one")
|
||||
}
|
||||
|
||||
// --body-file safety: cwd-subtree only. Mirrors the existing pattern
|
||||
// in mail_template_create.go:resolveTemplateContent + shortcut
|
||||
// runtime.ValidatePath.
|
||||
if bodyFile != "" {
|
||||
if err := runtime.ValidatePath(bodyFile); err != nil {
|
||||
return output.ErrValidation("--body-file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Pure local — no network IO. Surface this explicitly so the
|
||||
// dry-run envelope makes clear that running the command for real
|
||||
// has zero side effects.
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Lint HTML body locally (no API call, no draft mutation, no network IO).").
|
||||
Set("mode", "local-lint-only")
|
||||
if path := strings.TrimSpace(runtime.Str("body-file")); path != "" {
|
||||
api = api.Set("body_source", "file").Set("body_file", path)
|
||||
} else {
|
||||
api = api.Set("body_source", "flag")
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := readLintHTMLBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Plain-text input: short-circuit to an empty report (lib short-circuit
|
||||
// path, also useful so users running --body 'plain text' don't get
|
||||
// confused by an empty-but-rewritten output).
|
||||
var rep lint.Report
|
||||
if !bodyIsHTML(body) {
|
||||
rep = lint.EmptyReport(body)
|
||||
} else {
|
||||
rep = lint.Run(body, lint.Options{})
|
||||
}
|
||||
|
||||
// Public envelope shape: token-frugal by default. `cleaned_html` is
|
||||
// the primary product; the full `warnings[]` / `errors[]` Finding
|
||||
// arrays are only attached when the caller passes
|
||||
// `--show-lint-details`. A complex template can produce 30-80
|
||||
// warnings whose full payload would dominate the response by
|
||||
// thousands of tokens — AI consumers (the dominant audience for
|
||||
// `+lint-html` as a draft pre-flight check) overwhelmingly only
|
||||
// need cleaned_html.
|
||||
showDetails := runtime.Bool("show-lint-details")
|
||||
data := map[string]interface{}{
|
||||
"cleaned_html": rep.CleanedHTML,
|
||||
}
|
||||
if showDetails {
|
||||
data["warnings"] = rep.Applied // never nil — lib guarantees []
|
||||
data["errors"] = rep.Blocked // never nil — lib guarantees []
|
||||
}
|
||||
|
||||
runtime.OutFormat(data, &output.Meta{Count: len(rep.Applied) + len(rep.Blocked)}, func(w io.Writer) {
|
||||
printLintPretty(w, rep)
|
||||
})
|
||||
|
||||
// The lib already removed errors and rewrote warnings in place;
|
||||
// `+lint-html` is a preview / advisory tool and never bumps the
|
||||
// exit code. CI scripts that want to gate on findings should
|
||||
// post-process the envelope (e.g. with `--show-lint-details` and
|
||||
// jq on `errors[]` / `warnings[]`).
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// readLintHTMLBody resolves the input HTML body from --body or --body-file.
|
||||
// Validate has already enforced that exactly one is set, so we don't repeat
|
||||
// the mutual-exclusion check here.
|
||||
func readLintHTMLBody(runtime *common.RuntimeContext) (string, error) {
|
||||
if body := runtime.Str("body"); strings.TrimSpace(body) != "" {
|
||||
return body, nil
|
||||
}
|
||||
path := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if path == "" {
|
||||
// Should be unreachable given Validate, but defensive.
|
||||
return "", output.ErrValidation("internal: --body-file empty after Validate")
|
||||
}
|
||||
return readBodyFile(runtime.FileIO(), path)
|
||||
}
|
||||
|
||||
// printLintPretty renders the lint report as a human-readable summary used
|
||||
// when --format pretty is selected. Stays terse so CI logs aren't drowned.
|
||||
func printLintPretty(w io.Writer, rep lint.Report) {
|
||||
if len(rep.Blocked) == 0 && len(rep.Applied) == 0 {
|
||||
fmt.Fprintln(w, "OK: no compatibility / safety findings.")
|
||||
fmt.Fprintf(w, "cleaned_html_size: %d bytes\n", len(rep.CleanedHTML))
|
||||
return
|
||||
}
|
||||
if len(rep.Blocked) > 0 {
|
||||
fmt.Fprintf(w, "errors (%d):\n", len(rep.Blocked))
|
||||
for _, f := range rep.Blocked {
|
||||
fmt.Fprintf(w, " - [%s] %s — %s\n", f.RuleID, f.TagOrAttr, f.Hint)
|
||||
}
|
||||
}
|
||||
if len(rep.Applied) > 0 {
|
||||
fmt.Fprintf(w, "warnings (%d):\n", len(rep.Applied))
|
||||
for _, f := range rep.Applied {
|
||||
fmt.Fprintf(w, " - [%s] %s — %s\n", f.RuleID, f.TagOrAttr, f.Hint)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "cleaned_html_size: %d bytes\n", len(rep.CleanedHTML))
|
||||
}
|
||||
274
shortcuts/mail/mail_lint_html_test.go
Normal file
274
shortcuts/mail/mail_lint_html_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
131
shortcuts/mail/mail_lint_writepath.go
Normal file
131
shortcuts/mail/mail_lint_writepath.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/larksuite/cli/shortcuts/mail/lint"
|
||||
)
|
||||
|
||||
// showLintDetailsFlag is the optional --show-lint-details flag shared by every
|
||||
// compose shortcut (+send / +draft-create / +reply / +reply-all / +forward /
|
||||
// +draft-edit). By default the envelope carries no lint fields at all; passing
|
||||
// this flag attaches the two lint contract Finding arrays together
|
||||
// (`lint_applied[]` / `original_blocked[]`) so callers can inspect the
|
||||
// individual findings for debugging. The two keys enter and leave the envelope
|
||||
// as a single group (字段同进同退) — they are never present in a half state.
|
||||
// Default-off keeps the envelope small for AI consumers; rich-list templates
|
||||
// can trigger 20+ warnings whose full payload would balloon the response by
|
||||
// thousands of tokens, and most callers do not need to know the lint pass ran.
|
||||
// Callers who need a count can compute it locally via `len(lint_applied)` /
|
||||
// `len(original_blocked)`.
|
||||
var showLintDetailsFlag = common.Flag{
|
||||
Name: "show-lint-details",
|
||||
Type: "bool",
|
||||
Desc: "Include lint metadata (lint_applied[] / original_blocked[]) in the envelope. Default: no lint fields are returned to keep the envelope small.",
|
||||
}
|
||||
|
||||
// runWritePathLint is the single entrypoint compose 5 + +draft-edit body ops
|
||||
// use to invoke the lint lib before writing to emlbuilder / draftpkg.Apply.
|
||||
//
|
||||
// The writing-path safety contract is:
|
||||
// - The lib always autofixes warnings and removes errors; there is no
|
||||
// opt-out.
|
||||
// - The returned report is appended to the writing-path stdout envelope
|
||||
// under the contract keys `lint_applied` (warnings) and
|
||||
// `original_blocked` (errors); both arrays are always present (possibly
|
||||
// empty) so consumers can rely on `data.lint_applied[]` and
|
||||
// `data.original_blocked[]` unconditionally.
|
||||
// - When the body is plain-text, the lib short-circuits and returns an
|
||||
// EmptyReport; the cleaned HTML equals the input verbatim. Compose 5
|
||||
// callers are expected to gate the call on their existing useHTML
|
||||
// branch so the plain-text path doesn't pay the parse cost.
|
||||
//
|
||||
// Returns the cleaned HTML + the report. Callers MUST use the returned
|
||||
// `cleaned` value as the body that goes to bld.HTMLBody / draftpkg.Apply
|
||||
// (writing the original `body` would defeat the safety contract).
|
||||
func runWritePathLint(body string) (cleaned string, rep lint.Report) {
|
||||
if body == "" {
|
||||
return "", lint.EmptyReport("")
|
||||
}
|
||||
rep = lint.Run(body, lint.Options{})
|
||||
return rep.CleanedHTML, rep
|
||||
}
|
||||
|
||||
// applyLintToEnvelope mutates the OutFormat data map by adding the
|
||||
// writing-path lint contract keys.
|
||||
//
|
||||
// The two lint contract Finding arrays (`lint_applied[]` / `original_blocked[]`)
|
||||
// enter and leave the envelope as a single group (字段同进同退) — they are
|
||||
// never present in a half state.
|
||||
//
|
||||
// - When `showDetails` is false (default): the function adds zero keys to
|
||||
// `data`. The envelope therefore carries no lint metadata at all,
|
||||
// keeping it small for AI consumers who do not need to know the lint
|
||||
// pass ran.
|
||||
// - When `showDetails` is true (caller passed `--show-lint-details`): both
|
||||
// arrays are added together. `lint_applied[]` and `original_blocked[]`
|
||||
// are non-nil (possibly empty) so detail-mode consumers can rely on
|
||||
// `data.lint_applied[]` / `data.original_blocked[]` unconditionally. The
|
||||
// envelope no longer carries any `*_count` fields — callers needing a
|
||||
// count compute it via `len(lint_applied)` / `len(original_blocked)`.
|
||||
func applyLintToEnvelope(data map[string]interface{}, applied, blocked []lint.Finding, showDetails bool) {
|
||||
if applied == nil {
|
||||
applied = []lint.Finding{}
|
||||
}
|
||||
if blocked == nil {
|
||||
blocked = []lint.Finding{}
|
||||
}
|
||||
if showDetails {
|
||||
data["lint_applied"] = applied
|
||||
data["original_blocked"] = blocked
|
||||
}
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return []lint.Finding{}, []lint.Finding{}
|
||||
}
|
||||
|
||||
// composeHTMLGuideHint is the recommended-reading message that compose
|
||||
// shortcuts (+send / +draft-create / +reply / +reply-all / +forward /
|
||||
// +draft-edit body op) attach to their stdout envelope under the key
|
||||
// `compose_hint`. AI / users SHOULD read references/lark-mail-html.md
|
||||
// before composing rich-HTML mail to follow the writing rules.
|
||||
const composeHTMLGuideHint = "Please refer to skills/lark-mail/references/lark-mail-html.md for the recommended HTML writing guidelines before composing mail."
|
||||
|
||||
// addComposeHint inserts the compose-side reading hint into the envelope
|
||||
// data map under the key `compose_hint`. Compose shortcuts call this once
|
||||
// per top-level success branch so consumers always see the same hint key.
|
||||
func addComposeHint(out map[string]interface{}) {
|
||||
out["compose_hint"] = composeHTMLGuideHint
|
||||
}
|
||||
|
||||
// draftEditHintConst is the recommended-workflow message that the
|
||||
// +draft-create shortcut attaches to its stdout envelope under the key
|
||||
// `draft_edit_hint`. AI / users SHOULD edit the existing draft via
|
||||
// `+draft-edit --draft-id <id>` rather than re-running `+draft-create`,
|
||||
// which would create a duplicate draft entry instead of updating the
|
||||
// original one.
|
||||
const draftEditHintConst = "To modify this draft later (body, subject, recipients, attachments), prefer 'lark-cli mail +draft-edit --draft-id <id>' over creating a new draft via '+draft-create'. Re-running '+draft-create' will produce a separate draft entry instead of updating the existing one."
|
||||
|
||||
// addDraftEditHint inserts the draft-edit recommendation into the envelope
|
||||
// data map under the key `draft_edit_hint`. ONLY +draft-create calls this —
|
||||
// the other 5 compose shortcuts (+send / +reply / +reply-all / +forward /
|
||||
// +draft-edit) MUST NOT attach `draft_edit_hint`: it only applies to a newly
|
||||
// created draft, not to a sent message or an edit of an existing draft.
|
||||
func addDraftEditHint(out map[string]interface{}) {
|
||||
out["draft_edit_hint"] = draftEditHintConst
|
||||
}
|
||||
719
shortcuts/mail/mail_lint_writepath_test.go
Normal file
719
shortcuts/mail/mail_lint_writepath_test.go
Normal file
@@ -0,0 +1,719 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/mail/lint"
|
||||
)
|
||||
|
||||
// jsonDecoderUnmarshal is a thin alias used by helpers in this file to keep
|
||||
// the import set explicit even when the helper would otherwise be one-line.
|
||||
func jsonDecoderUnmarshal(b []byte, v interface{}) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// =====================================================================
|
||||
// Writing-path lint integration tests — compose 5 + +draft-edit emit
|
||||
// `lint_applied[]` and `original_blocked[]` arrays in the stdout envelope
|
||||
// always.
|
||||
// =====================================================================
|
||||
|
||||
// TestRunWritePathLint_PlainTextReturnsEmptyReport verifies the helper
|
||||
// short-circuits on plain-text input.
|
||||
func TestRunWritePathLint_PlainTextReturnsEmptyReport(t *testing.T) {
|
||||
cleaned, rep := runWritePathLint("")
|
||||
if cleaned != "" {
|
||||
t.Errorf("cleaned = %q, want empty", cleaned)
|
||||
}
|
||||
if rep.Applied == nil || rep.Blocked == nil {
|
||||
t.Error("Applied/Blocked must be non-nil")
|
||||
}
|
||||
if len(rep.Applied) != 0 || len(rep.Blocked) != 0 {
|
||||
t.Errorf("expected empty report, got applied=%d blocked=%d",
|
||||
len(rep.Applied), len(rep.Blocked))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated verifies the
|
||||
// writing path always autofixes warnings and never elevates them — the
|
||||
// writing-path safety contract has no opt-out. The input
|
||||
// triggers two warning autofixes (<p> paragraph-rewrite + <font> tag
|
||||
// rewrite); both must surface in `Applied` and never appear in `Blocked`.
|
||||
func TestRunWritePathLint_HTMLAlwaysAutofixedWarningNeverElevated(t *testing.T) {
|
||||
cleaned, rep := runWritePathLint(`<p><font color="red">x</font></p>`)
|
||||
if !strings.Contains(cleaned, "<span") {
|
||||
t.Errorf("expected autofix to rewrite <font>, cleaned=%q", cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, "<p>") || strings.Contains(cleaned, "<font") {
|
||||
t.Errorf("expected <p>/<font> rewritten, cleaned=%q", cleaned)
|
||||
}
|
||||
if len(rep.Applied) < 1 {
|
||||
t.Errorf("expected ≥1 warning surfaced (font + paragraph autofix), got %d", len(rep.Applied))
|
||||
}
|
||||
// Warnings never become errors on the writing-path; --strict no longer
|
||||
// exists at the surface either, so the contract is "Applied gathers
|
||||
// warnings, Blocked stays empty for warning-only inputs".
|
||||
if len(rep.Blocked) != 0 {
|
||||
t.Errorf("writing-path must NOT elevate warnings; expected 0 blocked, got %d", len(rep.Blocked))
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyLintToEnvelope_DefaultEmitsNoLintFields verifies the helper writes
|
||||
// zero keys in the default (non-detail) mode — neither count fields nor the
|
||||
// full Finding arrays appear; the envelope stays small.
|
||||
func TestApplyLintToEnvelope_DefaultEmitsNoLintFields(t *testing.T) {
|
||||
data := map[string]interface{}{"existing": "value"}
|
||||
rep := lint.EmptyReport(`<p>x</p>`)
|
||||
applyLintToEnvelope(data, rep.Applied, rep.Blocked, false)
|
||||
|
||||
if data["existing"] != "value" {
|
||||
t.Error("existing key was clobbered")
|
||||
}
|
||||
if _, ok := data["lint_applied_count"]; ok {
|
||||
t.Error("lint_applied_count must NOT be present in default mode")
|
||||
}
|
||||
if _, ok := data["original_blocked_count"]; ok {
|
||||
t.Error("original_blocked_count must NOT be present in default mode")
|
||||
}
|
||||
if _, ok := data["lint_applied"]; ok {
|
||||
t.Error("lint_applied[] must NOT be present in default mode")
|
||||
}
|
||||
if _, ok := data["original_blocked"]; ok {
|
||||
t.Error("original_blocked[] must NOT be present in default mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyLintToEnvelope_DetailModeIncludesArrays verifies the detail mode
|
||||
// (showDetails=true) attaches the two non-nil Finding arrays only. The
|
||||
// `*_count` fields are no longer emitted (callers can compute counts via
|
||||
// `len(arr)` themselves).
|
||||
func TestApplyLintToEnvelope_DetailModeIncludesArrays(t *testing.T) {
|
||||
data := map[string]interface{}{}
|
||||
rep := lint.EmptyReport(`<p>x</p>`)
|
||||
applyLintToEnvelope(data, rep.Applied, rep.Blocked, true)
|
||||
|
||||
if _, ok := data["lint_applied_count"]; ok {
|
||||
t.Error("lint_applied_count must NOT be present (count fields removed)")
|
||||
}
|
||||
if _, ok := data["original_blocked_count"]; ok {
|
||||
t.Error("original_blocked_count must NOT be present (count fields removed)")
|
||||
}
|
||||
la, ok := data["lint_applied"].([]lint.Finding)
|
||||
if !ok {
|
||||
t.Fatalf("lint_applied wrong type: %T", data["lint_applied"])
|
||||
}
|
||||
if la == nil {
|
||||
t.Error("lint_applied is nil — must be empty slice in detail mode")
|
||||
}
|
||||
ob, ok := data["original_blocked"].([]lint.Finding)
|
||||
if !ok {
|
||||
t.Fatalf("original_blocked wrong type: %T", data["original_blocked"])
|
||||
}
|
||||
if ob == nil {
|
||||
t.Error("original_blocked is nil — must be empty slice in detail mode")
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// End-to-end: +draft-create writing path emits envelope with lint fields.
|
||||
// =====================================================================
|
||||
|
||||
// TestMailDraftCreate_WritePathLintEnvelopeDefault verifies +draft-create's
|
||||
// default envelope contains the three always-present hint/id fields
|
||||
// (compose_hint + draft_edit_hint + draft_id) and carries NO lint fields at
|
||||
// all — neither `*_count` nor the full Finding arrays.
|
||||
func TestMailDraftCreate_WritePathLintEnvelopeDefault(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
registerDraftCreateOK(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Test",
|
||||
"--body", `<p>safe</p><script>alert(1)</script><font color="red">red</font>`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
|
||||
// The three always-present hint/id fields must appear.
|
||||
if hint, _ := data["compose_hint"].(string); hint == "" {
|
||||
t.Error("compose_hint must be present in default envelope")
|
||||
}
|
||||
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
|
||||
t.Error("draft_edit_hint must be present in +draft-create default envelope")
|
||||
} else if hint != draftEditHintConst {
|
||||
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
|
||||
}
|
||||
if id, _ := data["draft_id"].(string); id == "" {
|
||||
t.Error("draft_id must be present in default envelope")
|
||||
}
|
||||
|
||||
// No lint fields (neither count nor arrays) in default mode.
|
||||
if _, present := data["lint_applied_count"]; present {
|
||||
t.Error("lint_applied_count must NOT appear (count fields removed)")
|
||||
}
|
||||
if _, present := data["original_blocked_count"]; present {
|
||||
t.Error("original_blocked_count must NOT appear (count fields removed)")
|
||||
}
|
||||
if _, present := data["lint_applied"]; present {
|
||||
t.Error("lint_applied[] must be hidden in default mode")
|
||||
}
|
||||
if _, present := data["original_blocked"]; present {
|
||||
t.Error("original_blocked[] must be hidden in default mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreate_WritePathLintEnvelopeWithDetails verifies that passing
|
||||
// --show-lint-details attaches the two Finding arrays only — no `*_count`
|
||||
// fields — while still keeping compose_hint + draft_edit_hint + draft_id.
|
||||
func TestMailDraftCreate_WritePathLintEnvelopeWithDetails(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
registerDraftCreateOK(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Test",
|
||||
"--body", `<p>safe</p><script>alert(1)</script><font color="red">red</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
|
||||
// Always-present hint/id fields survive in detail mode.
|
||||
if hint, _ := data["compose_hint"].(string); hint == "" {
|
||||
t.Error("compose_hint must be present in detail envelope")
|
||||
}
|
||||
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
|
||||
t.Error("draft_edit_hint must be present in +draft-create detail envelope")
|
||||
} else if hint != draftEditHintConst {
|
||||
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
|
||||
}
|
||||
if id, _ := data["draft_id"].(string); id == "" {
|
||||
t.Error("draft_id must be present in detail envelope")
|
||||
}
|
||||
|
||||
// `*_count` fields are gone — callers compute counts via len(arr).
|
||||
if _, present := data["lint_applied_count"]; present {
|
||||
t.Error("lint_applied_count must NOT appear (count fields removed)")
|
||||
}
|
||||
if _, present := data["original_blocked_count"]; present {
|
||||
t.Error("original_blocked_count must NOT appear (count fields removed)")
|
||||
}
|
||||
|
||||
la, ok := data["lint_applied"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("lint_applied missing or wrong type: %T", data["lint_applied"])
|
||||
}
|
||||
ob, ok := data["original_blocked"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("original_blocked missing or wrong type: %T", data["original_blocked"])
|
||||
}
|
||||
if len(la) < 1 {
|
||||
t.Errorf("expected ≥1 lint_applied entry, got %d", len(la))
|
||||
}
|
||||
if len(ob) < 1 {
|
||||
t.Errorf("expected ≥1 original_blocked entry, got %d", len(ob))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreate_PlainTextWritePathOmitsLintFields verifies the
|
||||
// plain-text path's default envelope contains the always-present
|
||||
// compose_hint + draft_edit_hint + draft_id and emits no lint fields at all.
|
||||
func TestMailDraftCreate_PlainTextWritePathOmitsLintFields(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
registerDraftCreateOK(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Test",
|
||||
"--body", "plain text only",
|
||||
"--plain-text",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
|
||||
// Always-present hint/id fields on the plain-text branch.
|
||||
if hint, _ := data["compose_hint"].(string); hint == "" {
|
||||
t.Error("compose_hint must be present on plain-text path")
|
||||
}
|
||||
if hint, _ := data["draft_edit_hint"].(string); hint == "" {
|
||||
t.Error("draft_edit_hint must be present on +draft-create plain-text path")
|
||||
} else if hint != draftEditHintConst {
|
||||
t.Errorf("draft_edit_hint = %q, want exact const value", hint)
|
||||
}
|
||||
if id, _ := data["draft_id"].(string); id == "" {
|
||||
t.Error("draft_id must be present on plain-text path")
|
||||
}
|
||||
|
||||
// No lint fields at all on the default plain-text path.
|
||||
if _, present := data["lint_applied_count"]; present {
|
||||
t.Error("lint_applied_count must NOT appear on plain-text default path")
|
||||
}
|
||||
if _, present := data["original_blocked_count"]; present {
|
||||
t.Error("original_blocked_count must NOT appear on plain-text default path")
|
||||
}
|
||||
if _, present := data["lint_applied"]; present {
|
||||
t.Error("lint_applied[] must be hidden in default mode (plain-text)")
|
||||
}
|
||||
if _, present := data["original_blocked"]; present {
|
||||
t.Error("original_blocked[] must be hidden in default mode (plain-text)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreate_AutofixApplied verifies that the writing path actually
|
||||
// rewrites the body before sending it to drafts.create — the user's <font>
|
||||
// tag must NOT reach the network as <font>.
|
||||
func TestMailDraftCreate_AutofixApplied(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_test"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Test",
|
||||
"--body", `<font color="red">x</font>`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Decode the raw EML and confirm <font> was rewritten before reaching
|
||||
// emlbuilder. The base64url payload contains the HTML body in raw form.
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("write-path should have rewritten <font>, EML still contains it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> wrapper in EML, got %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreate_ScriptStrippedBeforeSend verifies <script> is removed
|
||||
// from the EML before drafts.create is invoked (writing-path safety floor).
|
||||
func TestMailDraftCreate_ScriptStrippedBeforeSend(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_test"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Test",
|
||||
"--body", `<p>before</p><script>alert(1)</script><p>after</p>`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
eml := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(eml, "<script") {
|
||||
t.Errorf("script should be stripped before EML send, got %q", eml)
|
||||
}
|
||||
if strings.Contains(eml, "alert(1)") {
|
||||
t.Errorf("script content should be removed, got %q", eml)
|
||||
}
|
||||
if !strings.Contains(eml, "before") || !strings.Contains(eml, "after") {
|
||||
t.Errorf("surrounding paragraphs should survive, got %q", eml)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers — mail_shortcut_test.go ships the factory; these are local
|
||||
// httpmock registrations specific to the lint integration tests.
|
||||
// =====================================================================
|
||||
|
||||
// registerMailboxProfileMock registers a stock GET .../profile response so
|
||||
// resolveComposeSenderEmail finds an address.
|
||||
func registerMailboxProfileMock(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"primary_email_address": "sender@example.com",
|
||||
"send_as": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerDraftCreateOK registers a successful drafts.create response.
|
||||
func registerDraftCreateOK(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"draft_id": "d_test123",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mustDecodeRawEMLFromStub extracts the `raw` field from a captured body and
|
||||
// base64url-decodes it. The stub.CapturedBody is populated by the httpmock
|
||||
// after a match (registry.go:42 — the stub records every captured request).
|
||||
func mustDecodeRawEMLFromStub(t *testing.T, stub *httpmock.Stub) string {
|
||||
t.Helper()
|
||||
if len(stub.CapturedBody) == 0 {
|
||||
t.Fatal("stub did not capture any request body")
|
||||
}
|
||||
var captured map[string]interface{}
|
||||
if err := jsonUnmarshal(stub.CapturedBody, &captured); err != nil {
|
||||
t.Fatalf("decode captured body: %v", err)
|
||||
}
|
||||
raw, ok := captured["raw"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("captured body has no `raw` string field: %#v", captured)
|
||||
}
|
||||
return decodeBase64URL(raw)
|
||||
}
|
||||
|
||||
func jsonUnmarshal(b []byte, v interface{}) error {
|
||||
return jsonDecoderUnmarshal(b, v)
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// End-to-end coverage for the 5 other compose shortcuts. Each test feeds
|
||||
// HTML containing a <font> tag (warning-tier autofix target) through the
|
||||
// shortcut and asserts (a) the EML sent on the wire has the <font>
|
||||
// rewritten to <span>, and (b) the envelope honours `--show-lint-details`.
|
||||
// =====================================================================
|
||||
|
||||
// stubSourceMessageHTML registers a minimal source-message GET stub that
|
||||
// `+reply` / `+reply-all` / `+forward` use to derive the parent message
|
||||
// headers + body. The original body is plain HTML so the reply lint path
|
||||
// is exercised on the user-authored body only (the writing-path contract:
|
||||
// quoted block is never re-linted).
|
||||
func stubSourceMessageHTML(reg *httpmock.Registry, bodyHTML string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"primary_email_address": "me@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/messages/msg_w1",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"message_id": "msg_w1",
|
||||
"thread_id": "thread_w1",
|
||||
"smtp_message_id": "<msg_w1@example.com>",
|
||||
"subject": "Original",
|
||||
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
|
||||
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
|
||||
"cc": []interface{}{},
|
||||
"bcc": []interface{}{},
|
||||
"body_html": base64URLEncode(bodyHTML),
|
||||
"body_plain_text": base64URLEncode("plain"),
|
||||
"internal_date": "1704067200000",
|
||||
"attachments": []map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// base64URLEncode wraps encoding/base64.URLEncoding.EncodeToString to keep
|
||||
// the new tests readable inline.
|
||||
func base64URLEncode(s string) string {
|
||||
return base64.URLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// TestMailSend_WritePathLintAutofixesFontInEML drives +send end-to-end with
|
||||
// HTML containing a <font> tag and asserts the body in the captured EML has
|
||||
// been rewritten to <span> before the drafts.create POST.
|
||||
func TestMailSend_WritePathLintAutofixesFontInEML(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_send"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailSend, []string{
|
||||
"+send",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Send",
|
||||
"--body", `<font color="red">payload</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("send failed: %v", err)
|
||||
}
|
||||
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("+send writing-path should rewrite <font>, EML still has it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> in EML, got %q", captured)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
la, ok := data["lint_applied"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("lint_applied missing or wrong type: %T", data["lint_applied"])
|
||||
}
|
||||
if len(la) < 1 {
|
||||
t.Errorf("expected ≥1 lint_applied entry, got %d", len(la))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReply_WritePathLintAutofixesFontInEML drives +reply end-to-end.
|
||||
func TestMailReply_WritePathLintAutofixesFontInEML(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
stubSourceMessageHTML(reg, `<p>Original</p>`)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_reply"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply",
|
||||
"--message-id", "msg_w1",
|
||||
"--body", `<font color="red">reply text</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply failed: %v", err)
|
||||
}
|
||||
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("+reply writing-path should rewrite <font>, EML still has it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> in EML, got %q", captured)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if _, present := data["lint_applied"]; !present {
|
||||
t.Error("lint_applied should appear under --show-lint-details")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailReplyAll_WritePathLintAutofixesFontInEML drives +reply-all e2e.
|
||||
func TestMailReplyAll_WritePathLintAutofixesFontInEML(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
stubSourceMessageHTML(reg, `<p>Original</p>`)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_replyall"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all",
|
||||
"--message-id", "msg_w1",
|
||||
"--body", `<font color="red">reply-all text</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply-all failed: %v", err)
|
||||
}
|
||||
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("+reply-all writing-path should rewrite <font>, EML still has it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> in EML, got %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailForward_WritePathLintAutofixesFontInEML drives +forward e2e.
|
||||
func TestMailForward_WritePathLintAutofixesFontInEML(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
stubSourceMessageHTML(reg, `<p>Original</p>`)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_forward"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward",
|
||||
"--message-id", "msg_w1",
|
||||
"--to", "bob@example.com",
|
||||
"--body", `<font color="red">forward note</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("forward failed: %v", err)
|
||||
}
|
||||
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("+forward writing-path should rewrite <font>, EML still has it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> in EML, got %q", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftEdit_WritePathLintAutofixesFontViaBodyFlag verifies the
|
||||
// `--body` shortcut on +draft-edit (which lowers to a set_body patch op)
|
||||
// runs the writing-path lint before PUT-ing the updated EML.
|
||||
func TestMailDraftEdit_WritePathLintAutofixesFontViaBodyFlag(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
// drafts.get(format=raw) returns a minimal multipart EML so the parser
|
||||
// has a body to patch.
|
||||
originalEML := "MIME-Version: 1.0\r\n" +
|
||||
"From: me@example.com\r\n" +
|
||||
"To: alice@example.com\r\n" +
|
||||
"Subject: Edit\r\n" +
|
||||
"Content-Type: text/html; charset=utf-8\r\n" +
|
||||
"\r\n" +
|
||||
"<p>original body</p>\r\n"
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/drafts/d_edit",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"draft_id": "d_edit",
|
||||
"raw": base64URLEncode(originalEML),
|
||||
},
|
||||
},
|
||||
})
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/user_mailboxes/me/drafts/d_edit",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"draft_id": "d_edit"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftEdit, []string{
|
||||
"+draft-edit",
|
||||
"--draft-id", "d_edit",
|
||||
"--body", `<font color="red">new body</font>`,
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("draft-edit failed: %v", err)
|
||||
}
|
||||
|
||||
captured := mustDecodeRawEMLFromStub(t, stub)
|
||||
if strings.Contains(captured, "<font") {
|
||||
t.Errorf("+draft-edit writing-path should rewrite <font>, EML still has it: %q", captured)
|
||||
}
|
||||
if !strings.Contains(captured, "<span") {
|
||||
t.Errorf("expected <span> in EML, got %q", captured)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if _, present := data["lint_applied"]; !present {
|
||||
t.Error("lint_applied should appear under --show-lint-details on +draft-edit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailDraftCreate_PlainTextShowLintDetailsEmitsEmptyArrays locks the
|
||||
// 2×2 corner: plain-text body + --show-lint-details. The envelope must
|
||||
// surface the two contract arrays as empty (non-nil) slices because the
|
||||
// detail flag toggles their presence; the plain-text branch produces zero
|
||||
// findings but the keys must still appear so consumers can rely on them
|
||||
// unconditionally.
|
||||
func TestMailDraftCreate_PlainTextShowLintDetailsEmitsEmptyArrays(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
chdirTemp(t)
|
||||
registerMailboxProfileMock(reg)
|
||||
registerDraftCreateOK(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailDraftCreate, []string{
|
||||
"+draft-create",
|
||||
"--to", "alice@example.com",
|
||||
"--subject", "Plain",
|
||||
"--body", "plain text body, no html",
|
||||
"--plain-text",
|
||||
"--show-lint-details",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
la, ok := data["lint_applied"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("lint_applied missing or wrong type on plain-text + show-lint-details: %T", data["lint_applied"])
|
||||
}
|
||||
if len(la) != 0 {
|
||||
t.Errorf("plain-text body should produce 0 lint_applied entries, got %d", len(la))
|
||||
}
|
||||
ob, ok := data["original_blocked"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("original_blocked missing or wrong type: %T", data["original_blocked"])
|
||||
}
|
||||
if len(ob) != 0 {
|
||||
t.Errorf("plain-text body should produce 0 original_blocked entries, got %d", len(ob))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/emlbuilder"
|
||||
@@ -24,9 +23,11 @@ var MailReply = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to", Required: true},
|
||||
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
|
||||
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
|
||||
bodyFileFlag,
|
||||
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
|
||||
{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original sender's address)"},
|
||||
@@ -42,7 +43,8 @@ var MailReply = common.Shortcut{
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -70,8 +72,17 @@ var MailReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
hasTemplate := runtime.Str("template-id") != ""
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
|
||||
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
@@ -95,7 +106,10 @@ var MailReply = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
body := runtime.Str("body")
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
toFlag := runtime.Str("to")
|
||||
ccFlag := runtime.Str("cc")
|
||||
bccFlag := runtime.Str("bcc")
|
||||
@@ -244,6 +258,10 @@ var MailReply = common.Shortcut{
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
var srcInlineBytes int64
|
||||
// Lint findings flowing into the writing-path stdout envelope.
|
||||
// Initialise empty (non-nil) so the envelope always carries
|
||||
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("HTML reply blocked: %w", err)
|
||||
@@ -261,6 +279,15 @@ var MailReply = common.Shortcut{
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
// Writing-path lint: operate on the user-authored body + signature
|
||||
// ONLY — NOT on `quoted` (the <blockquote> derived from the
|
||||
// original message). Double-sanitising risks dropping legitimate
|
||||
// Lark quote markup such as adit-html-block* / history-quote-* /
|
||||
// lark-mail-doc-quote (these classes are intentionally allow-listed
|
||||
// in the tag classification "通过" row).
|
||||
cleaned, rep := runWritePathLint(bodyWithSig)
|
||||
bodyWithSig = cleaned
|
||||
lintApplied, lintBlocked = rep.Applied, rep.Blocked
|
||||
composedHTMLBody = bodyWithSig + quoted
|
||||
bld = bld.HTMLBody([]byte(composedHTMLBody))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
@@ -316,8 +343,12 @@ var MailReply = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
|
||||
out := buildDraftSavedOutput(draftResult, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
|
||||
return nil
|
||||
}
|
||||
@@ -325,7 +356,10 @@ var MailReply = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/emlbuilder"
|
||||
@@ -24,9 +23,11 @@ var MailReplyAll = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true},
|
||||
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
|
||||
{Name: "body", Desc: "Reply body. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the reply body and the original message. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
|
||||
bodyFileFlag,
|
||||
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
|
||||
{Name: "to", Desc: "Additional To address(es), comma-separated (appended to original recipients)"},
|
||||
@@ -43,7 +44,8 @@ var MailReplyAll = common.Shortcut{
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's body/to/cc/bcc/attachments are appended to the reply-derived values (no de-duplication; see warning in Execute output)."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
messageId := runtime.Str("message-id")
|
||||
confirmSend := runtime.Bool("confirm-send")
|
||||
@@ -71,8 +73,17 @@ var MailReplyAll = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
hasTemplate := runtime.Str("template-id") != ""
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
|
||||
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the reply body (or use --template-id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateConfirmSendScope(runtime); err != nil {
|
||||
return err
|
||||
@@ -96,7 +107,10 @@ var MailReplyAll = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
messageId := runtime.Str("message-id")
|
||||
body := runtime.Str("body")
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
toFlag := runtime.Str("to")
|
||||
ccFlag := runtime.Str("cc")
|
||||
bccFlag := runtime.Str("bcc")
|
||||
@@ -253,6 +267,8 @@ var MailReplyAll = common.Shortcut{
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
var srcInlineBytes int64
|
||||
// Lint findings flowing into the writing-path stdout envelope.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("HTML reply-all blocked: %w", err)
|
||||
@@ -270,6 +286,13 @@ var MailReplyAll = common.Shortcut{
|
||||
if sigResult != nil {
|
||||
bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent)
|
||||
}
|
||||
// Writing-path lint: same pattern as +reply — operate on bodyWithSig
|
||||
// only; the `quoted` block from the original message must NOT be
|
||||
// re-linted (it may contain Feishu-native quote-block classes that
|
||||
// the lint allow-list intentionally permits in pass-through).
|
||||
cleaned, rep := runWritePathLint(bodyWithSig)
|
||||
bodyWithSig = cleaned
|
||||
lintApplied, lintBlocked = rep.Applied, rep.Blocked
|
||||
composedHTMLBody = bodyWithSig + quoted
|
||||
bld = bld.HTMLBody([]byte(composedHTMLBody))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
@@ -325,8 +348,12 @@ var MailReplyAll = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
|
||||
out := buildDraftSavedOutput(draftResult, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
|
||||
return nil
|
||||
}
|
||||
@@ -334,7 +361,10 @@ var MailReplyAll = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply-all (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintMarkAsRead(runtime, mailboxID, messageId)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -23,10 +23,12 @@ var MailSend = common.Shortcut{
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "to", Desc: "Recipient email address(es), comma-separated"},
|
||||
{Name: "subject", Desc: "Email subject. Required unless --template-id supplies a non-empty subject."},
|
||||
{Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Required unless --template-id supplies a non-empty body."},
|
||||
{Name: "body", Desc: "Email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
|
||||
bodyFileFlag,
|
||||
{Name: "from", Desc: "Sender email address for the From header. When using an alias (send_as) address, set this to the alias and use --mailbox for the owning mailbox. Defaults to the mailbox's primary address."},
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the draft (default: falls back to --from, then me). Use this when the sender (--from) differs from the mailbox, e.g. sending via an alias or send_as address."},
|
||||
{Name: "cc", Desc: "CC email address(es), comma-separated"},
|
||||
@@ -40,7 +42,8 @@ var MailSend = common.Shortcut{
|
||||
{Name: "template-id", Desc: "Optional. Apply a saved template by ID (decimal integer string) before composing. The template's subject/body/to/cc/bcc/attachments are merged with user-supplied flags (user flags win). Requires --as user."},
|
||||
signatureFlag,
|
||||
priorityFlag,
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
|
||||
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
|
||||
showLintDetailsFlag},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
@@ -74,12 +77,14 @@ var MailSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
hasTemplate := runtime.Str("template-id") != ""
|
||||
bodyFlag := runtime.Str("body")
|
||||
bodyFile := strings.TrimSpace(runtime.Str("body-file"))
|
||||
if err := validateBodyFileMutex(bodyFlag, bodyFile, runtime.ValidatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
|
||||
return output.ErrValidation("--subject is required; pass the final email subject (or use --template-id)")
|
||||
}
|
||||
if !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
|
||||
return output.ErrValidation("--body is required; pass the full email body (or use --template-id)")
|
||||
}
|
||||
// With --template-id, tos/ccs/bccs may come from the template, so
|
||||
// defer the at-least-one-recipient check to Execute (after
|
||||
// applyTemplate has merged the template addresses in).
|
||||
@@ -97,7 +102,19 @@ var MailSend = common.Shortcut{
|
||||
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
|
||||
// Resolve the body content first (reading --body-file if set) so
|
||||
// inline / HTML checks see the actual body. This makes the
|
||||
// `--body-file plain.txt --inline …` combination fail validation
|
||||
// the same way `--body 'plain' --inline …` already does, instead
|
||||
// of silently dropping the inline images at Execute (Major #4).
|
||||
body, bErr := resolveBodyFromFlags(runtime)
|
||||
if bErr != nil {
|
||||
return bErr
|
||||
}
|
||||
if err := validateRequiredResolvedBody(body, hasTemplate, "--body or --body-file is required; pass the full email body (or use --template-id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEventFlags(runtime); err != nil {
|
||||
@@ -108,7 +125,10 @@ var MailSend = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
to := runtime.Str("to")
|
||||
subject := runtime.Str("subject")
|
||||
body := runtime.Str("body")
|
||||
body, err := resolveBodyFromFlags(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ccFlag := runtime.Str("cc")
|
||||
bccFlag := runtime.Str("bcc")
|
||||
plainText := runtime.Bool("plain-text")
|
||||
@@ -206,6 +226,10 @@ var MailSend = common.Shortcut{
|
||||
var autoResolvedPaths []string
|
||||
var composedHTMLBody string
|
||||
var composedTextBody string
|
||||
// Lint findings flowing into the writing-path stdout envelope.
|
||||
// Initialised as empty (non-nil) slices so the envelope always carries
|
||||
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
|
||||
lintApplied, lintBlocked := emptyLintEnvelopeFields()
|
||||
if plainText {
|
||||
composedTextBody = body
|
||||
bld = bld.TextBody([]byte(composedTextBody))
|
||||
@@ -220,6 +244,14 @@ var MailSend = common.Shortcut{
|
||||
return resolveErr
|
||||
}
|
||||
resolved = injectSignatureIntoBody(resolved, sigResult)
|
||||
// Writing-path lint: AutoFix=true / Strict=false — the writing-path
|
||||
// safety contract has no `--no-lint` opt-out. Runs AFTER
|
||||
// applyTemplate (above) + ResolveLocalImagePaths +
|
||||
// injectSignatureIntoBody so the lint sees the final HTML the
|
||||
// recipient renderer will see.
|
||||
cleanedHTML, rep := runWritePathLint(resolved)
|
||||
resolved = cleanedHTML
|
||||
lintApplied, lintBlocked = rep.Applied, rep.Blocked
|
||||
composedHTMLBody = resolved
|
||||
bld = bld.HTMLBody([]byte(composedHTMLBody))
|
||||
bld = addSignatureImagesToBuilder(bld, sigResult)
|
||||
@@ -283,8 +315,12 @@ var MailSend = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
showLintDetails := runtime.Bool("show-lint-details")
|
||||
if !confirmSend {
|
||||
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
|
||||
out := buildDraftSavedOutput(draftResult, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
|
||||
return nil
|
||||
}
|
||||
@@ -292,7 +328,10 @@ var MailSend = common.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
|
||||
}
|
||||
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
|
||||
out := buildDraftSendOutput(resData, mailboxID)
|
||||
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
|
||||
addComposeHint(out)
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
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
|
||||
@@ -49,6 +51,57 @@ func assertValidatePasses(t *testing.T, err error) {
|
||||
// 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)
|
||||
|
||||
@@ -26,5 +26,6 @@ func Shortcuts() []common.Shortcut {
|
||||
MailShareToChat,
|
||||
MailTemplateCreate,
|
||||
MailTemplateUpdate,
|
||||
MailLintHTML,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +322,57 @@ lark-cli mail +send --to alice@example.com --subject '周报' \
|
||||
lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
```
|
||||
|
||||
**HTML 写法、风格指引、场景模板请参考两份配套文档:**
|
||||
|
||||
- [邮件 HTML 写法指南](references/lark-mail-html.md) — 标签 / class / inline style 速查、飞书原生写法(含风格指引)、完整场景模板(通知 / 周报 / 决策请求);表格 / 列表 / 字号 / 引用 / 链接 / 内嵌图片标准写法都在这里
|
||||
- [`+lint-html` 用法](references/lark-mail-lint-html.md) — 创建草稿前自检 / 修复 AI 输出
|
||||
|
||||
### 邮件风格规范
|
||||
|
||||
写信时必须遵守的文风底线(详见 [邮件 HTML 写法指南](references/lark-mail-html.md)):
|
||||
|
||||
- **禁机械编号**:用 `<ul>` / `<ol>` 表达列表,不要用 "一、二、三" / "①②③" / "1) 2) 3)"
|
||||
- **emoji 克制**:emoji 仅作状态标签(⏰紧急 / ✅完成 / ⚠️风险),不要在正文段落里堆 emoji 装饰
|
||||
- **禁冗长 disclaimer**:删除 "希望对您有帮助" / "感谢您的耐心阅读" 等填充语;信息密度优先
|
||||
- **标题 ≤ 30 字**:邮件主题 `--subject` 控制在 30 字内,避免被收件箱截断
|
||||
- **决策 / 结论前置**:第一段就给结论或决策项,让收件人扫一眼就知道是不是需要他做什么
|
||||
- **问候 / 落款不超 1 段**:`Hi 各位 Reviewer,` / `各位同事:` 一句话即可;落款 `[发件人姓名] / [团队] / [日期]` 一行结束
|
||||
|
||||
### 严禁手拼 raw EML
|
||||
|
||||
> **CRITICAL:严禁手拼 raw EML 直传 `drafts.create`,必须走 compose 5 shortcut(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward`)或 `+draft-edit` 的 body op。**
|
||||
|
||||
`emlbuilder` 已内置 RFC 合规处理(base64 / boundary / header folding / 附件 RFC 2231 等),AI **无需自学 RFC**。手拼 raw EML 几乎一定会踩坑(编码错误 / 边界冲突 / 收件端不渲染),且绕开了 lark-cli 的统一安全和兼容性兜底——本仓库的 `+send` / `+draft-create` 等 shortcut 已封装好所有发信细节,AI 只需关注业务字段(收件人 / 主题 / HTML 正文 / 附件路径)即可。
|
||||
|
||||
### 写入路径内置 HTML lint
|
||||
|
||||
`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op 在调用 `emlbuilder` **之前**会强制对 HTML 正文做 lint:
|
||||
|
||||
- 错误(`<script>` / `on*` / `javascript:` URL / `<iframe>` / `<form>` / `<style>` / `<link>` 等)会被**直接删除**
|
||||
- 警告(`<font>` / `<center>` / `<marquee>`)会被**自动修复**为飞书原生写法
|
||||
- 不允许的 CSS property(`position` / `z-index` / `transform` 等)会从 inline `style` 里删除
|
||||
|
||||
默认 envelope 只携带必要字段;加 `--show-lint-details` 后会同时输出两个 Finding 数组(无违规时是空数组),方便调用方调试:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"draft_id": "...",
|
||||
"lint_applied": [
|
||||
{"rule_id": "TAG_FONT_TO_SPAN", "severity": "warning", "tag_or_attr": "font",
|
||||
"excerpt": "<font color=\"red\"...>", "hint": "已替换为 <span style=...>"}
|
||||
],
|
||||
"original_blocked": [
|
||||
{"rule_id": "TAG_SCRIPT_BLOCKED", "severity": "error", "tag_or_attr": "script",
|
||||
"excerpt": "<script...>", "hint": "已整段删除(XSS 风险)"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
写入路径**没有 `--no-lint` 总开关**——这是本方案的安全契约。如果想预先看 HTML 是否会被改动,先用 [`+lint-html`](references/lark-mail-lint-html.md) 跑一次。
|
||||
|
||||
### 读取邮件:按需控制返回内容
|
||||
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
|
||||
@@ -12,6 +12,8 @@ metadata:
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
## 核心概念
|
||||
|
||||
- **邮件(Message)**:一封具体的邮件,包含发件人、收件人、主题、正文(纯文本/HTML)、附件。每封邮件有唯一 `message_id`。
|
||||
@@ -99,9 +101,10 @@ metadata:
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -336,6 +339,12 @@ lark-cli mail +send --to alice@example.com --subject '周报' \
|
||||
lark-cli mail +reply --message-id <id> --body '收到,谢谢'
|
||||
```
|
||||
|
||||
## 邮件书写规范
|
||||
|
||||
- 写信时**必须**遵守 [邮件 HTML 写法规范](references/lark-mail-html.md) — **CRITICAL** 飞书邮箱已验证的最纯净美观写法集合
|
||||
- [`+lint-html` 用法](references/lark-mail-lint-html.md) — 创建草稿前自检 / 修复 HTML 输出
|
||||
- **官方模板库** [`assets/templates/`](assets/templates/) — 提供部分场景模板,可供参考
|
||||
|
||||
### 读取邮件:按需控制返回内容
|
||||
|
||||
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
|
||||
@@ -354,6 +363,8 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
模板的创建 / 更新由专用 shortcut 处理(自动做 Drive 上传 + `<img src>` 改写成 `cid:`);发信类 shortcut 通过 `--template-id <id>` 套用模板。
|
||||
|
||||
> **跟仓库 `assets/templates/` 的区别**:本节讲的是**飞书 OAPI 的个人邮件模板系统**(用户邮箱里的"我的模板"),可在飞书客户端管理;上面"仓库内置 HTML 模板库"是 lark-cli 仓库里预制的飞书原生 HTML 文件,可供写信参考。
|
||||
|
||||
**管理模板**:
|
||||
|
||||
- [`+template-create`](references/lark-mail-template-create.md) — 创建新模板。`--name` 必填;正文通过 `--template-content` 或 `--template-content-file` 二选一;支持 HTML 内嵌图片自动上传到 Drive。
|
||||
@@ -472,6 +483,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)
|
||||
| [`+share-to-chat`](references/lark-mail-share-to-chat.md) | Share an email or thread as a card to a Lark IM chat. |
|
||||
| [`+template-create`](references/lark-mail-template-create.md) | Create a personal mail template. Scans HTML <img src> local paths (reusing draft inline-image detection), uploads inline images and non-inline attachments to Drive, rewrites HTML to cid: references, and POSTs a Template payload to mail.user_mailbox.templates.create. |
|
||||
| [`+template-update`](references/lark-mail-template-update.md) | Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites <img> local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins). |
|
||||
| [`+lint-html`](references/lark-mail-lint-html.md) | Lint mail HTML body for compatibility / safety / Feishu-native rules. Returns warnings/errors and (default) auto-fixed HTML. Read-only: no draft, no API call. Use this BEFORE creating a draft to preview what the writing-path lint would change, or as a CI gate for static HTML templates. |
|
||||
|
||||
## API Resources
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
SUBJECT 模板(lark-cli mail --subject 用):
|
||||
应聘 [期望职位] · [姓名]
|
||||
-->
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[招聘负责人称呼,如 HR / 团队负责人 / 招聘组],您好:</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">我是 [姓名],关注到贵司 [期望职位] 岗位,结合 [简短亮点:领域 / 经验 / 项目] 投递简历,期待进一步沟通。</span></span></div></div>
|
||||
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-size:22px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(36,91,219)">[姓名]</span></span></span></b></div></div>
|
||||
<div style="margin-top:0px;margin-bottom:20px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:14px">应聘 [期望职位]|[工作年限] 工作经验</span></span></div></div>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">基本信息</span></span></span></b></div></div>
|
||||
<table style="border-collapse:collapse;width:100%;font-size:13px"><tbody><tr><td style="padding:4px 8px 4px 0;width:18%;color:rgb(143,149,158);vertical-align:top">姓名</td><td style="padding:4px 8px 4px 0;width:32%;color:rgb(31,35,41);vertical-align:top">[姓名]</td><td style="padding:4px 8px 4px 0;width:18%;color:rgb(143,149,158);vertical-align:top">性别</td><td style="padding:4px 0;width:32%;color:rgb(31,35,41);vertical-align:top">[性别]</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">电话</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[+86 1XX-XXXX-XXXX]</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">邮箱</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top"><a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a></td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">生日</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[YYYY-MM-DD]([N] 岁)</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">工作年限</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[N] 年</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">家乡</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[城市]</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">当前城市</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[城市]</td></tr><tr><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">意向城市</td><td style="padding:4px 8px 4px 0;color:rgb(31,35,41);vertical-align:top">[城市 1] / [城市 2] / 不限</td><td style="padding:4px 8px 4px 0;color:rgb(143,149,158);vertical-align:top">期望职位</td><td style="padding:4px 0;color:rgb(31,35,41);vertical-align:top">[期望职位]</td></tr></tbody></table>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">教育经历</span></span></span></b></div></div>
|
||||
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="edu" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[学校名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [学历,如 本科 / 硕士 / 博士] · [专业]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="edu" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[学校名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [学历] · [专业]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span></li></ol>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">工作经历</span></span></span></b></div></div>
|
||||
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="work" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[公司名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [职位] · [全职 / 实习 / 兼职]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM 或 至今]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 1:聚焦动作 + 产出,附数据 / 影响范围]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 2:核心成果 + 关键技术 / 方法]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 3]</span></span></li></ul></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="work" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[公司名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [职位] · [全职 / 实习 / 兼职]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 1]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[工作职责描述 2]</span></span></li></ul></li></ol>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">项目经历</span></span></span></b></div></div>
|
||||
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="proj" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [角色,如 负责人 / 核心开发 / 设计主导]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目背景 / 业务价值 1 句话]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[关键贡献 / 技术栈]</span></span></li><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目成果 / 数据指标]</span></span></li></ul></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="proj" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> · [角色]</span></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM] ~ [YYYY-MM]</span></span><ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside"><li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:2px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[项目描述 + 关键贡献 + 成果数据]</span></span></li></ul></li></ol>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">技能</span></span></span></b></div></div>
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:2"><div dir="auto" style="font-size:14px"><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 1]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 2]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 3]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 4]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 5]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 6]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 7]</span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;font-size:12px;margin-right:6px">[技能 8]</span></div></div>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">证书</span></span></span></b></div></div>
|
||||
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[证书名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [一句话描述:颁发机构 / 等级 / 用途]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[证书名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">语言能力</span></span></span></b></div></div>
|
||||
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[语言,如 中文 / 英文 / 日语]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [精通程度:母语 / 流利 / 商务 / 日常 / 入门]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[语言]</span></span></b><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [精通程度] / [证书或考试成绩,如 CET-6 590、TOEFL 105、JLPT N1]</span></span></li></ul>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">竞赛信息</span></span></span></b></div></div>
|
||||
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[竞赛名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [名次 / 角色 + 一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[竞赛名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">获奖信息</span></span></span></b></div></div>
|
||||
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[获奖名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [颁发机构 / 评选范围 + 一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[获奖名称]</span></span></b><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · [YYYY-MM]</span></span><span style="font-family:inherit"><span style="color:rgb(31,35,41)"> — [描述]</span></span></li></ul>
|
||||
<div style="margin-top:24px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px;border-bottom:1px solid rgb(222,224,227);padding-bottom:6px"><b><span style="font-size:16px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">自我评价</span></span></span></b></div></div>
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[2-3 句话简评:技术深度 / 协作风格 / 长期方向,与岗位要求高度契合的方向。建议聚焦"为什么我适合这个岗位",避免"努力踏实诚信"这种通用形容词。]</span></span></div></div>
|
||||
<div style="margin-top:32px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">如需作品集 / 实习证明 / 推荐信等其它材料,欢迎进一步沟通面谈。期待您的回复。</span></span></div></div>
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">谢谢您的时间!</span></span></div></div>
|
||||
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">此致</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[姓名]</b></span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[+86 1XX-XXXX-XXXX]|<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a>|[YYYY-MM-DD]</span></span></div></div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(143,149,158)">WEEKLY DIGEST · 资讯周报</span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><b><span style="font-size:24px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(36,91,219)">[YYYY 第 NN 周] 资讯周报</span></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="text-align:center;font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 订阅源] · 编辑 [姓名] · 周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]</span></span></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">本周共精选 <b><span style="color:rgb(36,91,219)">[N]</span></b> 条值得关注的信息,其中重点 <b><span style="color:rgb(216,57,49)">[M]</span></b> 条,覆盖 <b>行业动态 / 技术前沿 / 内部动态</b> 三个方向。下方为按主题归类的速读版,标题点开即原文。</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><br></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">本周关键词</span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:14px">
|
||||
<span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 1]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 2]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 3]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 4]</b></span><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:2px 10px;border-radius:10px;margin-right:6px;font-size:12px"><b>[关键词 5]</b></span>
|
||||
</div></div>
|
||||
|
||||
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(36,91,219)"> 行业动态 </span></span></span></span></b></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">1. [行业资讯标题 1,建议 ≤ 30 字]</a></span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:6px"><b>重点</b></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 1:2-3 句话核心信息,介绍这条资讯讲了什么、为什么本周值得关注、与团队工作的关联]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">2. [行业资讯标题 2]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 2]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[news-url-3]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">3. [行业资讯标题 3]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 3]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[news-url-3]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
|
||||
|
||||
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(0,180,42)"> 技术前沿 </span></span></span></span></b></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[tech-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">4. [技术资讯标题 1]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[tech-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[tech-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">5. [技术资讯标题 2]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[来源] · [发布日期] · </span></span><a class="not-doclink" href="https://[tech-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看原文</a></div></div>
|
||||
|
||||
<div style="margin-top:16px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-size:15px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(124,77,255)"> 内部动态 </span></span></span></span></b></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[internal-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">6. [内部资讯标题 1]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 系统] · [发布日期] · </span></span><a class="not-doclink" href="https://[internal-url-1]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看详情</a></div></div>
|
||||
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(20,86,240)"><a class="not-doclink" href="https://[internal-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">7. [内部资讯标题 2]</a></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:13px"><span style="font-family:Roboto,Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要]</span></span></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="font-size:12px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队 / 系统] · [发布日期] · </span></span><a class="not-doclink" href="https://[internal-url-2]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">查看详情</a></div></div>
|
||||
|
||||
<div style="margin-top:24px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><br></div></div>
|
||||
<blockquote style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px"><div dir="auto" style="font-size:13px;padding-left:12px"><span style="font-family:inherit"><span style="color:rgb(100,106,115)"><b>本期编辑:</b>[姓名]|<b>下期预告:</b>[下期重点话题或筹备信息]|<b>反馈与投稿:</b>欢迎在 reply 中留言或邮件 <a class="not-doclink" href="mailto:[owner@example.com]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[订阅 Owner]</a></span></span></div></blockquote>
|
||||
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:11px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">订阅 / 退订请访问 <a class="not-doclink" href="https://[subscribe-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">订阅管理</a>。本周报为内部资讯整理,所有摘要均来自公开报道;不构成投资建议、不代表本团队立场。</span></span></div></div>
|
||||
256
skills/lark-mail/assets/templates/research--market-report.html
Normal file
256
skills/lark-mail/assets/templates/research--market-report.html
Normal file
@@ -0,0 +1,256 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
SUBJECT 模板(lark-cli mail --subject 用):
|
||||
[调研主题] 市场调研报告 ([YYYY-MM-DD])
|
||||
字段说明:
|
||||
· [调研主题]:调研对象赛道,例 "AI Mail Agent" / "向量数据库" / "前端构建工具"
|
||||
· [YYYY-MM-DD]:调研完成日期(ISO 格式)
|
||||
=============================================================================
|
||||
-->
|
||||
<style>
|
||||
.research-root { font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width:800px; margin:0 auto; color:#1a1a1a; line-height:1.6; background-color:#f8f9fa; padding:20px; }
|
||||
.gradient-header { background:linear-gradient(135deg, #1a73e8, #4285f4); border-radius:12px; padding:32px; color:white; text-align:center; }
|
||||
.card { background-color:white; border-radius:8px; padding:20px; margin:16px 0; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-row { display:flex; gap:10px; margin:16px 0; }
|
||||
.stat-card { flex:1; background-color:white; border-radius:8px; padding:14px; text-align:center; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
|
||||
.player-row { display:flex; gap:12px; margin:0 0 12px; flex-wrap:wrap; }
|
||||
.player-card { flex:1; min-width:200px; background-color:#f1f3f4; border-radius:6px; padding:14px; }
|
||||
.callout-error { background-color:#fce8e6; border-left:4px solid #ea4335; padding:10px 14px; margin-top:12px; border-radius:0 4px 4px 0; font-size:12px; }
|
||||
.tbl { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.tbl th { padding:8px; text-align:left; border-bottom:2px solid #ddd; background-color:#f1f3f4; }
|
||||
.tbl td { padding:8px; border-bottom:1px solid #eee; }
|
||||
.tbl tr.alt td { background-color:#fafafa; }
|
||||
.tbl-bug { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
.tbl-bug th { padding:10px; border-bottom:2px solid #ddd; background-color:#f1f3f4; text-align:left; }
|
||||
.tbl-bug td { padding:10px; border-bottom:1px solid #eee; }
|
||||
.tbl-bug tr.alt td { background-color:#fafafa; }
|
||||
.badge-info { background-color:#e8f0fe; color:#1a73e8; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
|
||||
.badge-success { background-color:#e6f4ea; color:#137333; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
|
||||
.badge-warn { background-color:#fff3e0; color:#e65100; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
|
||||
.badge-error { background-color:#fce8e6; color:#ea4335; padding:2px 8px; border-radius:4px; font-size:12px; white-space:nowrap; display:inline-block; }
|
||||
.pri-p0 { background-color:#fce8e6; color:#c5221f; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
|
||||
.pri-p1 { background-color:#fff3e0; color:#b06000; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
|
||||
.pri-p2 { background-color:#e8f0fe; color:#185abc; padding:2px 10px; border-radius:4px; font-size:11px; font-weight:600; white-space:nowrap; display:inline-block; }
|
||||
</style>
|
||||
|
||||
<div class="research-root">
|
||||
|
||||
<div class="gradient-header">
|
||||
<h1 style="margin:0;font-size:24px;font-weight:600">[调研主题] 市场调研报告</h1>
|
||||
<div style="margin:8px 0 0;font-size:14px">[YYYY-MM-DD] | 调研者:[姓名] · [团队] | [关联系统 / 版本]</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 12px;font-size:16px;color:#555">调研背景</h2>
|
||||
<div style="font-size:13px;margin:0">[一段话描述:本轮调研聚焦的赛道 / 行业背景 / 触发动机]。本轮调研覆盖 <b>[N] 类玩家</b>([类别 1] / [类别 2] / [类别 3] / [类别 4]),重点评估 [自家产品 / 团队] 在 [赛道名] 的位置、对外摩擦点,以及结合 [关联工作 / PR / 本期目标] 的待补能力。所有结论基于 [数据来源 1:公开资料 / 厂商文档 / 行业报告] + [数据来源 2:自有实测 / 内部调研笔记] + [数据来源 3:访谈 / 体验]。</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div style="font-size:26px;font-weight:700;color:#1a73e8">[N]</div>
|
||||
<div style="font-size:11px;color:#666">调研对象</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div style="font-size:26px;font-weight:700;color:#137333">[N]</div>
|
||||
<div style="font-size:11px;color:#666">已就绪能力</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div style="font-size:26px;font-weight:700;color:#fbbc04">[N]</div>
|
||||
<div style="font-size:11px;color:#666">明确缺口</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div style="font-size:26px;font-weight:700;color:#ea4335">[N]</div>
|
||||
<div style="font-size:11px;color:#666">高优待办</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 4px;font-size:16px">1. [章节标题:例 "全球市场态势"]</h2>
|
||||
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述本节切分维度,例 "把市场按 '为谁设计' 切四象限"]</div>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>玩家 / 对象</th>
|
||||
<th>定位 / 类型</th>
|
||||
<th style="text-align:center">[关键评分维度]</th>
|
||||
<th>关键观察</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>[玩家 1]</td>
|
||||
<td>[类别]</td>
|
||||
<td style="text-align:center"><span class="badge-info">[标签]</span></td>
|
||||
<td>[一句话观察]</td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td>[玩家 2]</td>
|
||||
<td>[类别]</td>
|
||||
<td style="text-align:center"><span class="badge-success">[标签]</span></td>
|
||||
<td>[一句话观察]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>[玩家 3]</td>
|
||||
<td>[类别]</td>
|
||||
<td style="text-align:center"><span class="badge-warn">[标签]</span></td>
|
||||
<td>[一句话观察]</td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td>[玩家 4]</td>
|
||||
<td>[类别]</td>
|
||||
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
|
||||
<td>[一句话观察]</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 4px;font-size:16px">2. [章节标题:例 "接入摩擦点"] <span class="badge-warn" style="vertical-align:middle;margin-left:8px">⚠️ 风险</span></h2>
|
||||
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:从哪里观察 / 案例 / 数据来源]</div>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>摩擦类型 / 维度</th>
|
||||
<th>具体表现</th>
|
||||
<th>业务影响</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>[摩擦 1]</b></td>
|
||||
<td>[具体表现 / 案例]</td>
|
||||
<td>[对业务 / 团队的影响]</td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td><b>[摩擦 2]</b></td>
|
||||
<td>[具体表现]</td>
|
||||
<td>[影响]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>[摩擦 3]</b></td>
|
||||
<td>[具体表现]</td>
|
||||
<td>[影响]</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 12px;font-size:16px">3. [章节标题:例 "新势力玩家详情" / "重点对象详细比较"]</h2>
|
||||
<div class="player-row">
|
||||
<div class="player-card">
|
||||
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 1]</div>
|
||||
<div style="font-size:12px;color:#444">[一句话产品定位 / 核心能力 / 差异化]</div>
|
||||
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话提炼]</div>
|
||||
</div>
|
||||
<div class="player-card">
|
||||
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 2]</div>
|
||||
<div style="font-size:12px;color:#444">[产品定位]</div>
|
||||
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话]</div>
|
||||
</div>
|
||||
<div class="player-card">
|
||||
<div style="font-size:13px;font-weight:700;color:#1a73e8;margin-bottom:6px">[玩家 / 对象 3]</div>
|
||||
<div style="font-size:12px;color:#444">[产品定位]</div>
|
||||
<div style="font-size:11px;color:#888;margin-top:6px">关键差异:[一句话]</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#666;margin:8px 0 0">[小结一句话:玩家共性 / 自家路线对比]</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 4px;font-size:16px">4. [章节标题:例 "安全风险全景" / "潜在隐患"] <span class="badge-error" style="vertical-align:middle;margin-left:8px">⚠️ 高危</span></h2>
|
||||
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:风险来源 / 关联前期工作]</div>
|
||||
<table class="tbl">
|
||||
<thead><tr>
|
||||
<th>威胁 / 风险</th>
|
||||
<th>案例 / 来源</th>
|
||||
<th style="text-align:center">自家现状</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>[风险 1]</td>
|
||||
<td>[案例 / 来源链接 / 引用前期报告]</td>
|
||||
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td>[风险 2]</td>
|
||||
<td>[案例 / 来源]</td>
|
||||
<td style="text-align:center"><span class="badge-success">[标签]</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>[风险 3](重点)</b></td>
|
||||
<td>[案例 / 来源]</td>
|
||||
<td style="text-align:center"><span class="badge-error">[标签]</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout-error">
|
||||
<b>结论:</b>[一段话,提炼本章节最关键的判断 / 行动建议]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 4px;font-size:16px">5. [章节标题:例 "自家已就绪能力"] <span class="badge-success" style="vertical-align:middle;margin-left:8px">✓ 优势</span></h2>
|
||||
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:基于哪些 PR / 已交付的工作得出]</div>
|
||||
<ul style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 1]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述 + 关联 PR / 文档链接]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 2]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 3]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[能力 4]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [简述]</span></span></li></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 4px;font-size:16px">6. [章节标题:例 "待补能力 / 机会清单"]</h2>
|
||||
<div style="font-size:12px;color:#888;margin:0 0 12px">[一句话描述:清单口径 / 优先级判定依据]</div>
|
||||
<table class="tbl-bug">
|
||||
<thead><tr>
|
||||
<th style="text-align:center;width:30px">#</th>
|
||||
<th style="text-align:center;width:50px">优先级</th>
|
||||
<th>能力 / 缺口</th>
|
||||
<th>建议落地</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="text-align:center">1</td>
|
||||
<td style="text-align:center"><span class="pri-p0">P0</span></td>
|
||||
<td>[能力 / 缺口 1]</td>
|
||||
<td style="font-size:12px">[具体落地路径 / Owner / 估算]</td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td style="text-align:center">2</td>
|
||||
<td style="text-align:center"><span class="pri-p0">P0</span></td>
|
||||
<td>[能力 / 缺口 2]</td>
|
||||
<td style="font-size:12px">[具体落地路径]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">3</td>
|
||||
<td style="text-align:center"><span class="pri-p1">P1</span></td>
|
||||
<td>[能力 / 缺口 3]</td>
|
||||
<td style="font-size:12px">[具体落地路径]</td>
|
||||
</tr>
|
||||
<tr class="alt">
|
||||
<td style="text-align:center">4</td>
|
||||
<td style="text-align:center"><span class="pri-p1">P1</span></td>
|
||||
<td>[能力 / 缺口 4]</td>
|
||||
<td style="font-size:12px">[具体落地路径]</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">5</td>
|
||||
<td style="text-align:center"><span class="pri-p2">P2</span></td>
|
||||
<td>[能力 / 缺口 5]</td>
|
||||
<td style="font-size:12px">[具体落地路径]</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 12px;font-size:16px;border-bottom:2px solid #137333;padding-bottom:8px">关联工作产出佐证</h2>
|
||||
<div style="font-size:12px;color:#666;margin:0 0 10px">本调研报告中部分章节的依据来自下列在执行中的工作:</div>
|
||||
<ul style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-1-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 1 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述跟本调研的关联]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-2-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 2 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="margin-bottom:6px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><a class="not-doclink" href="https://[pr-3-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)" rel="nofollow noopener noreferrer">[PR / Issue 3 标题]</a><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [一句话描述]</span></span></li></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 12px;font-size:16px">建议与下一步</h2>
|
||||
<ol start="1" style="font-size:13px;padding-left:20px;margin:0;list-style-position:inside" data-list-number="true"><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="1" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 1]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径 + 时间窗 + Owner]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="2" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 2]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径 + 时间窗]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="3" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 3]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径]</span></span></li><li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="a1b2c3d4" data-start="4" style="margin-bottom:8px;line-height:1.6;margin-top:0px;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><b>[行动 4]</b><span style="font-family:inherit"><span style="color:rgb(0,0,0)"> — [具体路径]</span></span></li></ol>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;padding:16px;color:#999;font-size:11px">
|
||||
<div style="margin:4px 0">调研者:<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;color:rgb(20,86,240);padding:2px;text-decoration:none;border-radius:999em;margin:0px 2px" rel="nofollow noopener noreferrer">[your@email]</a> · [团队]|整合于 [YYYY-MM-DD]</div>
|
||||
<div style="margin:4px 0">关联材料:[文档 / 笔记路径 / 前期报告]</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
SUBJECT 模板(lark-cli mail --subject 用):
|
||||
[姓名] 个人工作周报 · [YYYY 第 NN 周] · [团队]
|
||||
字段说明:
|
||||
· [姓名]:发件人中文名(不带 @)
|
||||
· [YYYY 第 NN 周]:年份 + ISO 周数
|
||||
· [团队]:所属团队(部门 / 二级团队 / 项目组)
|
||||
=============================================================================
|
||||
-->
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px"><b><span style="font-size:18px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(31,35,41)">[姓名] 个人工作周报 · [YYYY 第 NN 周]</span></span></span></b></div></div>
|
||||
<div style="margin-top:4px;margin-bottom:12px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px"><span style="font-size:13px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">[团队] · [角色]|周期 [YYYY-MM-DD] ~ [YYYY-MM-DD]</span></span></span></div></div>
|
||||
|
||||
<div style="margin-top:20px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(36,91,219);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(31,35,41)">本周工作内容</span></span></span></b></div></div>
|
||||
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">1. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,247,236);color:rgb(0,180,42);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>已完成</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · <a class="not-doclink" href="https://[doc-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">📄 文档</a> · <a class="not-doclink" href="https://[pr-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">PR 链接</a></span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.1:动作描述,附数据 / 链接]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.2:动作描述]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.3:动作描述,含具体数字 / 占比 / 时长]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">2. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(255,247,236);color:rgb(190,107,0);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>进行中</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · <a class="not-doclink" href="https://[doc-url]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">📄 文档</a></span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.1:动作 + 当前进度 + 数据]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.2:动作 + 当前进度]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">3. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,247,236);color:rgb(0,180,42);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>已完成</b></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.1]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.2]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(0,180,42);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(31,35,41)">下周工作内容</span></span></span></b></div></div>
|
||||
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">1. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P0</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.1:具体动作 + 推进方式,例「先 spike POC,再发 RFC 同协作方对齐方案」]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.2:里程碑 / 关键产出 + 完成方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 1.3:依赖 / 协作方 / 验收标准]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">2. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(254,241,241);color:rgb(216,57,49);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P0</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.2:里程碑 / 关键产出]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 2.3:依赖 / 验收]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">3. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(255,247,236);color:rgb(190,107,0);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P1</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.2:里程碑]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 3.3:协作方]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:14px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">4. [项目 / 主任务名称]</span></span></b><span style="background-color:rgb(232,243,255);color:rgb(20,86,240);padding:1px 8px;border-radius:8px;font-size:11px;margin-left:8px"><b>P2</b></span><span style="font-family:inherit"><span style="color:rgb(143,149,158);font-size:13px"> · 预计 [YYYY-MM-DD]</span></span></div></div>
|
||||
<div style="padding-left:24px"><ul style="margin-top:0px;margin-bottom:4px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 4.1:动作 + 推进方式]</span></span></li><li class="temp-li bullet1 bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin-top:2px;margin-bottom:2px;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)">[子项 4.2:依赖 / 关键产出]</span></span></li></ul></div>
|
||||
|
||||
<div style="margin-top:24px;margin-bottom:8px;line-height:1.6"><div dir="auto" style="text-align:left;font-size:14px;border-left:3px solid rgb(216,57,49);padding-left:10px"><b><span style="font-size:16px"><span style="font-family:LarkHackSafariFont,LarkEmojiFont,LarkChineseQuote,-apple-system,"Helvetica Neue",Tahoma,"PingFang SC","Microsoft Yahei",Arial,sans-serif"><span style="color:rgb(31,35,41)">风险与疑问</span></span></span></b></div></div>
|
||||
<ul style="margin-top:8px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 1]</b> — [背景:描述风险来源 / 触发场景];[影响:会延期 / 阻塞哪些工作];[建议:希望得到的支持 / 决策方向 / 期望响应方(@姓名 / 团队)]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 2]</b> — [背景];[影响];[建议]</span></span></li><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:4px;margin-bottom:4px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(31,35,41)"><b>[风险 / 疑问 3]</b> — [背景];[影响];[建议]</span></span></li></ul>
|
||||
<div style="margin-top:8px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">(若本周无风险 / 疑问,整段替换为:<b>无</b>。)</span></span></div></div>
|
||||
|
||||
<div style="margin-top:32px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px"><span style="font-family:inherit"><span style="color:rgb(143,149,158)">— [姓名] / [团队] / [日期]|<a class="not-doclink" href="mailto:[your@email]" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">[your@email]</a></span></span></div></div>
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,8 @@
|
||||
|
||||
如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`。
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
## 安全约束
|
||||
|
||||
此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此:
|
||||
@@ -44,7 +46,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
|------|------|------|
|
||||
| `--to <emails>` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice <alice@example.com>` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) |
|
||||
| `--subject <text>` | 是 | 草稿主题 |
|
||||
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--body <text>` | 二选一 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 |
|
||||
| `--body-file <path>` | 二选一 | 从文件读取邮件正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 |
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
- `--set-cc`
|
||||
- `--set-bcc`
|
||||
|
||||
**正文编辑和其他高级操作必须通过 `--patch-file`**。没有 `--set-body` flag。
|
||||
**正文整体替换的快捷方式:** `--body <text>` / `--body-file <path>`(二选一互斥)会自动展开为 `set_body` op。如果只想做整段正文替换且不需要保留引用区,用这两个 flag 即可,无需写 patch-file。要保留引用区或做更精细的 op 组合,仍走 `--patch-file`。两个入口与 `--patch-file` 内的 `set_body` / `set_reply_body` 互斥。
|
||||
|
||||
### 正文编辑:两个 op 的选择
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
正文编辑通过 `--patch-file` 传入,有两个 op 可选:
|
||||
## 正文编辑:快捷 flag 与 typed op 的选择
|
||||
|
||||
整段替换正文且不需要保留引用区时,可直接使用 `--body` / `--body-file`。需要保留引用区、修改引用区或组合高级正文编辑时,通过 `--patch-file` 传入 typed body op,有两个 op 可选:
|
||||
|
||||
| 情况 | op | 行为 |
|
||||
|------|-----|------|
|
||||
@@ -49,7 +51,10 @@
|
||||
# 编辑草稿元数据(主题、收件人)
|
||||
lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '更新后的主题' --set-to alice@example.com,bob@example.com
|
||||
|
||||
# 编辑草稿正文(必须通过 patch-file)
|
||||
# 快速完整替换正文
|
||||
lark-cli mail +draft-edit --draft-id <draft-id> --body '<p>更新后的正文</p>'
|
||||
|
||||
# 高级正文编辑(如保留回复/转发引用区)
|
||||
lark-cli mail +draft-edit --draft-id <draft-id> --patch-file ./patch.json
|
||||
|
||||
# 查看草稿(只读)— 返回包含 has_quoted_content、attachments_summary 和 inline_summary 的投影
|
||||
@@ -72,13 +77,15 @@ lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '测试' --dry-run
|
||||
| `--set-to <emails>` | 否 | 用此处提供的地址替换整个 To 收件人列表 |
|
||||
| `--set-cc <emails>` | 否 | 用此处提供的地址替换整个 Cc 抄送列表 |
|
||||
| `--set-bcc <emails>` | 否 | 用此处提供的地址替换整个 Bcc 密送列表 |
|
||||
| `--body <text>` | 否 | 整段替换正文(自动展开为 `set_body` op)。与 `--body-file` 互斥;与 `--patch-file` 内的 `set_body` / `set_reply_body` op 互斥 |
|
||||
| `--body-file <path>` | 否 | 从文件读取正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--set-priority <level>` | 否 | 设置邮件优先级:`high`、`normal`、`low`。设为 `normal` 会清除已有优先级 |
|
||||
| `--set-event-summary <text>` | 否 | 设置日程标题。需同时设置 `--set-event-start` 和 `--set-event-end` |
|
||||
| `--set-event-start <time>` | 条件必填 | 设置日程开始时间(ISO 8601) |
|
||||
| `--set-event-end <time>` | 条件必填 | 设置日程结束时间(ISO 8601) |
|
||||
| `--set-event-location <text>` | 否 | 设置日程地点 |
|
||||
| `--remove-event` | 否 | 移除草稿中的日程邀请。与 `--set-event-*` 互斥 |
|
||||
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--patch-file <path>` | 否 | typed body op(`set_body` / `set_reply_body`)、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
|
||||
| `--print-patch-template` | 否 | 打印 `--patch-file` 的 JSON 模板和支持的操作。建议在生成补丁文件前先运行此命令。不会读取或写入草稿 |
|
||||
| `--inspect` | 否 | 查看草稿但不修改。返回包含 `has_quoted_content`(是否有引用区)、`attachments_summary`(普通附件,含 `part_id`/`cid`/`filename`)、`large_attachments_summary`(超大附件,含 `token`/`filename`/`size_bytes`)和 `inline_summary` 的草稿投影 |
|
||||
| `--request-receipt` | 否 | 在草稿上追加 `Disposition-Notification-To: <草稿的 From 地址>` 头,请求已读回执(RFC 3798)。本质上是在 patch 中注入一个 `set_header` op;已有的 DNT 值会被覆盖。可以与其他 `--set-*` / `--patch-file` 编辑组合,也可以单独使用 |
|
||||
@@ -251,8 +258,8 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
|
||||
- `ops` 按顺序执行
|
||||
- `target` 接受 `part_id` 或 `cid`;优先级:`part_id` > `cid`
|
||||
- **所有文件路径(`--patch-file` 及 ops 中的 `path`)必须为相对路径**
|
||||
- **正文编辑没有 flag,必须通过 `--patch-file`**
|
||||
- **所有文件路径(`--body-file`、`--patch-file` 及 ops 中的 `path`)必须为相对路径**
|
||||
- **快速完整正文替换可用 `--body` / `--body-file`;高级正文编辑使用 `--patch-file`**
|
||||
- **`set_body` 替换用户撰写内容** — 不保留旧的引用区(用户要保留需在 value 里带上,或改用 `set_reply_body`);自动保留签名、超大附件卡片、普通附件
|
||||
- **`set_reply_body` 替换用户撰写内容** — 自动保留引用区、签名、超大附件卡片、普通附件;value 只传用户撰写的部分,不要包含引用区/签名/附件卡片;如果用户要修改引用区内容,用 `set_body` 并在 value 里带上修改后的引用区
|
||||
- **删除签名 / 附件**不能通过 `set_body` 清空实现 — 必须用对应的专用 op:`remove_signature`、`remove_attachment`(按 `part_id` / `cid` / `token` 定位)
|
||||
@@ -287,11 +294,8 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
# 1. 查看草稿当前状态
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
|
||||
# 2. 编辑草稿(元数据用 flag,正文用 patch-file)
|
||||
cat > ./patch.json << 'EOF'
|
||||
{ "ops": [{ "op": "set_body", "value": "<p>更新后的内容</p>" }] }
|
||||
EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --set-subject '最终版本' --patch-file ./patch.json
|
||||
# 2. 编辑草稿(元数据和快速正文替换)
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --set-subject '最终版本' --body '<p>更新后的内容</p>'
|
||||
|
||||
# 3. 发送草稿
|
||||
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
## CRITICAL — 发送工作流(必须遵循)
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
此命令默认**只保存草稿**,不会发送邮件。转发会将原邮件内容发送给新收件人,需要发送时有两种合规方式:
|
||||
|
||||
**方式 A(推荐)** — 创建转发草稿(不带 `--confirm-send`):
|
||||
@@ -60,7 +62,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | 是 | 被转发的邮件 ID |
|
||||
| `--to <emails>` | 是 | 收件人邮箱,多个用逗号分隔 |
|
||||
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 |
|
||||
| `--body-file <path>` | 否 | 从文件读取转发说明 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
|
||||
333
skills/lark-mail/references/lark-mail-html.md
Normal file
333
skills/lark-mail/references/lark-mail-html.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 邮件 HTML 写法指南
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解通用安全规则。本文档定义 lark-cli mail 写信场景下的 HTML / CSS / URL 写法、LarkSuite mail-editor 原生格式、可复制片段、3 套场景模板。
|
||||
|
||||
**CRITICAL 邮件是重要的对外交流渠道,请你保证书写语言凝练扼要**
|
||||
**CRITICAL 电子邮件的 HTML 不是 Web 开发的 HTML,请你务必遵守本文档中提及的常用邮件格式书写规范**
|
||||
**CRITICAL 请务必使用 shortcut 来进行邮件内容编辑 (`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward`)或 `+draft-edit` 的 body op,严禁自行拼接 EML**
|
||||
|
||||
你可以参考 **官方模板库** [`../assets/templates/`](../assets/templates) — 提供部分场景模板,可供参考
|
||||
|
||||
> 请注意,邮件内容编辑相关的 shortcut 内置 HTML lint 工具,处于安全考虑和格式适配,你输入的 HTML 可能会被自动调整
|
||||
|
||||
## 风格底线
|
||||
|
||||
- **邮件标题小于50字**: 邮件主题行 `--subject` 应控制在 50 字内,避免超长标题带来理解困难
|
||||
- **多用列表、表格**:不要堆叠过长的文本段落,请擅长使用列表`<ul>` / `<ol>`或分段 `<p>`
|
||||
- **列表书写规则**:**不要**用 `<p>一、...</p><p>二、...</p>` 这种「中文编号 + 段落」的列表样式,"①②③"、"1) 2) 3)的机械写法也请摒弃;请擅长使用列表格式 `<ul>` / `<ol>`。
|
||||
- **正文长度自适应**:不限制正文长度,但要求**首屏要见到关键信息**。
|
||||
|
||||
## 格式书写规范
|
||||
|
||||
电子邮件的 HTML 受客户端兼容性与安全沙箱约束,跟 Web 浏览器 HTML 不是同一规范体系。下面是飞书邮箱已验证的最纯净、最美观写法,请直接复制使用。
|
||||
|
||||
### 段落
|
||||
|
||||
```html
|
||||
<p>文字</p>
|
||||
```
|
||||
|
||||
### 标题
|
||||
|
||||
```html
|
||||
<h1>一级标题(26px,自动加粗)</h1>
|
||||
<h2>二级标题(22px)</h2>
|
||||
<h3>三级标题(20px)</h3>
|
||||
<h4>四级标题(18px)</h4>
|
||||
```
|
||||
|
||||
### 加粗
|
||||
|
||||
```html
|
||||
<b>加粗文字</b>
|
||||
```
|
||||
|
||||
### 斜体
|
||||
|
||||
```html
|
||||
<i>斜体文字</i>
|
||||
```
|
||||
|
||||
### 下划线
|
||||
|
||||
```html
|
||||
<u>下划线文字</u>
|
||||
```
|
||||
|
||||
### 删除线
|
||||
|
||||
```html
|
||||
<s>删除文字</s>
|
||||
```
|
||||
|
||||
### 字号
|
||||
|
||||
```html
|
||||
<span style="font-size:18px">放大到 18px</span>
|
||||
```
|
||||
|
||||
### 字体
|
||||
|
||||
```html
|
||||
<span style="font-family:'Courier New',monospace">等宽字体</span>
|
||||
```
|
||||
|
||||
### 文字颜色
|
||||
|
||||
```html
|
||||
<span style="color:rgb(245,74,69)">红色文字</span>
|
||||
```
|
||||
|
||||
### 换行
|
||||
|
||||
```html
|
||||
第一行<br>第二行
|
||||
```
|
||||
|
||||
### 分隔
|
||||
|
||||
```html
|
||||
<hr>
|
||||
```
|
||||
|
||||
### 列表
|
||||
|
||||
```html
|
||||
<!-- 无序列表 -->
|
||||
<ul><li>项</li></ul>
|
||||
|
||||
<!-- 有序列表 -->
|
||||
<ol><li>条</li></ol>
|
||||
|
||||
<!-- 多级列表通用规则(适用于下面两个示例):
|
||||
- <ul>/<ol> 的直接子节点必须是 <li>,HTML 规范不允许 <ul> 直接套 <ul>
|
||||
- 子列表必须嵌套在父 <li> 内,不要拆成多个独立 ol/ul 兄弟
|
||||
- 每级 list-style-type 用不同符号区分层级(disc/circle/square 或 decimal/lower-alpha/lower-roman)
|
||||
- 子级用 margin-left:24px 视觉缩进 -->
|
||||
|
||||
<!-- 多级有序列表(全 ol 三级嵌套:decimal → lower-alpha → lower-roman) -->
|
||||
<ol data-list-number="true" style="margin:0px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="demo-ol" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">第一级(decimal)</span></span></b>
|
||||
<ol data-list-number="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li number2" data-li-line="true" data-list="number2" data-ol-id="demo-ol" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:lower-alpha;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第二级(lower-alpha,缩进 24px)</span></span>
|
||||
<ol data-list-number="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li number3" data-li-line="true" data-list="number3" data-ol-id="demo-ol" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:lower-roman;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第三级(lower-roman,再缩进 24px)</span></span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="temp-li number2" data-li-line="true" data-list="number2" data-ol-id="demo-ol" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:lower-alpha;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第二级(同层)</span></span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li class="temp-li number1" data-li-line="true" data-list="number1" data-ol-id="demo-ol" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:decimal;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<b><span style="font-family:inherit"><span style="color:rgb(31,35,41)">第一级(接续编号)</span></span></b>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- 多级无序列表(全 ul 三级嵌套:disc → circle → square) -->
|
||||
<ul data-list-bullet="true" style="margin:0px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第一级(disc)</span></span>
|
||||
<ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第二级(circle,缩进 24px)</span></span>
|
||||
<ul data-list-bullet="true" style="margin:0px 0px 0px 24px;padding-left:0px;list-style-position:inside">
|
||||
<li class="temp-li bullet3" data-li-line="true" data-list="bullet3" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:square;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第三级(square,再缩进 24px)</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="temp-li bullet2" data-li-line="true" data-list="bullet2" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:circle;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第二级(同层)</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin:4px 0;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;list-style-position:inside" dir="auto">
|
||||
<span style="font-family:inherit"><span style="color:rgb(31,35,41)">第一级(同层)</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
### 表格
|
||||
|
||||
```html
|
||||
<table style="border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="background-color:rgb(242,243,245)">
|
||||
<th rowspan="2" style="border:1px solid rgb(222,224,227);padding:8px;vertical-align:middle">A</th>
|
||||
<th colspan="2" style="border:1px solid rgb(222,224,227);padding:8px;text-align:center">B</th>
|
||||
<th rowspan="2" style="border:1px solid rgb(222,224,227);padding:8px;vertical-align:middle">C</th>
|
||||
</tr>
|
||||
<tr style="background-color:rgb(242,243,245)">
|
||||
<th style="border:1px solid rgb(222,224,227);padding:8px">B1</th>
|
||||
<th style="border:1px solid rgb(222,224,227);padding:8px">B2</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">a1</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">b1-1</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">b2-1</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">c1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">a2</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">b1-2</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">b2-2</td>
|
||||
<td style="border:1px solid rgb(222,224,227);padding:8px">c2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
```
|
||||
|
||||
### 链接
|
||||
|
||||
```html
|
||||
<a href="https://www.larkoffice.com" style="color:rgb(20,86,240);text-decoration:none">链接文字</a>
|
||||
```
|
||||
|
||||
### AT 用户
|
||||
|
||||
```html
|
||||
<a id="at-user-1" href="mailto:user@example.com" style="cursor:pointer;color:rgb(20,86,240);padding:2px;text-decoration:none;border-radius:999em;margin:0px 2px">@姓名</a>
|
||||
```
|
||||
|
||||
**必填字段** `id="at-user-N"`、`mailto:` 和姓名文本
|
||||
|
||||
### 引用
|
||||
|
||||
```html
|
||||
<blockquote style="padding-left:12px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px">引用文字</blockquote>
|
||||
```
|
||||
|
||||
### 文字高亮(荧光笔风格)
|
||||
|
||||
```html
|
||||
<span style="background-color:rgb(255,200,220);color:rgb(31,35,41)">关键里程碑</span>
|
||||
<span style="background-color:rgb(255,225,140);color:rgb(31,35,41)">待跟进</span>
|
||||
<span style="background-color:rgb(190,230,200);color:rgb(31,35,41)">已完成</span>
|
||||
```
|
||||
|
||||
### 文字强调
|
||||
|
||||
```html
|
||||
<b><span style="font-family:inherit"><span style="color:rgb(245,74,69)">红色加粗</span></span></b>
|
||||
<i><span style="font-family:inherit"><span style="color:rgb(0,0,0)">斜体</span></span></i>
|
||||
<u><span style="font-family:inherit"><span style="color:rgb(0,0,0)">下划线</span></span></u>
|
||||
<s><span style="font-family:inherit"><span style="color:rgb(0,0,0)">删除线</span></span></s>
|
||||
```
|
||||
|
||||
### 居中 / 左对齐 / 右对齐
|
||||
|
||||
```html
|
||||
<div style="text-align:center">居中</div>
|
||||
<div style="text-align:left">左对齐(默认)</div>
|
||||
<div style="text-align:right">右对齐</div>
|
||||
```
|
||||
|
||||
### 盒模型
|
||||
|
||||
```html
|
||||
<div style="margin:8px;padding:12px;width:300px">外边距 8px / 内边距 12px / 宽度 300px</div>
|
||||
```
|
||||
|
||||
### 边框
|
||||
|
||||
```html
|
||||
<div style="border:1px solid rgb(222,224,227);border-radius:8px;padding:8px">圆角描边</div>
|
||||
```
|
||||
|
||||
### 透明
|
||||
|
||||
```html
|
||||
<span style="opacity:0.5">半透明文字</span>
|
||||
```
|
||||
|
||||
### 颜色(推荐调色盘)
|
||||
|
||||
```html
|
||||
<!-- 主黑(正文) -->
|
||||
<span style="color:rgb(31,35,41)">主文本</span>
|
||||
<!-- 副灰(次要说明 / 时间 / 备注) -->
|
||||
<span style="color:rgb(100,106,115)">副文本</span>
|
||||
<!-- 浅灰(三级文本 / 占位) -->
|
||||
<span style="color:rgb(143,149,158)">浅灰文本</span>
|
||||
<!-- LarkSuite 蓝(链接 / mention) -->
|
||||
<span style="color:rgb(20,86,240)">蓝色文字</span>
|
||||
<!-- LarkSuite 深蓝(重点标题) -->
|
||||
<span style="color:rgb(36,91,219)">深蓝标题</span>
|
||||
<!-- 警示红(错误 / 失败 / 红色加粗) -->
|
||||
<span style="color:rgb(245,74,69)">警示红</span>
|
||||
<!-- 紧急橙(紧急 / 阻塞 / 环比上升) -->
|
||||
<span style="color:rgb(255,140,40)">紧急橙</span>
|
||||
```
|
||||
|
||||
### URL scheme
|
||||
|
||||
```html
|
||||
<a href="https://example.com">外链</a>
|
||||
<a href="mailto:user@example.com">邮件链接</a>
|
||||
<img src="cid:abc"> <!-- 内嵌图片,配合 --inline 参数 -->
|
||||
<img src="data:image/png;base64,iVBOR..."> <!-- base64 内嵌图片 -->
|
||||
```
|
||||
|
||||
## 官方 HTML 模板
|
||||
|
||||
仓库 [`../assets/templates/`](../assets/templates/) 内预制了若干场景模板,按 LarkSuite mail-editor 原生格式写好。**注意:模板是静态 HTML,没有变量替换能力,AI 需要手工把模板里的样例文本替换成本次邮件的真实内容。**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---------------------------------|----------|
|
||||
| `newsletter--weekly-brief.html` | 资讯周报 |
|
||||
| `weekly--personal-report.html` | 工作周报(个人) |
|
||||
| `weekly--team-report.html` | 工作周报(团队) |
|
||||
| `research--market-report.html` | 调研报告 |
|
||||
| `job-application--resume.html` | 简历邮件 |
|
||||
|
||||
跟飞书 OAPI 个人邮件模板(`mail.user_mailbox.templates`)不同——OAPI 模板是用户邮箱里的"我的模板",跨客户端可见;这里是仓库里的静态 HTML 文件,AI 单次套用即可。
|
||||
|
||||
### AI 套用流程
|
||||
|
||||
1. **判断是否能用模板** — 看用户当前要写的邮件类型(周报 / 调研 / 简历 / 资讯 / ...)能否对上 [`../assets/templates/`](../assets/templates/) 里的某个文件;不匹配就跳过模板,直接按写法规范从零写。
|
||||
2. **Read 整个 HTML** — 用 Read 工具完整读取选定的模板文件,理解骨架(章节标题 / 列表层级 / 占位文本 / mention chip / 段落顺序)。
|
||||
3. **替换文本内容** — 把模板里的样例文字换成用户当前邮件的真实内容;保留所有 inline style / class / data-* 等结构性属性不动;列表条目 / 表格行可按需增删;不需要的整段(如「风险」「下周计划」)整段删除即可,不要留空骨架。
|
||||
4. **调写信 shortcut 生成草稿** — 把替换后的 HTML 通过 `--body` 参数交给写信链路(推荐 `+draft-create` 先存草稿、用户复核后再 `+send`):
|
||||
|
||||
```bash
|
||||
lark-cli mail +draft-create --as user \
|
||||
--to alice@example.com --subject 'Q3 团队周报' \
|
||||
--body "$(cat skills/lark-mail/assets/templates/weekly--team-report.html)"
|
||||
```
|
||||
|
||||
实际使用时 `$(cat ...)` 可换成 AI 替换文本后写入的本地副本,或直接把替换后的 HTML 字符串作为 `--body` 的值。
|
||||
|
||||
5. **拿到草稿链接给用户复核** — 写信 shortcut 返回 `reference` 字段(草稿打开链接),把它给用户在飞书邮箱 UI 里打开核对,再决定下一步发送 / 编辑。
|
||||
|
||||
## 写信 shortcut 的 lint 返回值
|
||||
|
||||
写信链路(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)调用 `emlbuilder` 之前会强制 lint 净化 HTML,但 **默认 envelope 不携带任何 lint 字段**(既无 `*_count` 也无 finding 数组),envelope 保持小巧供 AI 消费。每个写信 shortcut 默认 envelope 的字段集合:
|
||||
|
||||
| 字段 | 出现条件 | 说明 |
|
||||
|------|---------|------|
|
||||
| `compose_hint` | 6 个 shortcut 默认都附 | 固定英文文案,提示 AI / 用户在组合 HTML 前阅读本文 |
|
||||
| `draft_edit_hint` | **仅** `+draft-create` 默认附(其他 5 个 shortcut 不附) | 固定英文文案,提示拿到 `draft_id` 后改稿走 `+draft-edit --draft-id <id>` 而不是重跑 `+draft-create` 产生重复草稿 |
|
||||
| `draft_id` / `message_id` | OAPI 写入成功后写回 | `+draft-create` / `+draft-edit` 返回 `draft_id`;`+send` / `+reply` / `+reply-all` / `+forward` 返回 `message_id` |
|
||||
|
||||
需要看 lint 详情时加 `--show-lint-details`:
|
||||
|
||||
```bash
|
||||
lark-cli mail +draft-create --show-lint-details \
|
||||
--to alice@example.com --subject 'Hi' --body '<p>正文</p>'
|
||||
```
|
||||
|
||||
加了 `--show-lint-details` 后 envelope 同时返回 `lint_applied[]` / `original_blocked[]` 两个完整 Finding 数组(每条含 `rule_id` / `severity` / `tag_or_attr` / `excerpt` / `hint`),**不再返回任何 `*_count` 字段** —— 调用方需要 count 时直接 `len(lint_applied)` / `len(original_blocked)`。**默认场景不要加这个 flag**,徒增 token 消耗。
|
||||
|
||||
如果只是想预览 lint 会怎么改 HTML,建议直接用 [`+lint-html`](./lark-mail-lint-html.md) 命令——它本来就返回完整 `warnings[]` / `errors[]` + `cleaned_html`,比写信链路 `--show-lint-details` 更清晰。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [`+lint-html` 用法](./lark-mail-lint-html.md)
|
||||
- 写信 shortcut: [`+send`](./lark-mail-send.md) / [`+draft-create`](./lark-mail-draft-create.md) / [`+reply`](./lark-mail-reply.md) / [`+reply-all`](./lark-mail-reply-all.md) / [`+forward`](./lark-mail-forward.md) / [`+draft-edit`](./lark-mail-draft-edit.md)
|
||||
243
skills/lark-mail/references/lark-mail-lint-html.md
Normal file
243
skills/lark-mail/references/lark-mail-lint-html.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# mail +lint-html
|
||||
|
||||
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解通用安全规则。
|
||||
|
||||
## 作用
|
||||
|
||||
`+lint-html` 是邮件 HTML 正文的本地预检工具(read-only,无网络 IO)。
|
||||
|
||||
- 校验 HTML 是否符合飞书邮箱的兼容性 / 安全 / 原生写法要求;
|
||||
- 自动修复非法或不规范写法(autofix 始终启用),输出 `cleaned_html`;
|
||||
- 不写入任何邮箱状态,不调用任何 OAPI。
|
||||
|
||||
写信链路(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已**强制内置**同一份 lint,提交前会自动净化 HTML。默认 envelope 不携带任何 lint 字段以保持响应小巧;加 `--show-lint-details` 可拿到完整 `lint_applied[]` / `original_blocked[]` 两个 Finding 数组(不再返回任何 `*_count` 字段,调用方需要 count 时 `len(arr)` 即可,详见 [邮件 HTML 写法指南](./lark-mail-html.md#写信-shortcut-的-lint-返回值))。本命令是写信链路 lint 的预览版,行为一致,调用更轻量,适合:
|
||||
|
||||
- AI / 用户在创建草稿前自检 HTML 会被怎么改写;
|
||||
- CI 流水线把 HTML 模板当作产物校验。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 直接传 HTML
|
||||
lark-cli mail +lint-html --body '<p>正文</p>'
|
||||
|
||||
# 从文件读 HTML(路径必须在 cwd 子树内)
|
||||
lark-cli mail +lint-html --body-file ./template.html
|
||||
|
||||
# 查看完整 lint 详情
|
||||
lark-cli mail +lint-html --body-file ./template.html --show-lint-details
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--body <html>` | 二选一 | 待检查的 HTML 内容 |
|
||||
| `--body-file <path>` | 二选一 | 从文件读取 HTML,仅支持 cwd 子树(绝对路径 / `..` 越出 cwd 会被拒) |
|
||||
| `--show-lint-details` | 否 | 默认 `false`。`true` 时 envelope 同时返回 `warnings[]` / `errors[]` 完整 Finding 数组;默认仅返回 `cleaned_html`,避免复杂模板触发数十条装饰性 warning 把响应撑大几千 token |
|
||||
| `--format <fmt>` | 否 | `json`(默认)/ `pretty` / `table` / `csv` / `ndjson` |
|
||||
| `--jq <expr>` | 否 | 对返回 JSON 用 jq 表达式过滤 |
|
||||
| `--dry-run` | 否 | 不执行 lint,仅返回 dry-run 描述 |
|
||||
|
||||
## 返回值
|
||||
|
||||
**默认 envelope**(仅 `cleaned_html`,token-frugal):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"cleaned_html": "<p>...</p>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**加 `--show-lint-details` 后**:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"cleaned_html": "<p>...</p>",
|
||||
"warnings": [
|
||||
{ "rule_id": "...", "severity": "warning", "tag_or_attr": "...", "excerpt": "...", "hint": "..." }
|
||||
],
|
||||
"errors": [
|
||||
{ "rule_id": "...", "severity": "error", "tag_or_attr": "...", "excerpt": "...", "hint": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `cleaned_html` | 修复后的 HTML(autofix 始终启用);warning 已自动改写,error 已删除 |
|
||||
| `warnings[]` | 警告级 finding 数组(**仅 `--show-lint-details` 时返回**)。无违规时输出 `[]` |
|
||||
| `errors[]` | 错误级 finding 数组(**仅 `--show-lint-details` 时返回**)。无违规时输出 `[]` |
|
||||
|
||||
每条 finding 含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `rule_id` | 规则编号(UPPER_SNAKE_CASE) |
|
||||
| `severity` | `"warning"` 或 `"error"` |
|
||||
| `tag_or_attr` | 触发规则的 tag / attribute / `style.<property>` |
|
||||
| `excerpt` | HTML 片段(最多 200 字节,超出截断) |
|
||||
| `hint` | 可读的修复说明 |
|
||||
|
||||
## 调用示例
|
||||
|
||||
下面是用 `lark-cli mail +lint-html --body '<INPUT>' --show-lint-details` 实跑得到的典型 case(加 `--show-lint-details` 才能看到 finding;默认只返回 `cleaned_html`),覆盖 error 类(强制删)和 warning 类(自动修复)。
|
||||
|
||||
### Error 类(强制删除,写信链路也会拒)
|
||||
|
||||
#### 1. `<script>` 整段删除
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<script>alert(1)</script>正文
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
正文
|
||||
```
|
||||
|
||||
原因:`<script>` 有 XSS 风险,整段丢弃。
|
||||
|
||||
#### 2. `javascript:` URL 删除
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<a href="javascript:void(0)">click</a>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<a class="not-doclink" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">click</a>
|
||||
```
|
||||
|
||||
原因:`javascript:` scheme 是 XSS 入口,`href` 属性被剥。
|
||||
|
||||
#### 3. `on*` 事件 handler 删除
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<p onclick="alert(1)">hi</p>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px">hi</div></div>
|
||||
```
|
||||
|
||||
原因:inline event handler(`onclick` / `onerror` 等)是脚本注入入口,属性被剥。
|
||||
|
||||
### Warning 类(自动修复,视觉无差异)
|
||||
|
||||
#### 4. `<font>` → `<span style>`
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<font color="red" size="3">字</font>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<span style="color:red; font-size:16px">字</span>
|
||||
```
|
||||
|
||||
原因:`<font>` 是 HTML4 过时标签,飞书 mail-editor 用 inline style 表达字号 / 颜色。
|
||||
|
||||
#### 5. `<p>` 段落容器原生化
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<p>正文</p>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<div style="margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px">正文</div></div>
|
||||
```
|
||||
|
||||
原因:飞书 mail-editor 段落实际是双层 div(外层定 margin / line-height,内层定 font-size)。
|
||||
|
||||
#### 6. `<ul>/<li>` 列表原生化
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<ul><li>第一项</li></ul>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<ul style="margin-top:0px;margin-bottom:0px;margin-left:0px;padding-left:0px;list-style-position:inside" data-list-bullet="true"><li class="temp-li bullet1" data-li-line="true" data-list="bullet1" style="line-height:1.6;margin-top:0px;margin-bottom:0px;padding-left:0px;display:list-item;list-style-type:disc;font-family:inherit;font-size:14px;margin-left:0px;list-style-position:inside" dir="auto"><span style="font-family:inherit"><span style="color:rgb(0,0,0)">第一项</span></span></li></ul>
|
||||
```
|
||||
|
||||
原因:飞书 native list-block 要求 `<ul>` / `<li>` 补全 class + data marker + 双层 span 包裹,否则 li 之间会出现可见空行。
|
||||
|
||||
#### 7. `<blockquote>` 加灰边 + 灰文字
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<blockquote>引用</blockquote>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<blockquote style="padding-left:0px;color:rgb(100,106,115);border-left:2px solid rgb(187,191,196);margin:0px">引用</blockquote>
|
||||
```
|
||||
|
||||
原因:补飞书原生引用样式(左侧 2px 灰边 + 灰色文字)。
|
||||
|
||||
#### 8. `<a>` 链接补 not-doclink + LarkSuite 蓝
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<a href="https://example.com">link</a>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<a href="https://example.com" class="not-doclink" style="cursor:pointer;text-decoration:none;color:rgb(20,86,240)">link</a>
|
||||
```
|
||||
|
||||
原因:补 `not-doclink` class(防误识为内部 doc share)+ LarkSuite 品牌蓝 + 无下划线。
|
||||
|
||||
#### 9. 非白名单 CSS property 删除
|
||||
|
||||
输入:
|
||||
|
||||
```html
|
||||
<p style="position:absolute;color:red">x</p>
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```html
|
||||
<div style="color:red;margin-top:4px;margin-bottom:4px;line-height:1.6"><div dir="auto" style="font-size:14px">x</div></div>
|
||||
```
|
||||
|
||||
原因:`position` 不在 inline style 白名单内被剔除,`color` 保留。
|
||||
|
||||
## 相关命令
|
||||
|
||||
- 写信 shortcut(已内置同一份 lint):[`+send`](./lark-mail-send.md) / [`+draft-create`](./lark-mail-draft-create.md) / [`+reply`](./lark-mail-reply.md) / [`+reply-all`](./lark-mail-reply-all.md) / [`+forward`](./lark-mail-forward.md) / [`+draft-edit`](./lark-mail-draft-edit.md)
|
||||
- 知识文档:[邮件 HTML 写法指南](./lark-mail-html.md)
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
## CRITICAL — 发送工作流(必须遵循)
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
此命令默认**只保存草稿**,不会发送邮件。回复全部会发送给**所有**原始收件人,需要发送时有两种合规方式:
|
||||
|
||||
**方式 A(推荐)** — 创建回复全部草稿(不带 `--confirm-send`):
|
||||
@@ -62,7 +64,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | 是 | 被回复的邮件 ID |
|
||||
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--body <text>` | 二选一 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 |
|
||||
| `--body-file <path>` | 二选一 | 从文件读取回复正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) |
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
## CRITICAL — 发送工作流(必须遵循)
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
此命令默认**只保存草稿**,不会发送邮件。需要发送时,有两种合规方式:
|
||||
|
||||
**方式 A(推荐)** — 创建回复草稿(不带 `--confirm-send`):
|
||||
@@ -66,7 +68,8 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | 是 | 被回复的邮件 ID |
|
||||
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--body <text>` | 二选一 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 |
|
||||
| `--body-file <path>` | 二选一 | 从文件读取回复正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) |
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
## CRITICAL — 发送工作流(必须遵循)
|
||||
|
||||
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
|
||||
|
||||
此命令默认**只保存草稿**,不会发送邮件。需要发送时,有两种合规方式:
|
||||
|
||||
**方式 A(推荐)** — 先创建草稿,再确认发送:
|
||||
@@ -67,7 +69,8 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
|
||||
|------|------|------|
|
||||
| `--to <emails>` | 是 | 收件人邮箱,多个用逗号分隔 |
|
||||
| `--subject <text>` | 是 | 邮件主题 |
|
||||
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--body <text>` | 二选一 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径)。与 `--body-file` 互斥 |
|
||||
| `--body-file <path>` | 二选一 | 从文件读取邮件正文 HTML(相对路径,仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(EML From 头)。使用别名(send_as)发信时,设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
|
||||
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
|
||||
Reference in New Issue
Block a user