Files
larksuite-cli/shortcuts/mail/body_file.go
bubbmon233 bbef3cbfb1 feat(mail): HTML lint library + Larksuite-native autofix + lark-mail … (#1019)
* feat(mail): HTML lint library + Larksuite-native autofix + lark-mail skill

为 lark-cli mail 域写信链路引入 HTML lint 能力,提升邮件 HTML 的兼容性、
安全性与 Larksuite-native 格式适配。

lint 库(shortcuts/mail/lint/):
- 四档分类:pass / native-autofix / warn-autofix / error-strip
- 安全规则覆盖 script / iframe / on* 事件处理器 / javascript: 及其它
  危险 URL scheme 等 XSS 向量,未知 scheme 一律删除并归 error
- Larksuite-native 格式自动修复:双层 div 段落、原生多级列表结构、
  灰边引用、Larksuite 蓝链接
- cleaned_html 输出确定性稳定(位置索引派生 data-ol-id),便于
  golden-file 测试与缓存

+lint-html 独立预检 shortcut:
- 只读、不调 API、不建草稿,供 AI / 用户 / CI 在写信前预览 lint 结果

写入路径内置 lint(6 个 compose shortcut):
- +send / +draft-create / +draft-edit / +reply / +reply-all / +forward
  在 emlbuilder 之前强制 lint 净化 HTML
- 默认 envelope 对 lint 改动透明(无 lint 字段),保持小巧供 AI 消费;
  --show-lint-details 显式取证返回 lint_applied[] / original_blocked[]
- --body-file 支持从文件读取 body(32MB 上限),与 --body 互斥

预制 HTML 邮件模板(skills/lark-mail/assets/templates/):
- 资讯周报 / 个人周报 / 团队周报 / 调研报告 / 求职简历 5 套
- 按 Larksuite mail-editor 原生格式编写,含正确的多级列表嵌套结构

lark-mail skill 文档:
- references/lark-mail-html.md:邮件 HTML 写法指南(24 个格式 section
  + 颜色调色盘 + URL scheme + 官方模板套用流程)
- references/lark-mail-lint-html.md:+lint-html 用法
- SKILL.md 顶部 CRITICAL 引导

* fix(mail): remove unused readAttr func and apply gofmt

Drop the unused `readAttr` helper in shortcuts/mail/lint/linter.go
that was flagged by golangci-lint (unused linter). Apply gofmt to
linter.go and rules.go which had minor formatting issues.

* fix(mail): address compose lint and guidance
2026-05-27 22:23:32 +08:00

110 lines
4.6 KiB
Go

// 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
}