Compare commits

..

1 Commits

Author SHA1 Message Date
/
823a55a1ef fix: update sheets filter view condition validation 2026-05-27 17:31:19 +08:00
99 changed files with 443 additions and 8872 deletions

View File

@@ -2,30 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.42] - 2026-05-27
### Features
- **mail**: Add `+draft-send` shortcut for batch draft sending (#1017)
- **im**: Enrich messages with reactions and output `update_time` (#1095)
- **schema**: Output JSON spec envelope for all API commands (#1048)
- **event**: Support `vc` / `note` / `minute` events (#1113)
- **drive**: Add secure label shortcuts (#985)
- **affordance**: Use description and command in affordance example schema (#1126)
### Bug Fixes
- **docs**: Remove unsupported `fetch` text format (#1109)
### Refactor
- **auth**: Drop duplicate top-level user fields in `status` (#1128)
### Documentation
- **doc**: Document block anchor URLs in `lark-doc` skill (#1120)
- **whiteboard**: Improve SVG/Mermaid instructions (#1097)
## [v1.0.41] - 2026-05-26
### Features
@@ -910,7 +886,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
[v1.0.41]: https://github.com/larksuite/cli/releases/tag/v1.0.41
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39

View File

@@ -16,7 +16,6 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -122,7 +121,7 @@ func authLoginRun(opts *LoginOptions) error {
}
// Determine UI language from saved config
var lang i18n.Lang
lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil {
if app := multi.FindApp(config.ProfileName); app != nil {
lang = app.Lang
@@ -178,7 +177,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}

View File

@@ -3,8 +3,6 @@
package auth
import "github.com/larksuite/cli/internal/i18n"
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
@@ -117,8 +115,8 @@ var loginMsgEn = &loginMsg{
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang i18n.Lang) *loginMsg {
if lang.IsEnglish() {
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
}
return loginMsgZh

View File

@@ -8,8 +8,6 @@ import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetLoginMsg_Zh(t *testing.T) {
@@ -33,7 +31,7 @@ func TestGetLoginMsg_En(t *testing.T) {
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "fr_fr", "ja_jp", "unknown"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
@@ -63,7 +61,7 @@ func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string)
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
@@ -104,10 +102,10 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == i18n.LangZhCN && want == "turn" {
if lang == "zh" && want == "turn" {
want = "本轮"
}
if !strings.Contains(hint, want) {

View File

@@ -61,6 +61,7 @@ func authStatusRun(opts *StatusOptions) error {
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
@@ -85,6 +86,29 @@ func effectiveIdentity(d identitydiag.Result) string {
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -38,10 +37,8 @@ type BindOptions struct {
// this flag because its own prompts already require human confirmation.
Force bool
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateBindFlags
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
Lang string
langExplicit bool // true when --lang was explicitly passed
// Brand holds the resolved Lark product brand ("feishu" | "lark") for
// the account being bound. Populated after resolveAccount; TUI stages
@@ -58,7 +55,7 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
opts := &BindOptions{Factory: f}
cmd := &cobra.Command{
Use: "bind",
@@ -105,7 +102,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID to bind (required for OpenClaw multi-account)")
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (zh, en, or ja)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -150,7 +147,7 @@ func configBindRun(opts *BindOptions) error {
if err := warnIdentityEscalation(opts, existing.ConfigBytes); err != nil {
return err
}
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
applyPreferences(appConfig, opts)
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
@@ -205,18 +202,16 @@ func finalizeSource(opts *BindOptions) (string, error) {
// TUI: prompt for language before any downstream prompts. The source
// selection itself may still be skipped entirely if --source or the
// env already pinned it. Picker offers 2 options (中文 / English) and
// drives BOTH opts.Lang (preference) and opts.UILang (TUI rendering).
// env already pinned it.
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
lang, err := promptLangSelection("")
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", err
}
opts.Lang = string(lang)
opts.UILang = lang
opts.Lang = lang
}
if explicit != "" {
@@ -250,7 +245,7 @@ func reconcileExistingBinding(opts *BindOptions, source, configPath string) (exi
return existingBinding{}, err
}
if action == "cancel" {
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, msg.ConflictCancelled)
return existingBinding{Cancelled: true}, nil
}
@@ -334,7 +329,7 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
if !hasStrictBotLock(previousConfigBytes) {
return nil
}
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
return output.ErrWithHint(output.ExitValidation, "bind",
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
}
@@ -352,23 +347,14 @@ func noticeUserDefaultRisk(opts *BindOptions) {
if opts.IsTUI || opts.Identity != "user-default" {
return
}
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, "⚠️ "+msg.IdentityEscalationMessage)
}
// applyPreferences expands the chosen identity preset into the underlying
// StrictMode + DefaultAs on the AppConfig. Always writes both fields so the
// profile's intent survives later changes to global strict-mode settings.
// preferredLang resolves the language to persist: the requested value when set,
// otherwise the prior one — so an unset --lang never clears a stored preference.
func preferredLang(requested, prior i18n.Lang) i18n.Lang {
if requested != "" {
return requested
}
return prior
}
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.Lang) {
func applyPreferences(appConfig *core.AppConfig, opts *BindOptions) {
switch opts.Identity {
case "bot-only":
sm := core.StrictModeBot
@@ -379,22 +365,9 @@ func applyPreferences(appConfig *core.AppConfig, opts *BindOptions, prior i18n.L
appConfig.StrictMode = &sm
appConfig.DefaultAs = core.AsUser
}
appConfig.Lang = preferredLang(i18n.Lang(opts.Lang), prior)
}
// priorLang returns the language preference recorded in a previous config, or
// "" if there is none / the bytes don't parse.
func priorLang(previousConfigBytes []byte) i18n.Lang {
var multi core.MultiAppConfig
if json.Unmarshal(previousConfigBytes, &multi) != nil {
return ""
if opts.Lang != "" {
appConfig.Lang = opts.Lang
}
for _, app := range multi.Apps {
if app.Lang != "" {
return app.Lang
}
}
return ""
}
// commitBinding finalizes the bind: atomic write of the new workspace config,
@@ -420,10 +393,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
replaced := previousConfigBytes != nil
// uiMsg renders human-facing TUI text (stderr success banner). Follows
// opts.UILang — zh by default; picker can flip it to en. --lang does
// not influence the TUI language.
uiMsg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
display := sourceDisplayName(source)
if replaced {
@@ -431,11 +401,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
}
fmt.Fprintln(opts.Factory.IOStreams.ErrOut,
fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+uiMsg.BindSuccessNotice)
if opts.langExplicit && opts.Lang != "" {
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(uiMsg.LangPreferenceSet, opts.Lang))
}
fmt.Sprintf(msg.BindSuccessHeader, display)+"\n"+msg.BindSuccessNotice)
// TUI mode is a human sitting at a terminal; the BindSuccess notice on
// stderr is enough and a machine-readable JSON dump on stdout is just
@@ -453,17 +419,12 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
"replaced": replaced,
"identity": opts.Identity,
}
// JSON "message" follows the effective preference on disk (appConfig.Lang),
// not the raw --lang value: when --lang is omitted on re-bind, preferredLang
// has already inherited the prior preference into appConfig.Lang, and the
// message should respect that inherited choice. stderr above follows UILang.
prefMsg := getBindMsg(appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), appConfig.Lang)
brand := brandDisplay(string(appConfig.Brand), opts.Lang)
switch opts.Identity {
case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
envelope["message"] = fmt.Sprintf(msg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
envelope["message"] = fmt.Sprintf(msg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -500,7 +461,7 @@ func cleanupKeychainFromData(kc keychain.KeychainAccess, data []byte, keep *core
// tuiSelectSource prompts user to choose bind source.
func tuiSelectSource(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
var source string
// Pre-select based on detected env signals
@@ -525,7 +486,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectSource).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.UILang))).
Description(fmt.Sprintf(msg.SelectSourceDesc, brandDisplay(opts.Brand, opts.Lang))).
Options(
huh.NewOption(fmt.Sprintf(msg.SourceOpenClaw, openclawPath), "openclaw"),
huh.NewOption(fmt.Sprintf(msg.SourceHermes, hermesEnvPath), "hermes"),
@@ -547,7 +508,7 @@ func tuiSelectSource(opts *BindOptions) (string, error) {
// tuiSelectApp prompts the user to choose from multiple account candidates.
// Invoked only via selectCandidate's tuiPrompt callback, and only in TUI mode.
func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Candidate, error) {
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
options := make([]huh.Option[int], 0, len(candidates))
for i, c := range candidates {
label := c.AppID
@@ -561,7 +522,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.UILang))).
Title(fmt.Sprintf(msg.SelectAccount, sourceDisplayName(source), brandDisplay(opts.Brand, opts.Lang))).
Options(options...).
Value(&selected),
),
@@ -578,7 +539,7 @@ func tuiSelectApp(opts *BindOptions, source string, candidates []Candidate) (*Ca
// tuiConflictPrompt shows existing binding and asks user to Force or Cancel.
func tuiConflictPrompt(opts *BindOptions, source, configPath string) (string, error) {
msg := getBindMsg(opts.UILang)
msg := getBindMsg(opts.Lang)
// Build existing binding summary
existingSummary := fmt.Sprintf(msg.ConflictDesc, source, "?", "?", configPath)
@@ -630,11 +591,6 @@ func validateBindFlags(opts *BindOptions) error {
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
}
}
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
@@ -650,8 +606,8 @@ func validateBindFlags(opts *BindOptions) error {
// DescriptionFunc approach breaks here because a longer description on
// hover pushes options out of the field's initial viewport.
func tuiSelectIdentity(opts *BindOptions) (string, error) {
msg := getBindMsg(opts.UILang)
brand := brandDisplay(opts.Brand, opts.UILang)
msg := getBindMsg(opts.Lang)
brand := brandDisplay(opts.Brand, opts.Lang)
botLabel := msg.IdentityBotOnly + "\n" + indent(fmt.Sprintf(msg.IdentityBotOnlyDesc, brand))
userLabel := msg.IdentityUserDefault + "\n" + indent(fmt.Sprintf(msg.IdentityUserDefaultDesc, brand, brand))
var value string

View File

@@ -3,8 +3,6 @@
package config
import "github.com/larksuite/cli/internal/i18n"
// bindMsg holds all TUI text for config bind, supporting zh/en via --lang.
//
// Brand-aware strings use a %s slot where the UI-friendly product name
@@ -86,11 +84,6 @@ type bindMsg struct {
// require in-flow human confirmation.
IdentityEscalationMessage string
IdentityEscalationHint string
// LangPreferenceSet is printed to stderr after a successful bind when the
// user explicitly passed --lang. Format: language code. Not printed when
// --lang was not explicit (i.e., the cobra default zh stayed in effect).
LangPreferenceSet string
}
var bindMsgZh = &bindMsg{
@@ -123,8 +116,6 @@ var bindMsgZh = &bindMsg{
IdentityEscalationMessage: "你正在从应用身份切换到用户身份 —— 切换后 AI 将以你的名义在飞书中执行所有操作(读写文档、搜索消息、修改日程等)。⚠️ 请勿将此机器人分享给他人或拉入群聊中使用,以免泄露你的飞书数据。",
IdentityEscalationHint: "若用户确认切换,附加 --force 重新运行:`lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "语言偏好已设置:%s",
}
var bindMsgEn = &bindMsg{
@@ -159,13 +150,10 @@ var bindMsgEn = &bindMsg{
IdentityEscalationMessage: "you are switching from bot-only to user-default — the AI will then act under your Feishu identity for all operations (docs, messages, calendar, etc.). ⚠️ Don't share this bot with others or add it to group chats. It has access to your personal Feishu data.",
IdentityEscalationHint: "if the user confirms the switch, re-run with --force: `lark-cli config bind --identity user-default --force`",
LangPreferenceSet: "Language preference set to: %s",
}
// getBindMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getBindMsg(lang i18n.Lang) *bindMsg {
if lang.IsEnglish() {
func getBindMsg(lang string) *bindMsg {
if lang == "en" {
return bindMsgEn
}
return bindMsgZh
@@ -176,11 +164,11 @@ func getBindMsg(lang i18n.Lang) *bindMsg {
// "feishu" (or empty / unknown) maps to "飞书" in zh and "Feishu" in en —
// this is the safe default when the brand hasn't been resolved yet (for
// example, on the pre-binding source-selection screen).
func brandDisplay(brand string, lang i18n.Lang) string {
func brandDisplay(brand, lang string) string {
if brand == "lark" || brand == "Lark" || brand == "LARK" {
return "Lark"
}
if lang.IsEnglish() {
if lang == "en" {
return "Feishu"
}
return "飞书"

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -121,184 +120,14 @@ func TestConfigBindCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "" {
t.Errorf("Lang = %q, want default %q (unset)", gotOpts.Lang, "")
if gotOpts.Lang != "zh" {
t.Errorf("Lang = %q, want default %q", gotOpts.Lang, "zh")
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang not passed")
}
}
// TestConfigBindRun_InvalidLang verifies a non-empty --lang is strictly
// validated: wrong case, typos, and removed codes all exit with
// ExitValidation (code 2) and a message identifying the offending value.
// (Empty is not invalid — see TestConfigBindRun_EmptyLangIsNoOp.)
func TestConfigBindRun_InvalidLang(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
{"dropped short code ko", "ko"},
{"dropped locale ko_kr", "ko_kr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: true,
})
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
// TestConfigBindRun_EmptyLangIsNoOp verifies that an empty --lang (omitted or
// explicit "") is unset: it neither errors nor persists a language, while a
// non-empty short code or Feishu locale both canonicalize to the same locale.
func TestConfigBindRun_EmptyLangIsNoOp(t *testing.T) {
cases := []struct {
name string
lang string
explicit bool
wantLang i18n.Lang
}{
{"omitted", "", false, ""},
{"explicit empty", "", true, ""},
{"short code", "ja", true, i18n.LangJaJP},
{"feishu locale", "ja_jp", true, i18n.LangJaJP},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Lang: tc.lang,
langExplicit: tc.explicit,
}); err != nil {
t.Fatalf("configBindRun(--lang %q) = %v, want nil", tc.lang, err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := multi.CurrentAppConfig("")
if app == nil {
t.Fatal("no app persisted")
}
if app.Lang != tc.wantLang {
t.Errorf("persisted Lang = %q, want %q", app.Lang, tc.wantLang)
}
})
}
}
// TestConfigBindRun_OmitLangPreservesPrior guards against a re-bind without
// --lang silently dropping a previously stored preference (appConfig is rebuilt
// fresh, so commitBinding must inherit the prior Lang).
func TestConfigBindRun_OmitLangPreservesPrior(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "ja", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang ja): %v", err)
}
f2, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
multi, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := multi.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-bind = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigBindRun_EnvelopeMessageFollowsInheritedLang guards the JSON envelope
// "message" field against regressing to opts.Lang: when --lang is omitted on
// re-bind, the inherited preference (appConfig.Lang) must drive the message
// language and the embedded brand display — otherwise an AI agent that set
// English on first bind sees Chinese in every subsequent re-bind envelope.
func TestConfigBindRun_EnvelopeMessageFollowsInheritedLang(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f1, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f1, Source: "hermes", Lang: "en", langExplicit: true}); err != nil {
t.Fatalf("first bind (--lang en): %v", err)
}
f2, stdout, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f2, Source: "hermes", Lang: "", langExplicit: false}); err != nil {
t.Fatalf("re-bind (no --lang): %v", err)
}
envelope := map[string]any{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("invalid JSON output: %v", err)
}
msg, _ := envelope["message"].(string)
enMsg := getBindMsg(i18n.LangEnUS)
wantMsg := fmt.Sprintf(enMsg.MessageBotOnly, "cli_abc", "Hermes", brandDisplay("feishu", i18n.LangEnUS))
if msg != wantMsg {
t.Errorf("envelope.message = %q,\nwant %q (must follow inherited appConfig.Lang=en_us, not raw opts.Lang)", msg, wantMsg)
}
}
// ── Run function tests (aligned with TestConfigShowRun pattern) ──
func TestConfigBindRun_InvalidSource(t *testing.T) {
@@ -1645,14 +1474,10 @@ func TestGetBindMsg_En(t *testing.T) {
}
}
func TestGetBindMsg_NonEnLang_FallsBackToZh(t *testing.T) {
// Only zh and en TUI bundles exist; any non-English language (canonical
// locale, short code, or unrecognized value) falls back to zh.
for _, lang := range []i18n.Lang{"fr_fr", "ja_jp", "ko", "unknown", ""} {
msg := getBindMsg(lang)
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("getBindMsg(%q) SelectSource = %q, want %q (zh fallback)", lang, msg.SelectSource, want)
}
func TestGetBindMsg_UnknownLang_DefaultsToZh(t *testing.T) {
msg := getBindMsg("fr")
if want := "你想在哪个 Agent 中使用 lark-cli?"; msg.SelectSource != want {
t.Errorf("fr (default) SelectSource = %q, want %q", msg.SelectSource, want)
}
}
@@ -1815,36 +1640,3 @@ func TestHasStrictBotLock(t *testing.T) {
})
}
}
// TestConfigBindRun_LangExplicit_PrintsConfirmation covers the flag-mode
// confirmation line: when --lang is explicit, bind prints "language preference
// set" to stderr (rendered in the TUI language, embedding the preference value).
func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) {
saveWorkspace(t)
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
hermesHome := t.TempDir()
t.Setenv("HERMES_HOME", hermesHome)
if err := os.WriteFile(filepath.Join(hermesHome, ".env"), []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600); err != nil {
t.Fatalf("write .env: %v", err)
}
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{
Factory: f,
Source: "hermes",
Identity: "bot-only",
Lang: "en",
langExplicit: true,
})
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}
// The short --lang en is canonicalized to en_us before the confirmation
// echoes it back; the TUI language stays zh (flag mode, no picker).
want := fmt.Sprintf(getBindMsg(i18n.LangZhCN).LangPreferenceSet, "en_us")
if got := stderr.String(); !strings.Contains(got, want) {
t.Errorf("stderr = %q, want it to contain confirmation %q", got, want)
}
}

View File

@@ -389,12 +389,10 @@ func resolveHermesEnvPath() string {
}
// resolveLarkChannelConfigPath returns the path to lark-channel-bridge's
// source config. LARK_CHANNEL_CONFIG lets a host point bind at a projected
// single-account config without changing lark-cli's target config directory.
// config.json. Mirrors the bridge's src/config/paths.ts which hardcodes
// ~/.lark-channel/config.json with no env override — multi-instance is not
// a supported scenario today.
func resolveLarkChannelConfigPath() string {
if p := os.Getenv("LARK_CHANNEL_CONFIG"); strings.TrimSpace(p) != "" {
return expandHome(p)
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)

View File

@@ -4,7 +4,6 @@
package config
import (
"path/filepath"
"reflect"
"testing"
@@ -174,27 +173,3 @@ func TestSelectCandidate_AppIDFlag_WinsOverTUI(t *testing.T) {
}
assertCandidate(t, got, Candidate{AppID: "cli_b"})
}
func TestResolveLarkChannelConfigPath_Default(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, ".lark-channel", "config.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}
func TestResolveLarkChannelConfigPath_EnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("LARK_CHANNEL_CONFIG", "~/bridge/projection.json")
got := resolveLarkChannelConfigPath()
want := filepath.Join(home, "bridge", "projection.json")
if got != want {
t.Fatalf("resolveLarkChannelConfigPath() = %q, want %q", got, want)
}
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -152,9 +151,8 @@ func TestConfigInitCmd_LangFlag(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// --lang en is canonicalized to en_us in RunE before runF captures opts.
if gotOpts.Lang != string(i18n.LangEnUS) {
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
@@ -175,84 +173,14 @@ func TestConfigInitCmd_LangDefault(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "" {
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
// re-running init without --lang must inherit the prior preference, not clear it.
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
}}
if err := core.SaveMultiAppConfig(existing); err != nil {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
func TestConfigInitCmd_InvalidLang(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
lang string
}{
{"wrong case ZH", "ZH"},
{"typo frr", "frr"},
{"removed code ar", "ar"},
{"unknown xx", "xx"},
{"hyphen form zh-CN", "zh-CN"},
{"dropped short code ko", "ko"},
{"dropped locale ko_kr", "ko_kr"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigInit(f, nil)
f.IOStreams.In = strings.NewReader("sec\n")
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
err := cmd.Execute()
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
@@ -484,71 +412,3 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
})
}
}
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
// the same locale; an unrecognized value errors.
func TestValidateInitLang(t *testing.T) {
t.Run("empty is a no-op", func(t *testing.T) {
for _, explicit := range []bool{false, true} {
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
if err := validateInitLang(opts); err != nil {
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
}
if opts.Lang != "" {
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
}
}
})
t.Run("short and locale canonicalize alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
if err := validateInitLang(opts); err != nil {
t.Fatalf("--lang %q: unexpected error %v", in, err)
}
if opts.Lang != string(i18n.LangJaJP) {
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
}
}
})
t.Run("dropped short code ko errors", func(t *testing.T) {
opts := &ConfigInitOptions{Lang: "ko", langExplicit: true}
if err := validateInitLang(opts); err == nil {
t.Fatal("expected validation error for --lang ko, got nil")
}
})
t.Run("dropped locale ko_kr errors", func(t *testing.T) {
opts := &ConfigInitOptions{Lang: "ko_kr", langExplicit: true}
if err := validateInitLang(opts); err == nil {
t.Fatal("expected validation error for --lang ko_kr, got nil")
}
})
}
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
// to stderr only when --lang explicitly set a non-empty preference.
func TestPrintLangPreferenceConfirmation(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
got := stderr.String()
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
}
})
t.Run("implicit prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
}
})
t.Run("explicit empty prints nothing", func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
if got := stderr.String(); got != "" {
t.Errorf("stderr = %q, want empty when --lang is empty", got)
}
})
}

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
@@ -32,13 +31,9 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
UILang i18n.Lang // TUI display language (picker-only); intentionally separate from --lang
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Lang string
langExplicit bool // true when --lang was explicitly passed
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
@@ -50,7 +45,7 @@ type ConfigInitOptions struct {
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
@@ -68,9 +63,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateInitLang(opts); err != nil {
return err
}
if err := guardAgentWorkspace(opts); err != nil {
return err
}
@@ -85,7 +77,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (zh, en, or ja)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
@@ -93,25 +85,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
return cmd
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
if !opts.langExplicit || opts.Lang == "" {
return
}
msg := getInitMsg(opts.UILang)
fmt.Fprintln(opts.Factory.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, opts.Lang))
}
func validateInitLang(opts *ConfigInitOptions) error {
lang, err := cmdutil.ParseLangFlag(opts.Lang)
if err != nil {
return err
}
opts.Lang = string(lang)
return nil
}
// guardAgentWorkspace refuses 'config init' when run inside an OpenClaw or
// Hermes Agent context, because the Agent has already provisioned an app
// and 'config bind' is the right tool for hooking lark-cli into it.
@@ -159,7 +132,7 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
@@ -173,13 +146,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
return saveAsOnlyApp(appId, secret, brand, lang)
}
// saveAsProfile appends or updates a named profile in the config.
@@ -200,10 +167,11 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
multi.Apps[idx].Users = []core.AppUser{}
}
// Update existing profile
multi.Apps[idx].AppId = appId
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].Lang = lang
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
@@ -214,7 +182,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Lang: lang,
Users: []core.AppUser{},
})
}
@@ -270,7 +238,7 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
app.AppId = appID
app.Brand = brand
app.Lang = preferredLang(i18n.Lang(lang), app.Lang)
app.Lang = lang
return core.SaveMultiAppConfig(existing)
}
@@ -315,27 +283,29 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set.
// Picker offers 2 options (中文 / English) and drives BOTH opts.Lang
// (preference) and opts.UILang (TUI rendering).
// For interactive modes, prompt language selection if --lang was not explicitly set
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
savedLang := ""
if existing != nil {
if app := existing.CurrentAppConfig(""); app != nil {
savedLang = app.Lang
}
}
lang, err := promptLangSelection(savedLang)
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return err
}
opts.Lang = string(lang)
opts.UILang = lang
opts.Lang = lang
}
msg := getInitMsg(opts.UILang)
msg := getInitMsg(opts.Lang)
// Mode 3: Create new app directly (--new)
if opts.New {
@@ -354,7 +324,6 @@ func configInitRun(opts *ConfigInitOptions) error {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
@@ -397,7 +366,6 @@ func configInitRun(opts *ConfigInitOptions) error {
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
return nil
}
@@ -484,6 +452,5 @@ func configInitRun(opts *ConfigInitOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
)
type initMsg struct {
@@ -27,10 +26,6 @@ type initMsg struct {
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// LangPreferenceSet is printed to stderr after a successful init when the
// user explicitly passed --lang. Format: language code.
LangPreferenceSet string
}
var initMsgZh = &initMsg{
@@ -48,7 +43,6 @@ var initMsgZh = &initMsg{
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
LangPreferenceSet: "语言偏好已设置:%s",
}
var initMsgEn = &initMsg{
@@ -66,27 +60,29 @@ var initMsgEn = &initMsg{
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
LangPreferenceSet: "Language preference set to: %s",
}
// getInitMsg picks the zh/en TUI bundle; non-English falls back to zh.
func getInitMsg(lang i18n.Lang) *initMsg {
if lang.IsEnglish() {
func getInitMsg(lang string) *initMsg {
if lang == "en" {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows the 中文/English picker and returns the chosen locale.
func promptLangSelection() (i18n.Lang, error) {
lang := i18n.LangZhCN
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[i18n.Lang]().
huh.NewSelect[string]().
Title("Language / 语言").
Options(
huh.NewOption("中文", i18n.LangZhCN),
huh.NewOption("English", i18n.LangEnUS),
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
).
Value(&lang),
),

View File

@@ -6,8 +6,6 @@ package config
import (
"fmt"
"testing"
"github.com/larksuite/cli/internal/i18n"
)
func TestGetInitMsg_Zh(t *testing.T) {
@@ -31,7 +29,7 @@ func TestGetInitMsg_En(t *testing.T) {
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []i18n.Lang{"", "unknown", "xyz", "invalid"} {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
@@ -64,7 +62,6 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"LangPreferenceSet": msg.LangPreferenceSet,
}
for name, val := range fields {
if val == "" {
@@ -74,7 +71,7 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []i18n.Lang{i18n.LangZhCN, i18n.LangEnUS} {
for _, lang := range []string{"zh", "en"} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
@@ -87,37 +84,3 @@ func TestInitMsg_FormatStrings(t *testing.T) {
}
}
}
func TestGetInitMsg_BilingualCollapse(t *testing.T) {
// The TUI is bilingual (zh + en). Only English-bucket languages return the
// English struct — by canonical locale ("en_us") or legacy short ("en").
// Everything else (zh, the other codes, invalid, "") returns Chinese.
tests := []struct {
lang i18n.Lang
shouldBeEn bool
}{
{i18n.LangZhCN, false},
{i18n.LangEnUS, true},
{"en", true}, // legacy short value
{i18n.LangJaJP, false},
{"fr_fr", false},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
msg := getInitMsg(tt.lang)
if msg == nil {
t.Fatal("getInitMsg returned nil")
}
want := initMsgZh
if tt.shouldBeEn {
want = initMsgEn
}
if msg != want {
t.Errorf("getInitMsg(%q) returned wrong struct", tt.lang)
}
})
}
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
@@ -41,7 +40,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&appID, "app-id", "", "App ID (required)")
cmd.Flags().BoolVar(&appSecretStdin, "app-secret-stdin", false, "read App Secret from stdin")
cmd.Flags().StringVar(&brand, "brand", "feishu", "feishu or lark")
cmd.Flags().StringVar(&lang, "lang", "", "language preference (zh, en, or ja)")
cmd.Flags().StringVar(&lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().BoolVar(&use, "use", false, "switch to this profile after adding")
_ = cmd.MarkFlagRequired("name")
@@ -56,12 +55,6 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
return output.ErrValidation("%v", err)
}
langPref, err := cmdutil.ParseLangFlag(lang)
if err != nil {
return err
}
lang = string(langPref)
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
@@ -122,7 +115,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
AppId: appID,
AppSecret: secret,
Brand: parsedBrand,
Lang: i18n.Lang(lang),
Lang: lang,
Users: []core.AppUser{},
})

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
)
@@ -52,70 +51,6 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
// short codes and Feishu locales both canonicalize to the same stored locale,
// empty stores no preference, and an unrecognized value errors.
func TestProfileAddRun_Lang(t *testing.T) {
t.Run("short and locale canonicalize and persist alike", func(t *testing.T) {
for _, in := range []string{"ja", "ja_jp"} {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", in, false); err != nil {
t.Fatalf("--lang %q: profileAddRun() error = %v", in, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if app := saved.FindApp("p"); app == nil || app.Lang != i18n.LangJaJP {
t.Errorf("--lang %q: stored Lang = %v, want %q", in, app, i18n.LangJaJP)
}
}
})
t.Run("empty stores no preference", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
if err := profileAddRun(f, "p", "app-p", true, "feishu", "", false); err != nil {
t.Fatalf("profileAddRun() error = %v", err)
}
saved, _ := core.LoadMultiAppConfig()
if app := saved.FindApp("p"); app == nil || app.Lang != "" {
t.Errorf("stored Lang = %v, want \"\" (unset)", app)
}
})
t.Run("invalid lang errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ZH", false)
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
t.Run("dropped code ko errors", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "p", "app-p", true, "feishu", "ko", false)
if err == nil {
t.Fatal("expected validation error for --lang ko, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
}
})
}
func TestProfileAddRun_UseAfterUpdatesCurrentAndPrevious(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

View File

@@ -205,32 +205,14 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(errBody))
if msg != "" {
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
attachStreamLogID(err, resp.Header)
return nil, err
return nil, output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
}
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
attachStreamLogID(err, resp.Header)
return nil, err
return nil, output.ErrNetwork("HTTP %d", resp.StatusCode)
}
return resp, nil
}
func attachStreamLogID(err *output.ExitError, header http.Header) {
if err == nil || err.Detail == nil {
return
}
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
if logID == "" {
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
}
if logID == "" {
return
}
err.Detail.Detail = map[string]any{"log_id": logID}
}
type cancelOnCloseBody struct {
io.ReadCloser
cancel context.CancelFunc

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client_test
import (
"context"
"errors"
"net/http"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
factory, _, _, reg := cmdutil.TestFactory(t, config)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/drive/v1/medias/file_token/download",
Status: http.StatusForbidden,
RawBody: []byte("forbidden"),
Headers: http.Header{
larkcore.HttpHeaderKeyLogId: []string{"202605270003"},
},
})
client, err := factory.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
_, err = client.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
if detail["log_id"] != "202605270003" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"strings"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
// ParseLangFlag validates and canonicalizes a --lang value, shared by config
// and profile so every entry point honors one contract. Empty is unset (no-op);
// a non-empty value must resolve via i18n.Parse or it errors.
func ParseLangFlag(raw string) (i18n.Lang, error) {
if raw == "" {
return "", nil
}
lang, ok := i18n.Parse(raw)
if !ok {
return "", output.ErrValidation(
"invalid --lang %q; valid values: %s",
raw, strings.Join(i18n.Codes(), ", "))
}
return lang, nil
}

View File

@@ -11,7 +11,6 @@ import (
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -42,7 +41,7 @@ type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang i18n.Lang `json:"lang,omitempty"`
Lang string `json:"lang,omitempty"`
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
@@ -160,7 +159,6 @@ type CliConfig struct {
DefaultAs Identity // AsUser | AsBot | AsAuto | "" (from config file)
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
@@ -266,7 +264,6 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
Lang: app.Lang,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
// Lang is a Feishu locale (e.g. "zh_cn"); "" means unset.
type Lang string
const (
LangZhCN Lang = "zh_cn"
LangEnUS Lang = "en_us"
LangJaJP Lang = "ja_jp"
)
type langEntry struct {
Code Lang // canonical Feishu locale
Short string // ISO 639-1 code, also accepted as input shorthand
}
// catalog is the single source of truth; order drives --help and error listing.
// Locked to {zh, en, ja} as of 2026-05-28: TUI bundles only ship for zh/en
// (ja falls back to the zh bundle), and Lark API client code only branches on
// these three for localization. Adding more entries here is meaningful only
// after the downstream codepaths (mail signature locale, TUI bundle) gain
// branches for them.
var catalog = []langEntry{
{LangZhCN, "zh"}, {LangEnUS, "en"}, {LangJaJP, "ja"},
}
// find matches a short code or Feishu locale against the catalog (case-sensitive).
func find(s string) (langEntry, bool) {
for _, e := range catalog {
if string(e.Code) == s || e.Short == s {
return e, true
}
}
return langEntry{}, false
}
// Parse resolves a short code or Feishu locale to its canonical Lang.
// "" and unrecognized values return ("", false).
func Parse(s string) (Lang, bool) {
e, ok := find(s)
return e.Code, ok
}
// IsEnglish reports whether l uses the English TUI bundle (robust to "en_us"
// and legacy "en").
func (l Lang) IsEnglish() bool {
e, _ := find(string(l))
return e.Code == LangEnUS
}
// Base returns the ISO 639-1 short code ("en_us" → "en"), or "" if unknown.
func (l Lang) Base() string {
e, _ := find(string(l))
return e.Short
}
// Codes lists the canonical locales, for --help and error messages.
func Codes() []string {
out := make([]string, len(catalog))
for i, e := range catalog {
out[i] = string(e.Code)
}
return out
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package i18n
import (
"slices"
"testing"
)
func TestParse(t *testing.T) {
tests := []struct {
in string
want Lang
wantOK bool
}{
{"zh", LangZhCN, true}, // short code
{"zh_cn", LangZhCN, true}, // canonical locale
{"en", LangEnUS, true}, // short code
{"en_us", LangEnUS, true}, // canonical locale
{"ja", LangJaJP, true}, // short code
{"ja_jp", LangJaJP, true}, // canonical locale
{"", "", false}, // unset
{"ZH", "", false}, // case-sensitive
{"zh-CN", "", false}, // hyphen form not accepted
{"zh_CN", "", false}, // case-sensitive region
{"ar", "", false}, // not in the supported set
{"xx", "", false}, // unknown
{"ko", "", false}, // dropped in 2026-05-28 catalog shrink
{"ko_kr", "", false}, // dropped: legacy Feishu locale
{"fr_fr", "", false}, // dropped: legacy Feishu locale
{"de_de", "", false}, // dropped: legacy Feishu locale
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
got, ok := Parse(tt.in)
if got != tt.want || ok != tt.wantOK {
t.Errorf("Parse(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK)
}
})
}
}
func TestIsEnglish(t *testing.T) {
tests := []struct {
lang Lang
want bool
}{
{LangEnUS, true},
{Lang("en"), true}, // legacy short value on disk stays robust
{LangZhCN, false},
{LangJaJP, false},
{Lang("zh"), false},
{Lang(""), false}, // unset → not English (zh bundle)
{Lang("garbage"), false},
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.IsEnglish(); got != tt.want {
t.Errorf("Lang(%q).IsEnglish() = %v, want %v", tt.lang, got, tt.want)
}
})
}
}
func TestBase(t *testing.T) {
tests := []struct {
lang Lang
want string
}{
{LangEnUS, "en"},
{LangZhCN, "zh"},
{LangJaJP, "ja"},
{Lang("en"), "en"}, // legacy short value
{Lang("zh"), "zh"},
{Lang(""), ""}, // unset
{Lang("garbage"), ""}, // unknown
}
for _, tt := range tests {
t.Run(string(tt.lang), func(t *testing.T) {
if got := tt.lang.Base(); got != tt.want {
t.Errorf("Lang(%q).Base() = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestCodes(t *testing.T) {
codes := Codes()
want := []string{"zh_cn", "en_us", "ja_jp"}
if !slices.Equal(codes, want) {
t.Fatalf("Codes() = %v, want %v", codes, want)
}
// Every code must round-trip through Parse to itself (canonical).
for _, c := range codes {
if got, ok := Parse(c); !ok || string(got) != c {
t.Errorf("Parse(%q) = (%q, %v), want (%q, true)", c, got, ok, c)
}
}
}

View File

@@ -66,19 +66,6 @@ const (
// IM resource ownership mismatch.
LarkErrOwnershipMismatch = 231205
// Mail send: account / mailbox-level failures returned by
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
// because ErrAPI preserves Detail.Code exactly as returned by the server.
// These codes indicate the entire batch will keep failing identically and
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
LarkErrMailQuota = 1236010 // mail quota limit
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
)
// legacyHints supplies the per-code actionable hint string for the legacy

View File

@@ -91,32 +91,6 @@ func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
}
}
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
got int
want int
}{
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if tt.got != tt.want {
t.Fatalf("code=%d, want %d", tt.got, tt.want)
}
})
}
}
// TestClassifyLarkError_WikiLockContention verifies the wiki write-lock
// contention error (131009) maps to an actionable retry hint instead of
// a generic "api_error". Surfaces during concurrent wiki +node-create

View File

@@ -575,7 +575,7 @@ func TestParseAffordance_FullPopulated(t *testing.T) {
"do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"},
"prerequisites": []interface{}{"user 身份登录"},
"examples": []interface{}{
map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"},
map[string]interface{}{"title": "获取主日历", "input": map[string]interface{}{}},
},
"related": []interface{}{"calendars.list"},
}
@@ -586,8 +586,7 @@ func TestParseAffordance_FullPopulated(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
if len(a.Examples) != 1 || a.Examples[0].Title != "获取主日历" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {

View File

@@ -76,11 +76,10 @@ type Affordance struct {
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one example entry: a one-line description plus a
// ready-to-run lark-cli command string.
// AffordanceCase is one example entry.
type AffordanceCase struct {
Description string `json:"description"`
Command string `json:"command"`
Title string `json:"title"`
Input map[string]interface{} `json:"input"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.42",
"version": "1.0.41",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -21,7 +21,7 @@ var AppsAccessScopeGet = common.Shortcut{
Command: "+access-scope-get",
Description: "Get Miaoda app access scope configuration",
Risk: "read",
Scopes: []string{"spark:app:read"},
Scopes: []string{"spark:app.access_scope:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -27,7 +27,7 @@ var AppsAccessScopeSet = common.Shortcut{
Command: "+access-scope-set",
Description: "Set Miaoda app access scope (specific / public / tenant)",
Risk: "write",
Scopes: []string{"spark:app:write"},
Scopes: []string{"spark:app.access_scope:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -21,7 +21,7 @@ var AppsHTMLPublish = common.Shortcut{
Command: "+html-publish",
Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)",
Risk: "write",
Scopes: []string{"spark:app:write"},
Scopes: []string{"spark:app:publish"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{

View File

@@ -11,7 +11,6 @@ import (
"image"
"image/color"
"image/png"
"net/http"
"net/url"
"os"
"path/filepath"
@@ -2201,7 +2200,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) {
t.Run("download reports progress when later attachment fails", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -2229,9 +2228,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/box_b/download",
Status: 403,
Status: 500,
RawBody: []byte("server error"),
Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}},
})
tmpDir := t.TempDir()
@@ -2260,9 +2258,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
if detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)
}

View File

@@ -787,7 +787,7 @@ func downloadBaseAttachment(ctx context.Context, runtime *common.RuntimeContext,
QueryParams: query,
})
if err != nil {
return nil, err
return nil, output.ErrNetwork("download failed: %v", err)
}
defer resp.Body.Close()
@@ -835,13 +835,6 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
msg := fmt.Sprintf("download failed after %d attachment(s) succeeded and %d failed: %v", len(downloaded), len(failed), err)
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
detail := map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
}
if logID := baseAttachmentDownloadLogID(err); logID != "" {
detail["log_id"] = logID
}
return &output.ExitError{
Code: exitErr.Code,
Detail: &output.ErrDetail{
@@ -849,7 +842,10 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
Code: exitErr.Detail.Code,
Message: msg,
Hint: "Some files may already have been saved. Inspect error.detail.downloaded before retrying, or rerun with --overwrite if the failed target now exists.",
Detail: detail,
Detail: map[string]interface{}{
"downloaded": downloaded,
"failed": failed,
},
},
Err: err,
}
@@ -869,19 +865,6 @@ func attachmentDownloadProgressError(err error, downloaded []map[string]interfac
}
}
func baseAttachmentDownloadLogID(err error) string {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return ""
}
detail, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
return ""
}
logID, _ := detail["log_id"].(string)
return strings.TrimSpace(logID)
}
func outputPathLooksDirectory(runtime *common.RuntimeContext, outputPath string) bool {
if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, string(filepath.Separator)) {
return true

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -171,34 +170,13 @@ func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (ma
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), driveMediaUploadErrorDetail(apiResp, result["error"]))
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func driveMediaUploadErrorDetail(apiResp *larkcore.ApiResp, detail interface{}) interface{} {
logID := ""
if apiResp != nil {
logID = strings.TrimSpace(apiResp.LogId())
}
if logID == "" {
return detail
}
detailMap, ok := detail.(map[string]interface{})
if !ok {
if detail == nil {
return map[string]interface{}{"log_id": logID}
}
return map[string]interface{}{"error": detail, "log_id": logID}
}
if _, exists := detailMap["log_id"]; !exists {
detailMap["log_id"] = logID
}
return detailMap
}
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {

View File

@@ -7,12 +7,10 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"strings"
"sync/atomic"
@@ -23,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
var commonDriveMediaUploadTestSeq atomic.Int64
@@ -462,24 +459,6 @@ func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
t.Fatalf("expected API error, got %v", err)
}
})
t.Run("api code error includes log_id", func(t *testing.T) {
t.Parallel()
resp := &larkcore.ApiResp{
RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`),
Header: http.Header{"X-Tt-Logid": []string{"202605270002"}},
}
_, err := ParseDriveMediaUploadResponse(resp, "upload media failed")
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["log_id"] != "202605270002" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
})
}
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {

View File

@@ -25,7 +25,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -73,22 +72,6 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// Lang returns the user's preference as a canonical locale.
// Empty stays empty (unset). Any non-empty stored value that does not resolve
// via i18n.Parse (e.g. legacy ko_kr / fr_fr from before the catalog was
// shrunk to zh/en/ja) is silently coerced to LangZhCN — existing configs
// stay readable, just behave as zh.
func (ctx *RuntimeContext) Lang() i18n.Lang {
raw := string(ctx.Config.Lang)
if raw == "" {
return ""
}
if lang, ok := i18n.Parse(raw); ok {
return lang
}
return i18n.LangZhCN
}
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
func TestRuntimeContext_Lang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want i18n.Lang
}{
{"canonical locale", i18n.LangJaJP, i18n.LangJaJP},
{"legacy short value normalizes", "ja", i18n.LangJaJP},
{"legacy short zh normalizes", "zh", i18n.LangZhCN},
{"unset stays empty", "", ""},
// Flipped semantics: unrecognized non-empty values are now treated
// as legacy storage from the pre-2026-05-28 14-language catalog
// and silently coerced to LangZhCN, not left empty.
{"unrecognized garbage coerces to zh", "klingon", i18n.LangZhCN},
{"legacy ko_kr coerces to zh", "ko_kr", i18n.LangZhCN},
{"legacy fr_fr coerces to zh", "fr_fr", i18n.LangZhCN},
{"legacy short ko coerces to zh", "ko", i18n.LangZhCN},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := ctx.Lang(); got != tt.want {
t.Errorf("Lang() with stored %q = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -353,6 +353,7 @@ func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
// --- Execute tests ---
func TestDriveInspectExecute_DocxURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -394,6 +395,7 @@ func TestDriveInspectExecute_DocxURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -456,6 +458,7 @@ func TestDriveInspectExecute_WikiURL(t *testing.T) {
}
func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -484,6 +487,7 @@ func TestDriveInspectExecute_WikiGetNodeIncompleteData(t *testing.T) {
}
func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -520,6 +524,7 @@ func TestDriveInspectExecute_BareTokenWithType(t *testing.T) {
}
func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
@@ -543,6 +548,7 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)

View File

@@ -729,18 +729,6 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, "Bot/user membership in the target chat is not verified") ||
!strings.Contains(got, "Bot/User can NOT be out of the chat") {
t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got)
}
})
t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -754,19 +742,6 @@ func TestShortcutDryRunShapes(t *testing.T) {
}
})
t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`
membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."`
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) {
t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got)
}
})
t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,om_2",

View File

@@ -155,20 +155,6 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext,
}
// Preserve API-provided fields (even if this formatter doesn't otherwise use them).
// update_time is only meaningful when the message was actually edited;
// the server echoes update_time == create_time for unedited messages, which
// would otherwise make every output look "updated" to downstream consumers.
if updated {
if v, ok := m["update_time"]; ok && v != nil {
if s, isStr := v.(string); isStr {
if strings.TrimSpace(s) != "" {
msg["update_time"] = common.FormatTime(s)
}
} else {
msg["update_time"] = common.FormatTime(v)
}
}
}
if v, ok := m["chat_id"]; ok {
msg["chat_id"] = v
}

View File

@@ -95,61 +95,6 @@ func TestFormatMessageItem(t *testing.T) {
}
}
func TestFormatMessageItem_UpdateTime_Present(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_edit",
"updated": true,
"create_time": "1710500000",
"update_time": "1710600000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"edited"}`},
}
got := FormatMessageItem(raw, nil)
want := common.FormatTime("1710600000")
if got["update_time"] != want {
t.Fatalf("FormatMessageItem() update_time = %#v, want %#v", got["update_time"], want)
}
}
func TestFormatMessageItem_UpdateTime_Absent(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_no_edit",
"updated": false,
"create_time": "1710500000",
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if _, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() should not include update_time when absent, got = %#v", got["update_time"])
}
}
// TestFormatMessageItem_UpdateTime_UnchangedMessage: real API behavior — even
// for unedited messages, server returns update_time == create_time. We must
// NOT echo it through, otherwise every message looks "edited" to consumers.
// Gate the output on updated==true.
func TestFormatMessageItem_UpdateTime_UnchangedMessage(t *testing.T) {
raw := map[string]interface{}{
"msg_type": "text",
"message_id": "om_unchanged",
"updated": false,
"create_time": "1710500000",
"update_time": "1710500000", // server echoes create_time
"sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"},
"body": map[string]interface{}{"content": `{"text":"hi"}`},
}
got := FormatMessageItem(raw, nil)
if v, ok := got["update_time"]; ok {
t.Fatalf("FormatMessageItem() must skip update_time for unedited message, got = %#v", v)
}
}
func TestResolveAppLinkDomain(t *testing.T) {
if got := resolveAppLinkDomain(core.BrandFeishu); got != "applink.feishu.cn" {
t.Fatalf("resolveAppLinkDomain(feishu) = %q", got)

View File

@@ -1,207 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"fmt"
"net/http"
"github.com/larksuite/cli/shortcuts/common"
)
// reactionsBatchQueryMaxQueries is the server-side hard limit on queries[]
// length for POST /im/v1/messages/reactions/batch_query (see
// larkim/message/members/facade_reaction/service: batchListReactionsMaxMessageIDs).
const reactionsBatchQueryMaxQueries = 20
// EnrichReactions enriches messages with their reactions by calling the
// im.reactions.batch_query API. Messages are modified in place: each message
// that the server returns reactions for gets a "reactions" map attached.
//
// Failure modes (warning to stderr + skip; never aborts main message output):
// - batch_query call fails (network, 5xx, scope insufficient, rate limited):
// each message in the failed batch is marked with "reactions_error": true
// so callers can distinguish "fetch failed" from "no reactions exist".
// - batch_query returns a partial result: only messages the server failed on
// get "reactions_error": true; the successful ones get the reactions block.
//
// The "reactions_error" flag mirrors the "thread_replies_error" pattern in
// thread.go so downstream consumers handle both enrichment failures uniformly.
//
// Output shape (only on messages that the server actually returned data for):
//
// "reactions": {
// "counts": [{"reaction_type": "SMILE", "count": 3}],
// "details": [{"reaction_id": "...", "emoji_type": "SMILE",
// "operator": {...}, "action_time": "..."}]
// }
//
// The server caps queries[] at 20 per call, so messages are split into
// batches of size <= 20 before invoking the API.
func EnrichReactions(runtime *common.RuntimeContext, messages []map[string]interface{}) {
if len(messages) == 0 {
return
}
// Index messages by ID so we can merge reactions back later.
// A single message_id may appear more than once (e.g. mget --message-ids
// om_a,om_a); every occurrence must receive the reactions block, but the
// API should only be queried once per distinct id.
// Walks into msg["thread_replies"] recursively so replies attached by
// ExpandThreadReplies are enriched in the same batched call as their parent.
idIndex := make(map[string][]map[string]interface{}, len(messages))
var ids []string
collectMessageNodes(messages, idIndex, &ids)
if len(ids) == 0 {
return
}
for i := 0; i < len(ids); i += reactionsBatchQueryMaxQueries {
end := i + reactionsBatchQueryMaxQueries
if end > len(ids) {
end = len(ids)
}
fetchReactionsBatch(runtime, ids[i:end], idIndex)
}
}
// collectMessageNodes walks messages (and any nested thread_replies) and
// records each map under its message_id. Distinct ids are appended to *ids in
// first-seen order so the API is queried at most once per id.
func collectMessageNodes(messages []map[string]interface{}, idIndex map[string][]map[string]interface{}, ids *[]string) {
for _, msg := range messages {
if id, _ := msg["message_id"].(string); id != "" {
if _, seen := idIndex[id]; !seen {
*ids = append(*ids, id)
}
idIndex[id] = append(idIndex[id], msg)
}
// thread_replies may arrive as a typed slice (set by ExpandThreadReplies)
// or as []interface{} (e.g. when produced via JSON round-trip).
switch nested := msg["thread_replies"].(type) {
case []map[string]interface{}:
collectMessageNodes(nested, idIndex, ids)
case []interface{}:
typed := make([]map[string]interface{}, 0, len(nested))
for _, raw := range nested {
if m, ok := raw.(map[string]interface{}); ok {
typed = append(typed, m)
}
}
collectMessageNodes(typed, idIndex, ids)
}
}
}
// fetchReactionsBatch invokes batch_query for one batch of <= 20 message IDs
// and merges the results into idIndex. Failures are logged to stderr without
// aborting subsequent batches.
func fetchReactionsBatch(runtime *common.RuntimeContext, batchIDs []string, idIndex map[string][]map[string]interface{}) {
queries := make([]map[string]interface{}, 0, len(batchIDs))
for _, id := range batchIDs {
queries = append(queries, map[string]interface{}{"message_id": id})
}
data, err := runtime.DoAPIJSON(http.MethodPost,
"/open-apis/im/v1/messages/reactions/batch_query",
nil,
map[string]interface{}{"queries": queries},
)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: reactions_batch_query_failed: %v\n", err)
markReactionsError(batchIDs, idIndex)
return
}
countsByMsg := groupReactionCounts(data["success_msg_reaction_counts"])
detailsByMsg := groupReactionDetails(data["success_msg_reaction_details"])
// Attach the merged reactions block to every message that had any data.
// Each id may map to >1 message map (duplicate input), so iterate the slice.
for _, id := range batchIDs {
msgs := idIndex[id]
if len(msgs) == 0 {
continue
}
counts := countsByMsg[id]
details := detailsByMsg[id]
if len(counts) == 0 && len(details) == 0 {
continue
}
block := make(map[string]interface{}, 2)
if len(counts) > 0 {
block["counts"] = counts
}
if len(details) > 0 {
block["details"] = details
}
for _, msg := range msgs {
msg["reactions"] = block
}
}
// Surface per-message failures from the API response.
if fails, _ := data["fail_msg_reaction_details"].([]interface{}); len(fails) > 0 {
var failedIDs []string
for _, raw := range fails {
item, _ := raw.(map[string]interface{})
if id, _ := item["message_id"].(string); id != "" {
failedIDs = append(failedIDs, id)
}
}
if len(failedIDs) > 0 {
fmt.Fprintf(runtime.IO().ErrOut,
"warning: reactions_partial_failed: %d message(s) failed (%v)\n",
len(failedIDs), failedIDs)
markReactionsError(failedIDs, idIndex)
}
}
}
// markReactionsError flags every message map indexed under the given ids with
// reactions_error=true, so downstream consumers can distinguish "fetch failed"
// from "no reactions exist" by reading stdout alone.
func markReactionsError(ids []string, idIndex map[string][]map[string]interface{}) {
for _, id := range ids {
for _, msg := range idIndex[id] {
msg["reactions_error"] = true
}
}
}
func groupReactionCounts(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["reaction_count"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}
func groupReactionDetails(raw interface{}) map[string][]interface{} {
groups := map[string][]interface{}{}
items, _ := raw.([]interface{})
for _, item := range items {
row, _ := item.(map[string]interface{})
msgID, _ := row["message_id"].(string)
if msgID == "" {
continue
}
entries, _ := row["message_reaction_items"].([]interface{})
if len(entries) == 0 {
continue
}
groups[msgID] = append(groups[msgID], entries...)
}
return groups
}

View File

@@ -1,352 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"sort"
"strings"
"testing"
)
// TestEnrichReactions_Success exercises the basic happy path: messages that
// carry reactions get a "reactions" field, messages without reactions stay
// untouched.
func TestEnrichReactions_Success(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/reactions/batch_query") {
return nil, fmt.Errorf("unexpected path: %s", req.URL.Path)
}
var payload map[string]interface{}
body, _ := io.ReadAll(req.Body)
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries size = %d, want 2", len(queries))
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 3},
},
},
},
"success_msg_reaction_details": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"message_reaction_items": []interface{}{
map[string]interface{}{
"reaction_id": "react_1",
"emoji_type": "SMILE",
"operator": map[string]interface{}{"operator_id": "ou_x", "operator_type": "user"},
"action_time": "1710600000",
},
},
},
},
"fail_msg_reaction_details": []interface{}{},
},
}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
reactionsA, ok := messages[0]["reactions"].(map[string]interface{})
if !ok {
t.Fatalf("message om_a missing reactions field: %#v", messages[0])
}
counts, _ := reactionsA["counts"].([]interface{})
if len(counts) != 1 {
t.Fatalf("om_a counts = %d, want 1", len(counts))
}
details, _ := reactionsA["details"].([]interface{})
if len(details) != 1 {
t.Fatalf("om_a details = %d, want 1", len(details))
}
if _, ok := messages[1]["reactions"]; ok {
t.Fatalf("message om_b should not have reactions field (none in response): %#v", messages[1])
}
}
// TestEnrichReactions_BatchSize splits queries into batches of 20 (server-side
// max for batch_query).
func TestEnrichReactions_BatchSize(t *testing.T) {
var observedBatchSizes []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
observedBatchSizes = append(observedBatchSizes, len(queries))
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
}), nil
}))
messages := make([]map[string]interface{}, 25)
for i := range messages {
messages[i] = map[string]interface{}{"message_id": fmt.Sprintf("om_%02d", i)}
}
EnrichReactions(runtime, messages)
if want := []int{20, 5}; !reflect.DeepEqual(observedBatchSizes, want) {
t.Fatalf("batch sizes = %v, want %v", observedBatchSizes, want)
}
}
// TestEnrichReactions_APIFailure: when the API call fails, messages stay
// without a reactions field but get marked with reactions_error=true so
// downstream consumers can distinguish "fetch failed" from "no reactions".
// Mirrors the thread_replies_error pattern in thread.go.
func TestEnrichReactions_APIFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("simulated network error")
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
for _, m := range messages {
if _, ok := m["reactions"]; ok {
t.Fatalf("message %v should have no reactions after API failure", m["message_id"])
}
if v, _ := m["reactions_error"].(bool); !v {
t.Fatalf("message %v should have reactions_error=true after API failure, got = %#v",
m["message_id"], m["reactions_error"])
}
}
}
// TestEnrichReactions_PartialFailure: when batch_query returns a fail entry
// for one ID, that message gets reactions_error=true while the rest stay
// clean (no error flag) and keep their normal reactions block.
func TestEnrichReactions_PartialFailure(t *testing.T) {
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_ok",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
},
"fail_msg_reaction_details": []interface{}{
map[string]interface{}{"message_id": "om_bad"},
},
},
}), nil
}))
ok := map[string]interface{}{"message_id": "om_ok"}
bad := map[string]interface{}{"message_id": "om_bad"}
EnrichReactions(runtime, []map[string]interface{}{ok, bad})
if _, has := ok["reactions"]; !has {
t.Fatalf("om_ok should have reactions: %#v", ok)
}
if v, _ := ok["reactions_error"].(bool); v {
t.Fatalf("om_ok must not carry reactions_error: %#v", ok)
}
if _, has := bad["reactions"]; has {
t.Fatalf("om_bad should have no reactions block: %#v", bad)
}
if v, _ := bad["reactions_error"].(bool); !v {
t.Fatalf("om_bad should have reactions_error=true, got = %#v", bad["reactions_error"])
}
}
// TestEnrichReactions_EmptyMessages: no messages -> no API call at all.
func TestEnrichReactions_EmptyMessages(t *testing.T) {
called := false
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
EnrichReactions(runtime, nil)
EnrichReactions(runtime, []map[string]interface{}{})
if called {
t.Fatalf("API should not be called when messages list is empty")
}
}
// TestEnrichReactions_SkipsMessagesWithoutID: messages missing message_id
// (defensive) should not crash and not be sent in queries.
func TestEnrichReactions_SkipsMessagesWithoutID(t *testing.T) {
var sentIDs []string
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
sentIDs = append(sentIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
messages := []map[string]interface{}{
{"message_id": "om_a"},
{}, // no message_id
{"message_id": ""},
{"message_id": "om_b"},
}
EnrichReactions(runtime, messages)
if want := []string{"om_a", "om_b"}; !reflect.DeepEqual(sentIDs, want) {
t.Fatalf("sent IDs = %v, want %v", sentIDs, want)
}
}
// TestEnrichReactions_WalksThreadReplies: thread_replies nested under a parent
// message must also be enriched, in the same batch_query call as the parent —
// otherwise the parent gets reactions but its replies don't, leaving the output
// inconsistent.
func TestEnrichReactions_WalksThreadReplies(t *testing.T) {
var observedQueriedIDs []string
var observedCallCount int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
observedCallCount++
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
for _, q := range queries {
qm, _ := q.(map[string]interface{})
id, _ := qm["message_id"].(string)
observedQueriedIDs = append(observedQueriedIDs, id)
}
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_top",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 1},
},
},
map[string]interface{}{
"message_id": "om_reply1",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "THUMBSUP", "count": 2},
},
},
map[string]interface{}{
"message_id": "om_reply2",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "HEART", "count": 3},
},
},
},
},
}), nil
}))
reply1 := map[string]interface{}{"message_id": "om_reply1"}
reply2 := map[string]interface{}{"message_id": "om_reply2"}
top := map[string]interface{}{
"message_id": "om_top",
"thread_replies": []map[string]interface{}{reply1, reply2},
}
messages := []map[string]interface{}{top}
EnrichReactions(runtime, messages)
if observedCallCount != 1 {
t.Fatalf("expected 1 batched API call, got %d", observedCallCount)
}
sort.Strings(observedQueriedIDs)
if want := []string{"om_reply1", "om_reply2", "om_top"}; !reflect.DeepEqual(observedQueriedIDs, want) {
t.Fatalf("queried IDs = %v, want %v (top + thread_replies)", observedQueriedIDs, want)
}
if _, ok := top["reactions"]; !ok {
t.Fatalf("top message missing reactions")
}
if _, ok := reply1["reactions"]; !ok {
t.Fatalf("reply1 missing reactions — thread_replies were not walked")
}
if _, ok := reply2["reactions"]; !ok {
t.Fatalf("reply2 missing reactions — thread_replies were not walked")
}
}
// TestEnrichReactions_DuplicateMessageID: when the caller passes two distinct
// message maps that share the same message_id (e.g. mget --message-ids om_a,om_a),
// both maps must receive the same reactions block, and the API must be queried
// for the id only once.
func TestEnrichReactions_DuplicateMessageID(t *testing.T) {
var observedQueriesPerCall []int
runtime := newBotConvertlibRuntime(t, convertlibRoundTripFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
queries, _ := payload["queries"].([]interface{})
observedQueriesPerCall = append(observedQueriesPerCall, len(queries))
return convertlibJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"success_msg_reaction_counts": []interface{}{
map[string]interface{}{
"message_id": "om_a",
"reaction_count": []interface{}{
map[string]interface{}{"reaction_type": "SMILE", "count": 2},
},
},
},
},
}), nil
}))
first := map[string]interface{}{"message_id": "om_a"}
second := map[string]interface{}{"message_id": "om_a"}
other := map[string]interface{}{"message_id": "om_b"}
messages := []map[string]interface{}{first, other, second}
EnrichReactions(runtime, messages)
if want := []int{2}; !reflect.DeepEqual(observedQueriesPerCall, want) {
t.Fatalf("queries-per-call = %v, want %v (each id once, no dup fetch)", observedQueriesPerCall, want)
}
firstReactions, firstOK := first["reactions"]
secondReactions, secondOK := second["reactions"]
if !firstOK {
t.Fatalf("first om_a entry missing reactions")
}
if !secondOK {
t.Fatalf("second om_a entry missing reactions — dup msg_id was dropped")
}
if !reflect.DeepEqual(firstReactions, secondReactions) {
t.Fatalf("dup entries reactions differ: %#v vs %#v", firstReactions, secondReactions)
}
}

View File

@@ -22,8 +22,8 @@ var ImChatMessageList = common.Shortcut{
Description: "List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.base:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -34,7 +34,6 @@ var ImChatMessageList = common.Shortcut{
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
@@ -55,12 +54,7 @@ var ImChatMessageList = common.Shortcut{
dryParams[k] = vs[0]
}
}
d = d.GET("/open-apis/im/v1/messages").Params(dryParams)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages (including thread_replies expanded inline) in batches of up to 20. Pass --no-reactions to skip.")
}
return d
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Under bot identity, --user-id is not supported; require --chat-id only.
@@ -127,9 +121,6 @@ var ImChatMessageList = common.Shortcut{
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -22,22 +22,16 @@ var ImMessagesMGet = common.Shortcut{
Description: "Batch get messages by IDs; user/bot; fetches up to 50 om_ message IDs, formats sender names, expands thread replies",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-ids", Desc: "message IDs, comma-separated (om_xxx,om_yyy)", Required: true},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids := common.SplitCSV(runtime.Str("message-ids"))
d := common.NewDryRunAPI().GET(buildMGetURL(ids))
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned messages in batches of up to 20 to attach the reactions block (operator, action_time, counts). Pass --no-reactions to skip.")
}
return d
return common.NewDryRunAPI().GET(buildMGetURL(ids))
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
ids := common.SplitCSV(runtime.Str("message-ids"))
@@ -75,9 +69,6 @@ var ImMessagesMGet = common.Shortcut{
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
convertlib.ExpandThreadReplies(runtime, messages, nameCache, convertlib.ThreadRepliesPerThread, convertlib.ThreadRepliesTotalLimit)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"messages": messages,

View File

@@ -30,7 +30,7 @@ var ImMessagesSearch = common.Shortcut{
Command: "+messages-search",
Description: "Search messages across chats (supports keyword, sender, time range filters) with user identity; user-only; filters by chat/sender/attachment/time, enriches results via mget and chats batch_query",
Risk: "read",
Scopes: []string{"search:message", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
Scopes: []string{"search:message", "contact:user.basic_profile:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
@@ -49,7 +49,6 @@ var ImMessagesSearch = common.Shortcut{
{Name: "page-token", Desc: "page token"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate search results"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
req, err := buildMessagesSearchRequest(runtime)
@@ -69,17 +68,12 @@ var ImMessagesSearch = common.Shortcut{
} else {
d = d.Desc("Step 1: search messages")
}
d = d.
return d.
POST("/open-apis/im/v1/messages/search").
Params(dryParams).
Body(req.body).
Desc("Step 2 (if results): GET /open-apis/im/v1/messages/mget?message_ids=... — batch fetch message details (max 50)").
Desc("Step 3 (if results): POST /open-apis/im/v1/chats/batch_query — fetch chat names for context")
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Step 4 (if results): reaction enrichment in batches of up to 20 messages. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildMessagesSearchRequest(runtime)
@@ -190,9 +184,6 @@ var ImMessagesSearch = common.Shortcut{
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, enriched, nameCache)
convertlib.AttachSenderNames(enriched, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, enriched)
}
outData := map[string]interface{}{
"messages": enriched,

View File

@@ -81,14 +81,10 @@ var ImMessagesSend = common.Shortcut{
if desc != "" {
d.Desc(desc)
}
d.
return d.
POST("/open-apis/im/v1/messages").
Params(map[string]interface{}{"receive_id_type": receiveIdType}).
Body(body)
if chatFlag != "" {
d.Desc("NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with `Bot/User can NOT be out of the chat`.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatFlag := runtime.Str("chat-id")

View File

@@ -24,8 +24,8 @@ var ImThreadsMessagesList = common.Shortcut{
Description: "List messages in a thread; user/bot; accepts om_/omt_ input, resolves message IDs to thread_id, supports sort/pagination",
Risk: "read",
Scopes: []string{"im:message:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "im:message.reactions:read", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "im:message.reactions:read", "contact:user.base:readonly"},
UserScopes: []string{"im:message.group_msg:get_as_user", "im:message.p2p_msg:get_as_user", "contact:user.basic_profile:readonly"},
BotScopes: []string{"im:message.group_msg", "im:message.p2p_msg:readonly", "contact:user.base:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
@@ -33,7 +33,6 @@ var ImThreadsMessagesList = common.Shortcut{
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
{Name: "page-token", Desc: "page token"},
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
threadFlag := runtime.Str("thread")
@@ -66,15 +65,10 @@ var ImThreadsMessagesList = common.Shortcut{
params["page_token"] = pageToken
}
d = d.
return d.
GET("/open-apis/im/v1/messages").
Params(params).
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
if !runtime.Bool("no-reactions") {
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
}
return d
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
threadId := runtime.Str("thread")
@@ -130,9 +124,6 @@ var ImThreadsMessagesList = common.Shortcut{
// Enrich: resolve sender names for outer messages (reuses cache from merge_forward)
convertlib.ResolveSenderNames(runtime, messages, nameCache)
convertlib.AttachSenderNames(messages, nameCache)
if !runtime.Bool("no-reactions") {
convertlib.EnrichReactions(runtime, messages)
}
outData := map[string]interface{}{
"thread_id": threadId,

View File

@@ -1,109 +0,0 @@
// 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,920 +0,0 @@
// 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)
}
})
}
}

View File

@@ -1,353 +0,0 @@
// 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')
}

View File

@@ -1,92 +0,0 @@
// 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,
}
}

View File

@@ -13,7 +13,6 @@ 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
@@ -45,8 +44,7 @@ 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. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{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: "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."},
@@ -59,7 +57,6 @@ var MailDraftCreate = common.Shortcut{
signatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag,
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(runtime)
@@ -85,30 +82,19 @@ 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
}
// 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 {
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
return validatePriorityFlag(runtime)
@@ -119,14 +105,10 @@ 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: body,
Body: runtime.Str("body"),
From: runtime.Str("from"),
CC: runtime.Str("cc"),
BCC: runtime.Str("bcc"),
@@ -185,7 +167,7 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return err
}
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
rawEML, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
if err != nil {
return err
@@ -198,14 +180,6 @@ 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
@@ -228,10 +202,6 @@ 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,
@@ -242,19 +212,14 @@ func buildRawEMLForDraftCreate(
mailboxID, templateID string,
templateInlineAttachments []templateInlineRef,
templateSmallAttachments []templateAttachmentRef,
) (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()
) (string, error) {
senderEmail := resolveComposeSenderEmail(runtime)
if senderEmail == "" {
return "", lintApplied, lintBlocked, fmt.Errorf("unable to determine sender email; please specify --from explicitly")
return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly")
}
if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil {
return "", lintApplied, lintBlocked, err
return "", err
}
bld := emlbuilder.New().WithFileIO(runtime.FileIO()).
@@ -272,7 +237,7 @@ func buildRawEMLForDraftCreate(
// compose shortcuts; if it ever trips in this path, the above check
// regressed.
if err := requireSenderForRequestReceipt(runtime, senderEmail); err != nil {
return "", lintApplied, lintBlocked, err
return "", err
}
if runtime.Bool("request-receipt") {
bld = bld.DispositionNotificationTo("", senderEmail)
@@ -283,9 +248,9 @@ func buildRawEMLForDraftCreate(
if input.BCC != "" {
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
}
inlineSpecs, parseErr := parseInlineSpecs(input.Inline)
if parseErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("%v", parseErr)
inlineSpecs, err := parseInlineSpecs(input.Inline)
if err != nil {
return "", output.ErrValidation("%v", err)
}
var autoResolvedPaths []string
var composedHTMLBody string
@@ -300,17 +265,9 @@ func buildRawEMLForDraftCreate(
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody)
if resolveErr != nil {
return "", lintApplied, lintBlocked, resolveErr
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 (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)
@@ -326,14 +283,13 @@ func buildRawEMLForDraftCreate(
}
allCIDs = append(allCIDs, signatureCIDs(sigResult)...)
var tplInlineCIDs []string
var embedErr error
bld, tplInlineCIDs, embedErr = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
if embedErr != nil {
return "", lintApplied, lintBlocked, embedErr
bld, tplInlineCIDs, err = embedTemplateInlineAttachments(ctx, runtime, bld, resolved, mailboxID, templateID, templateInlineAttachments)
if err != nil {
return "", err
}
allCIDs = append(allCIDs, tplInlineCIDs...)
if cidErr := validateInlineCIDs(resolved, allCIDs, nil); cidErr != nil {
return "", lintApplied, lintBlocked, cidErr
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}
} else {
composedTextBody = input.Body
@@ -343,10 +299,9 @@ 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
var smallErr error
bld, templateSmallBytes, smallErr = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
if smallErr != nil {
return "", lintApplied, lintBlocked, smallErr
bld, templateSmallBytes, err = embedTemplateSmallAttachments(ctx, runtime, bld, mailboxID, templateID, templateSmallAttachments)
if err != nil {
return "", err
}
bld = applyPriority(bld, priority)
if calData := buildCalendarBody(runtime, senderEmail, input.To, input.CC); calData != nil {
@@ -355,17 +310,16 @@ func buildRawEMLForDraftCreate(
allInlinePaths := append(inlineSpecFilePaths(inlineSpecs), autoResolvedPaths...)
composedBodySize := int64(len(composedHTMLBody) + len(composedTextBody))
emlBase := estimateEMLBaseSize(runtime.FileIO(), composedBodySize, allInlinePaths, 0) + templateSmallBytes
var largeErr error
bld, largeErr = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
if largeErr != nil {
return "", lintApplied, lintBlocked, largeErr
bld, err = processLargeAttachments(ctx, runtime, bld, composedHTMLBody, composedTextBody, splitByComma(input.Attach), emlBase, 0)
if err != nil {
return "", err
}
if hdr, hdrErr := encodeTemplateLargeAttachmentHeader(templateLargeAttachmentIDs); hdrErr == nil && hdr != "" {
bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, hdr)
}
rawEML, buildErr := bld.BuildBase64URL()
if buildErr != nil {
return "", lintApplied, lintBlocked, output.ErrValidation("build EML failed: %v", buildErr)
rawEML, err := bld.BuildBase64URL()
if err != nil {
return "", output.ErrValidation("build EML failed: %v", err)
}
return rawEML, lintApplied, lintBlocked, nil
return rawEML, nil
}

View File

@@ -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)
}

View File

@@ -35,9 +35,7 @@ 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: "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: "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: "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."},
@@ -47,7 +45,6 @@ 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") {
@@ -71,7 +68,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. 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.").
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.").
GET(mailboxPath(mailboxID, "drafts", draftID)).
Params(map[string]interface{}{"format": "raw"}).
PUT(mailboxPath(mailboxID, "drafts", draftID)).
@@ -177,32 +174,6 @@ 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 {
@@ -226,10 +197,6 @@ 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)
@@ -403,31 +370,6 @@ 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)
@@ -589,7 +531,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 --body/--body-file for quick full-body replacement; use --patch-file for advanced body edits and advanced changes (recipients, headers, attachments, inline images)",
"Use --patch-file for ALL 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{}{
@@ -602,7 +544,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",
"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",
"all body edits MUST go through --patch-file; there is no --set-body flag",
"`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",

View File

@@ -1,330 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// MaxBatchSendDrafts caps the number of draft IDs accepted in a single
// +draft-send invocation. The limit is purely client-side: it bounds command-
// line length comfortably below ARG_MAX and keeps the failure blast radius of
// a single batch small. It is intentionally local to this shortcut (rather
// than living in limits.go) because no other shortcut shares the semantics.
const MaxBatchSendDrafts = 50
// sentDraft is the per-draft success entry in the +draft-send aggregated
// output. message_id and thread_id come from the server response of
// POST /drafts/:draft_id/send.
type sentDraft struct {
DraftID string `json:"draft_id"`
MessageID string `json:"message_id"`
ThreadID string `json:"thread_id,omitempty"`
}
// failedDraft is the per-draft failure entry. error is the
// human-readable err.Error() string (typically including ClassifyLarkError
// hints); v2 may surface a structured errno field separately once the server-
// side mapping stabilises (see tech-design "待确认事项").
type failedDraft struct {
DraftID string `json:"draft_id"`
Error string `json:"error"`
}
// batchSendOutput is the JSON envelope data shape:
//
// {
// "mailbox_id": "me",
// "total": 3,
// "success_count": 2,
// "failure_count": 1,
// "sent": [{"draft_id":..., "message_id":..., "thread_id":...}, ...],
// "failed":[{"draft_id":..., "error":...}]
// }
//
// failed is marked omitempty so a fully successful batch returns a clean shape
// without an empty array.
type batchSendOutput struct {
MailboxID string `json:"mailbox_id"`
Total int `json:"total"`
SuccessCount int `json:"success_count"`
FailureCount int `json:"failure_count"`
Sent []sentDraft `json:"sent"`
Failed []failedDraft `json:"failed,omitempty"`
}
// MailDraftSend is the `+draft-send` shortcut: send N existing drafts
// sequentially via POST /drafts/:draft_id/send, isolating per-draft failures.
// Risk is "high-risk-write"; callers must pass --yes. User identity only —
// drafts are user-owned resources and bot has no coherent semantics here.
//
// Output schema is the batchSendOutput type above. Partial failures (any
// failed[]) return exit 1 with envelope.error.type="partial_failure" so that
// agents can distinguish "all sent" from "some sent" without parsing the
// success_count field.
var MailDraftSend = common.Shortcut{
Service: "mail",
Command: "+draft-send",
Description: "Send one or more existing mail drafts sequentially. Calls " +
"POST /drafts/:draft_id/send for each input ID, isolates per-draft " +
"failures, and aggregates the results. Use after the drafts have " +
"already been created (via the Lark client, +draft-create, or the " +
"drafts.create API).",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:send"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the drafts (default: me)."},
{Name: "draft-id", Type: "string_slice", Required: true,
Desc: "Draft IDs to send; comma-separated or repeat the flag (max 50)."},
{Name: "stop-on-error", Type: "bool",
Desc: "Stop at the first recoverable per-draft failure (default: continue and aggregate). " +
"Fatal errors (auth, permission, network, mailbox-level quota) always abort immediately " +
"regardless of this flag."},
},
Validate: validateDraftSend,
DryRun: dryRunDraftSend,
Execute: executeDraftSend,
}
// executeDraftSend runs the +draft-send command:
//
// 1. Resolve mailbox ID (defaults to "me" via resolveComposeMailboxID).
// 2. Validate the draft-id slice (non-empty, under MaxBatchSendDrafts cap,
// no empty elements).
// 3. Loop over each draft ID, calling POST .../drafts/:id/send directly via
// runtime.CallAPI. Per-draft outcomes:
// - fatal err (isFatalSendErr) → return immediately (bypasses --stop-on-error).
// - recoverable err → append to failed[]; honor --stop-on-error.
// - success + automation_send_disable signal → return immediately with
// ExitAPI/"automation_send_disabled".
// - success → append to sent[].
// 4. Emit batchSendOutput via runtime.Out.
// 5. If any draft failed, return ExitAPI/"partial_failure" so exit code = 1.
func executeDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, err := normalizedDraftSendIDs(rt)
if err != nil {
return err
}
out := batchSendOutput{MailboxID: mailboxID, Total: len(draftIDs)}
stopOnErr := rt.Bool("stop-on-error")
for i, id := range draftIDs {
idx := i + 1
writeDraftSendProgressf(rt, "[%d/%d] sending draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
// Direct CallAPI rather than draftpkg.Send: this shortcut never sends
// a body, so the helper's send_time-aware envelope would add no value.
data, err := rt.CallAPI("POST",
mailboxPath(mailboxID, "drafts", id, "send"), nil, nil)
if err != nil {
if isFatalSendErr(err) {
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
hadProgress := out.hasProgress()
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if hadProgress {
emitDraftSendOutput(rt, &out)
}
// Account- / mailbox-level failures (auth, permission, network,
// quota) will repeat identically for every remaining draft —
// abort immediately so the caller sees a single clear error
// instead of 100 redundant failed[] entries.
return err
}
writeDraftSendProgressf(rt, "[%d/%d] failed draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
if stopOnErr {
break
}
continue
}
if reason := extractAutomationDisabledReason(data); reason != "" {
err := output.Errorf(output.ExitAPI, "automation_send_disabled",
"automation send is disabled for this mailbox: %s", reason)
writeDraftSendProgressf(rt, "[%d/%d] aborting after draft %s: %s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(err.Error()))
if out.hasProgress() {
out.Failed = append(out.Failed, failedDraft{DraftID: id, Error: err.Error()})
emitDraftSendOutput(rt, &out)
}
// HTTP success (code: 0) but the backend signaled automation send
// is disabled — every subsequent send will fail the same way, so
// abort the batch with a single descriptive error.
return err
}
s := sentDraft{DraftID: id}
if v, ok := data["message_id"].(string); ok {
s.MessageID = v
}
if v, ok := data["thread_id"].(string); ok {
s.ThreadID = v
}
out.Sent = append(out.Sent, s)
if s.MessageID != "" {
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s message_id=%s",
idx, len(draftIDs), sanitizeForSingleLine(id), sanitizeForSingleLine(s.MessageID))
} else {
writeDraftSendProgressf(rt, "[%d/%d] sent draft %s",
idx, len(draftIDs), sanitizeForSingleLine(id))
}
}
emitDraftSendOutput(rt, &out)
if out.FailureCount == 0 {
return nil
}
return output.Errorf(output.ExitAPI, "partial_failure",
"%d of %d drafts failed to send", out.FailureCount, out.Total)
}
// dryRunDraftSend builds the --dry-run preview: one POST call per draft ID,
// in input order, with a header description summarising the batch size.
func dryRunDraftSend(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveComposeMailboxID(rt)
draftIDs, _ := normalizedDraftSendIDs(rt)
api := common.NewDryRunAPI().Desc(fmt.Sprintf(
"Send %d existing drafts sequentially", len(draftIDs)))
for _, id := range draftIDs {
api = api.POST(mailboxPath(mailboxID, "drafts", id, "send"))
}
return api
}
func validateDraftSend(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizedDraftSendIDs(rt)
return err
}
func normalizedDraftSendIDs(rt *common.RuntimeContext) ([]string, error) {
return normalizeDraftSendIDs(rt.StrSlice("draft-id"))
}
func normalizeDraftSendIDs(draftIDs []string) ([]string, error) {
if len(draftIDs) == 0 {
return nil, output.ErrValidation("--draft-id is required")
}
normalized := make([]string, 0, len(draftIDs))
seen := make(map[string]struct{}, len(draftIDs))
for _, id := range draftIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return nil, output.ErrValidation("--draft-id contains empty value")
}
if _, ok := seen[trimmed]; ok {
return nil, output.ErrValidation("--draft-id contains duplicate value: %s", trimmed)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
if len(normalized) > MaxBatchSendDrafts {
return nil, output.ErrValidation(
"too many drafts: %d > %d (split into multiple batches)",
len(normalized), MaxBatchSendDrafts)
}
return normalized, nil
}
func (out *batchSendOutput) hasProgress() bool {
return len(out.Sent) > 0 || len(out.Failed) > 0
}
func emitDraftSendOutput(rt *common.RuntimeContext, out *batchSendOutput) {
out.SuccessCount = len(out.Sent)
out.FailureCount = len(out.Failed)
rt.Out(*out, nil)
}
func writeDraftSendProgressf(rt *common.RuntimeContext, format string, args ...interface{}) {
if rt == nil || rt.Factory == nil || rt.Factory.IOStreams == nil || rt.Factory.IOStreams.ErrOut == nil {
return
}
fmt.Fprintf(rt.Factory.IOStreams.ErrOut, "mail +draft-send: "+format+"\n", args...)
}
// isFatalSendErr reports whether err is an account- or mailbox-level failure
// that will repeat identically for every subsequent draft. Fatal errors
// bypass --stop-on-error and immediately abort the batch.
//
// Trigger conditions:
//
// - err does not unwrap to an *output.ExitError, or its Detail is missing:
// unknown shapes are treated as fatal so they cannot accidentally
// accumulate into failed[] for every remaining draft.
// - Detail.Type ∈ {"auth", "app_status", "config", "permission",
// "rate_limit", "network"}: token, scope, app-installation problems,
// throttling, and connectivity are account-level.
// - Code == output.ExitNetwork: connectivity loss is account-level.
// - Detail.Code ∈ {LarkErrMailboxNotFound, LarkErrMailSendQuotaUser,
// LarkErrMailSendQuotaUserExt, LarkErrMailSendQuotaTenantExt,
// LarkErrMailQuota, LarkErrTenantStorageLimit}: mailbox / quota
// exhaustion is account-level.
func isFatalSendErr(err error) bool {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return true
}
switch exitErr.Detail.Type {
case "auth", "app_status", "config":
return true
case "permission", "rate_limit", "network":
return true
}
if exitErr.Code == output.ExitNetwork || wrapsExitCode(err, output.ExitNetwork) {
return true
}
switch exitErr.Detail.Code {
case output.LarkErrMailboxNotFound,
output.LarkErrMailSendQuotaUser,
output.LarkErrMailSendQuotaUserExt,
output.LarkErrMailSendQuotaTenantExt,
output.LarkErrMailQuota,
output.LarkErrTenantStorageLimit:
return true
}
return false
}
func wrapsExitCode(err error, code int) bool {
for unwrapped := errors.Unwrap(err); unwrapped != nil; unwrapped = errors.Unwrap(unwrapped) {
if exitErr, ok := unwrapped.(*output.ExitError); ok && exitErr.Code == code {
return true
}
}
return false
}
// extractAutomationDisabledReason returns the human-readable reason when the
// send succeeded at HTTP level (code: 0) but the backend reports that
// automation send is disabled for this mailbox. An empty return value means
// automation send is enabled.
//
// The data["automation_send_disable"] payload is best-effort: a malformed
// shape or missing reason still produces a generic non-empty message so the
// caller can surface the disabled status to the user instead of silently
// continuing.
func extractAutomationDisabledReason(data map[string]interface{}) string {
ad, ok := data["automation_send_disable"]
if !ok {
return ""
}
m, ok := ad.(map[string]interface{})
if !ok {
return "automation send disabled (no reason provided)"
}
if reason, ok := m["reason"].(string); ok && strings.TrimSpace(reason) != "" {
return strings.TrimSpace(reason)
}
return "automation send disabled (no reason provided)"
}

View File

@@ -1,942 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestMailDraftSend_Metadata pins the public surface of the +draft-send
// shortcut: command name, risk level, scopes, auth type, and the three
// declared flags. Changing any of these is a public-contract change and must
// be intentional.
func TestMailDraftSend_Metadata(t *testing.T) {
if MailDraftSend.Service != "mail" {
t.Errorf("Service = %q, want %q", MailDraftSend.Service, "mail")
}
if MailDraftSend.Command != "+draft-send" {
t.Errorf("Command = %q, want %q", MailDraftSend.Command, "+draft-send")
}
if MailDraftSend.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want %q", MailDraftSend.Risk, "high-risk-write")
}
if !MailDraftSend.HasFormat {
t.Error("HasFormat must be true so --format is auto-injected")
}
if len(MailDraftSend.AuthTypes) != 1 || MailDraftSend.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailDraftSend.AuthTypes)
}
// Minimum-permission rule: only :send. Adding :modify or :readonly here is
// an explicit scope-policy regression.
if len(MailDraftSend.Scopes) != 1 || MailDraftSend.Scopes[0] != "mail:user_mailbox.message:send" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:send]", MailDraftSend.Scopes)
}
flagByName := map[string]common.Flag{}
for _, fl := range MailDraftSend.Flags {
flagByName[fl.Name] = fl
}
mailbox, ok := flagByName["mailbox"]
if !ok {
t.Fatal("missing --mailbox flag")
}
if mailbox.Required {
t.Error("--mailbox must NOT be Required (defaults to me via resolveComposeMailboxID)")
}
if mailbox.Default != "" {
t.Errorf("--mailbox Default should be empty (let resolveComposeMailboxID supply 'me'); got %q", mailbox.Default)
}
draftID, ok := flagByName["draft-id"]
if !ok {
t.Fatal("missing --draft-id flag")
}
if !draftID.Required {
t.Error("--draft-id must be Required so cobra rejects missing-flag invocations")
}
if draftID.Type != "string_slice" {
t.Errorf("--draft-id Type = %q, want %q", draftID.Type, "string_slice")
}
stopOnErr, ok := flagByName["stop-on-error"]
if !ok {
t.Fatal("missing --stop-on-error flag")
}
if stopOnErr.Required {
t.Error("--stop-on-error must be optional")
}
if stopOnErr.Type != "bool" {
t.Errorf("--stop-on-error Type = %q, want %q", stopOnErr.Type, "bool")
}
}
// stubDraftSend registers a stub for POST .../drafts/<draftID>/send with the
// supplied response body. Used to assemble multi-draft test scenarios.
func stubDraftSend(reg *httpmock.Registry, draftID string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts/" + draftID + "/send",
Body: body,
}
reg.Register(stub)
return stub
}
// TestMailDraftSend_AllSuccess verifies the happy path: every draft sends
// successfully, sent[] is fully populated, failed[] is omitted from the JSON,
// and exit code = 0 (err == nil).
func TestMailDraftSend_AllSuccess(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
"thread_id": "thread_1",
},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_2",
"thread_id": "thread_2",
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("expected nil err on full success, got %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 2 {
t.Errorf("total = %v, want 2", data["total"])
}
if data["success_count"].(float64) != 2 {
t.Errorf("success_count = %v, want 2", data["success_count"])
}
if data["failure_count"].(float64) != 0 {
t.Errorf("failure_count = %v, want 0", data["failure_count"])
}
sent, ok := data["sent"].([]interface{})
if !ok || len(sent) != 2 {
t.Fatalf("sent[] missing or wrong size: %#v", data["sent"])
}
if _, exists := data["failed"]; exists {
t.Errorf("failed[] should be omitted on full success; got %#v", data["failed"])
}
first := sent[0].(map[string]interface{})
if first["draft_id"] != "d1" || first["message_id"] != "msg_1" || first["thread_id"] != "thread_1" {
t.Errorf("first sent entry shape unexpected: %#v", first)
}
}
// TestMailDraftSend_ProgressWritesToStderr verifies long sends do not look
// hung: per-draft progress is emitted on stderr while stdout remains the
// final machine-readable JSON ledger.
func TestMailDraftSend_ProgressWritesToStderr(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found",
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_3",
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
progress := stderr.String()
for _, want := range []string{
"mail +draft-send: [1/3] sending draft d1",
"mail +draft-send: [1/3] sent draft d1 message_id=msg_1",
"mail +draft-send: [2/3] sending draft d2",
"mail +draft-send: [2/3] failed draft d2:",
"mail +draft-send: [3/3] sending draft d3",
"mail +draft-send: [3/3] sent draft d3 message_id=msg_3",
} {
if !strings.Contains(progress, want) {
t.Errorf("stderr missing %q; got %s", want, progress)
}
}
if strings.Contains(stdout.String(), "mail +draft-send:") {
t.Errorf("stdout must not contain progress lines; got %s", stdout.String())
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 2 || data["failure_count"].(float64) != 1 {
t.Errorf("unexpected aggregate counts: %#v", data)
}
}
// TestMailDraftSend_PartialFailure verifies that one recoverable per-draft
// failure does not abort the batch; the remaining drafts are attempted; both
// arrays are populated; and the call returns ExitAPI/"partial_failure".
func TestMailDraftSend_PartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
// Non-fatal code (not in the {auth, app_status, config, permission,
// network, 1234013, 1236007, 1236008, 1236009, 1236010, 1236013}
// set) → recoverable.
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found or already sent",
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_3"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "partial_failure" {
t.Errorf("Detail.Type = %v, want partial_failure", exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 2 {
t.Errorf("success_count = %v, want 2", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
failed, ok := data["failed"].([]interface{})
if !ok || len(failed) != 1 {
t.Fatalf("failed[] missing or wrong size: %#v", data["failed"])
}
failedEntry := failed[0].(map[string]interface{})
if failedEntry["draft_id"] != "d2" {
t.Errorf("failed entry draft_id = %v, want d2", failedEntry["draft_id"])
}
if !strings.Contains(strings.ToLower(failedEntry["error"].(string)), "draft not found") {
t.Errorf("failed entry error should contain server msg, got %q", failedEntry["error"])
}
}
// TestMailDraftSend_StopOnError verifies --stop-on-error short-circuits at the
// first recoverable failure. d3 is intentionally NOT stubbed: if the loop
// kept going, the httpmock RoundTripper would return "no stub for POST
// /user_mailboxes/me/drafts/d3/send" and Execute would surface it.
func TestMailDraftSend_StopOnError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 230001,
"msg": "draft not found",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
"--stop-on-error",
}, f, stdout)
if err == nil {
t.Fatal("expected partial_failure error, got nil")
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
}
// TestMailDraftSend_FatalAborts verifies that a fatal errno (mailbox not
// found) aborts the batch immediately and does NOT populate failed[]; the
// later drafts are not attempted (d2 is intentionally not stubbed — any
// attempt would be observable as a runner failure from the httpmock layer).
func TestMailDraftSend_FatalAborts(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": output.LarkErrMailboxNotFound,
"msg": "mailbox not found",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailboxNotFound {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailboxNotFound, exitErr.Detail)
}
// No JSON envelope on stdout because Execute returned early before rt.Out.
if stdout.Len() != 0 {
t.Errorf("expected no JSON output on fatal abort, got %s", stdout.String())
}
}
// TestMailDraftSend_FatalAfterSuccessEmitsLedger verifies that a fatal error
// after earlier side effects still emits the aggregate stdout ledger before
// returning the fatal stderr error. This lets callers avoid blindly retrying a
// draft that was already sent.
func TestMailDraftSend_FatalAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": output.LarkErrMailSendQuotaUser,
"msg": "user daily send count exceeded",
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected fatal abort error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Code != output.LarkErrMailSendQuotaUser {
t.Errorf("expected Detail.Code = %d, got %#v", output.LarkErrMailSendQuotaUser, exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
}
// TestMailDraftSend_AutomationDisabled verifies that an HTTP-success response
// carrying the automation_send_disable signal aborts the batch with
// ExitAPI/"automation_send_disabled" and does NOT continue to subsequent
// drafts (d2 intentionally has no stub — any attempt would surface as an
// httpmock "no stub" failure).
func TestMailDraftSend_AutomationDisabled(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_1",
"automation_send_disable": map[string]interface{}{
"reason": "policy: outbound automation disabled",
},
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("Code = %d, want ExitAPI=%d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
if !strings.Contains(exitErr.Error(), "outbound automation disabled") {
t.Errorf("error message should propagate reason, got %q", exitErr.Error())
}
}
// TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger verifies that an
// automation-send policy stop after earlier successful sends still writes the
// batch ledger to stdout before returning the structured fatal error.
func TestMailDraftSend_AutomationDisabledAfterSuccessEmitsLedger(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message_id": "msg_2",
"automation_send_disable": map[string]interface{}{
"reason": "policy: outbound automation disabled",
},
},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2,d3",
"--yes",
}, f, stdout)
if err == nil {
t.Fatal("expected automation_send_disabled error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "automation_send_disabled" {
t.Errorf("Detail.Type = %v, want automation_send_disabled", exitErr.Detail)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["total"].(float64) != 3 {
t.Errorf("total = %v, want 3", data["total"])
}
if data["success_count"].(float64) != 1 {
t.Errorf("success_count = %v, want 1", data["success_count"])
}
if data["failure_count"].(float64) != 1 {
t.Errorf("failure_count = %v, want 1", data["failure_count"])
}
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "draft_id"); got != "d2" {
t.Errorf("failed[0].draft_id = %q, want d2", got)
}
if got := gjsonLikeString(t, data, "failed", 0, "error"); !strings.Contains(got, "outbound automation disabled") {
t.Errorf("failed[0].error should contain reason, got %q", got)
}
}
// TestMailDraftSend_ValidateErrors verifies that input-shape problems are
// caught in the pre-call layers (cobra Required + Validate). No network call
// is registered; the test should fail loudly if any HTTP call is attempted
// (httpmock returns "no stub" in that case).
func TestMailDraftSend_ValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
wantSub string
wantCobra bool // true → cobra-level MarkFlagRequired error path
}{
{
name: "missing draft-id",
args: []string{"+draft-send", "--yes"},
wantSub: `required flag(s) "draft-id" not set`,
wantCobra: true,
},
{
// cobra's StringSlice treats a bare "" as an unset flag, so pass a
// whitespace-only element instead to drive the Validate-callback
// empty-element branch.
name: "whitespace-only value",
args: []string{"+draft-send", "--draft-id", " ", "--yes"},
wantSub: "--draft-id contains empty value",
},
{
name: "exceeds cap",
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--yes"},
wantSub: "too many drafts",
},
{
name: "duplicate value",
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--yes"},
wantSub: "--draft-id contains duplicate value: d1",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), c.wantSub) {
t.Errorf("err = %v, want substring %q", err, c.wantSub)
}
})
}
}
func TestMailDraftSend_DryRunValidateErrors(t *testing.T) {
cases := []struct {
name string
args []string
wantSub string
}{
{
name: "whitespace-only value",
args: []string{"+draft-send", "--draft-id", " ", "--dry-run"},
wantSub: "--draft-id contains empty value",
},
{
name: "exceeds cap",
args: []string{"+draft-send", "--draft-id", manyDraftIDs(MaxBatchSendDrafts + 1), "--dry-run"},
wantSub: "too many drafts",
},
{
name: "duplicate value",
args: []string{"+draft-send", "--draft-id", "d1,d2,d1", "--dry-run"},
wantSub: "--draft-id contains duplicate value: d1",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, c.args, f, stdout)
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), c.wantSub) {
t.Errorf("err = %v, want substring %q", err, c.wantSub)
}
if stdout.Len() != 0 {
t.Errorf("expected no dry-run output on validation error, got %s", stdout.String())
}
})
}
}
// manyDraftIDs returns a CSV string with n synthesised IDs. Used to drive the
// >MaxBatchSendDrafts validation branch without bloating the test file with a
// hand-written list.
func manyDraftIDs(n int) string {
parts := make([]string, n)
for i := range parts {
parts[i] = "d" + strings.Repeat("x", 1) + intToString(i)
}
return strings.Join(parts, ",")
}
// intToString avoids the strconv import noise for a tiny test helper.
func intToString(i int) string {
if i == 0 {
return "0"
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
return string(buf[pos:])
}
// TestMailDraftSend_MissingYes verifies the framework's high-risk-write
// confirmation gate triggers ExitConfirmationRequired (10) when --yes is
// omitted, before Execute is called.
func TestMailDraftSend_MissingYes(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1",
}, f, stdout)
if err == nil {
t.Fatal("expected ExitConfirmationRequired, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitConfirmationRequired {
t.Errorf("Code = %d, want ExitConfirmationRequired=%d", exitErr.Code, output.ExitConfirmationRequired)
}
}
// TestMailDraftSend_DryRun verifies --dry-run prints N POST calls in input
// order and does NOT touch the network.
func TestMailDraftSend_DryRun(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", " d1 , d2 ",
"--draft-id", " d3 ",
"--yes",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
s := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/drafts/d1/send`,
`/user_mailboxes/me/drafts/d2/send`,
`/user_mailboxes/me/drafts/d3/send`,
`"method"`,
`"POST"`,
} {
if !strings.Contains(s, want) {
t.Errorf("dry-run output missing %q; got %s", want, s)
}
}
}
// TestMailDraftSend_NormalizesDraftIDs verifies request paths and output use
// trimmed draft IDs rather than preserving CLI whitespace.
func TestMailDraftSend_NormalizesDraftIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_2"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", " d1 , d2 ",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if got := gjsonLikeString(t, data, "sent", 0, "draft_id"); got != "d1" {
t.Errorf("sent[0].draft_id = %q, want d1", got)
}
if got := gjsonLikeString(t, data, "sent", 1, "draft_id"); got != "d2" {
t.Errorf("sent[1].draft_id = %q, want d2", got)
}
}
// TestMailDraftSend_DryRunDirectInvocation drives dryRunDraftSend through a
// hand-built RuntimeContext so the dry-run plan can be inspected without the
// full Mount pipeline. Useful for catching path-encoding regressions in
// mailboxPath().
func TestMailDraftSend_DryRunDirectInvocation(t *testing.T) {
rt := runtimeForMailDraftSendTest(t, map[string]string{
"mailbox": "alice@example.com",
}, []string{"d1", "d2"})
api := dryRunDraftSend(context.Background(), rt)
raw, err := json.Marshal(api)
if err != nil {
t.Fatalf("marshal dry-run failed: %v", err)
}
s := string(raw)
for _, want := range []string{
`/user_mailboxes/alice@example.com/drafts/d1/send`,
`/user_mailboxes/alice@example.com/drafts/d2/send`,
`"method":"POST"`,
} {
if !strings.Contains(s, want) {
t.Errorf("dry-run JSON missing %q; got %s", want, s)
}
}
}
// runtimeForMailDraftSendTest builds a minimal RuntimeContext with the +draft-
// send flag set so the DryRun callback can be exercised directly. Mirrors
// runtimeForMailDeclineReceiptDryRun.
func runtimeForMailDraftSendTest(t *testing.T, strFlags map[string]string, draftIDs []string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("mailbox", "", "")
cmd.Flags().StringSlice("draft-id", nil, "")
cmd.Flags().Bool("stop-on-error", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("parse flags failed: %v", err)
}
for k, v := range strFlags {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set flag --%s failed: %v", k, err)
}
}
for _, id := range draftIDs {
if err := cmd.Flags().Set("draft-id", id); err != nil {
t.Fatalf("set draft-id failed: %v", err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// TestMailDraftSend_MailboxFallback verifies that omitting --mailbox falls
// through to "me" via resolveComposeMailboxID, and the output reflects it.
func TestMailDraftSend_MailboxFallback(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["mailbox_id"] != "me" {
t.Errorf("mailbox_id = %v, want me (default)", data["mailbox_id"])
}
}
// TestMailDraftSend_RepeatedFlagAndCSV verifies that string_slice supports
// both the repeated-flag form (--draft-id d1 --draft-id d2) and the
// comma-separated form (--draft-id d1,d2) — and mixing both in one invocation.
func TestMailDraftSend_RepeatedFlagAndCSV(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubDraftSend(reg, "d1", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_1"},
})
stubDraftSend(reg, "d2", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_2"},
})
stubDraftSend(reg, "d3", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"message_id": "msg_3"},
})
err := runMountedMailShortcut(t, MailDraftSend, []string{
"+draft-send",
"--draft-id", "d1,d2",
"--draft-id", "d3",
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["success_count"].(float64) != 3 {
t.Errorf("success_count = %v, want 3", data["success_count"])
}
}
// TestIsFatalSendErr is a focused unit test for the classifier. Covers every
// branch documented in the doc comment so future tweaks immediately surface
// mis-categorisation.
func TestIsFatalSendErr(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "nil-like / unknown shape → fatal",
err: errors.New("raw network panic surfaced unwrapped"),
want: true,
},
{
name: "ExitError without Detail → fatal",
err: &output.ExitError{Code: output.ExitInternal},
want: true,
},
{
name: "auth → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "auth", Message: "token expired"},
},
want: true,
},
{
name: "app_status → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "app_status", Message: "app disabled"},
},
want: true,
},
{
name: "config → fatal",
err: &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{Type: "config", Message: "bad app_id"},
},
want: true,
},
{
name: "permission → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "permission", Message: "denied"},
},
want: true,
},
{
name: "rate_limit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "rate_limit", Code: output.LarkErrRateLimit},
},
want: true,
},
{
name: "ExitNetwork → fatal",
err: &output.ExitError{
Code: output.ExitNetwork,
Detail: &output.ErrDetail{Type: "network", Message: "DNS timeout"},
},
want: true,
},
{
name: "wrapped ExitNetwork → fatal",
err: output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", output.ErrNetwork("DNS timeout")),
want: true,
},
{
name: "LarkErrMailboxNotFound → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailboxNotFound},
},
want: true,
},
{
name: "LarkErrMailSendQuotaUser → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrMailSendQuotaUser},
},
want: true,
},
{
name: "LarkErrTenantStorageLimit → fatal",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: output.LarkErrTenantStorageLimit},
},
want: true,
},
{
name: "generic api_error → recoverable",
err: &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 230001},
},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := isFatalSendErr(c.err)
if got != c.want {
t.Errorf("isFatalSendErr(%s) = %v, want %v", c.name, got, c.want)
}
})
}
}
// TestExtractAutomationDisabledReason verifies all branches of the helper:
// missing key → "", malformed map → generic message, empty/whitespace reason
// → generic message, non-empty reason → trimmed value.
func TestExtractAutomationDisabledReason(t *testing.T) {
cases := []struct {
name string
in map[string]interface{}
want string
}{
{"missing key", map[string]interface{}{"message_id": "x"}, ""},
{"non-map value", map[string]interface{}{
"automation_send_disable": "not a map",
}, "automation send disabled (no reason provided)"},
{"map but no reason", map[string]interface{}{
"automation_send_disable": map[string]interface{}{},
}, "automation send disabled (no reason provided)"},
{"reason empty", map[string]interface{}{
"automation_send_disable": map[string]interface{}{"reason": " "},
}, "automation send disabled (no reason provided)"},
{"reason populated", map[string]interface{}{
"automation_send_disable": map[string]interface{}{"reason": " policy block "},
}, "policy block"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractAutomationDisabledReason(c.in)
if got != c.want {
t.Errorf("extractAutomationDisabledReason() = %q, want %q", got, c.want)
}
})
}
}
func gjsonLikeString(t *testing.T, data map[string]interface{}, arrayKey string, index int, field string) string {
t.Helper()
items, ok := data[arrayKey].([]interface{})
if !ok {
t.Fatalf("%s missing or wrong type: %#v", arrayKey, data[arrayKey])
}
if index >= len(items) {
t.Fatalf("%s[%d] missing; len=%d", arrayKey, index, len(items))
}
item, ok := items[index].(map[string]interface{})
if !ok {
t.Fatalf("%s[%d] wrong type: %#v", arrayKey, index, items[index])
}
value, ok := item[field].(string)
if !ok {
t.Fatalf("%s[%d].%s missing or wrong type: %#v", arrayKey, index, field, item[field])
}
return value
}

View File

@@ -26,12 +26,10 @@ 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. Mutually exclusive with --body-file."},
bodyFileFlag,
{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: "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"},
@@ -46,8 +44,7 @@ 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,
showLintDetailsFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
@@ -75,11 +72,6 @@ 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
}
@@ -110,10 +102,7 @@ var MailForward = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
to := runtime.Str("to")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
body := runtime.Str("body")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
plainText := runtime.Bool("plain-text")
@@ -253,8 +242,6 @@ 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)
@@ -280,13 +267,6 @@ 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)
@@ -499,12 +479,8 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -512,10 +488,7 @@ var MailForward = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -1,170 +0,0 @@
// 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))
}

View File

@@ -1,274 +0,0 @@
// 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)
}
}

View File

@@ -1,131 +0,0 @@
// 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
}

View File

@@ -1,719 +0,0 @@
// 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))
}
}

View File

@@ -8,6 +8,7 @@ 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"
@@ -23,11 +24,9 @@ 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. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{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: "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)"},
@@ -43,8 +42,7 @@ 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,
showLintDetailsFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -72,17 +70,8 @@ var MailReply = 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
}
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 !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
}
if err := validateConfirmSendScope(runtime); err != nil {
return err
@@ -106,10 +95,7 @@ var MailReply = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
body := runtime.Str("body")
toFlag := runtime.Str("to")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
@@ -258,10 +244,6 @@ 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)
@@ -279,15 +261,6 @@ 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)
@@ -343,12 +316,8 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -356,10 +325,7 @@ var MailReply = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send reply (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -8,6 +8,7 @@ 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"
@@ -23,11 +24,9 @@ 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. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{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: "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)"},
@@ -44,8 +43,7 @@ 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,
showLintDetailsFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
messageId := runtime.Str("message-id")
confirmSend := runtime.Bool("confirm-send")
@@ -73,17 +71,8 @@ var MailReplyAll = 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
}
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 !hasTemplate && strings.TrimSpace(runtime.Str("body")) == "" {
return output.ErrValidation("--body is required; pass the reply body (or use --template-id)")
}
if err := validateConfirmSendScope(runtime); err != nil {
return err
@@ -107,10 +96,7 @@ var MailReplyAll = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
messageId := runtime.Str("message-id")
body, bErr := resolveBodyFromFlags(runtime)
if bErr != nil {
return bErr
}
body := runtime.Str("body")
toFlag := runtime.Str("to")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
@@ -267,8 +253,6 @@ 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)
@@ -286,13 +270,6 @@ 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)
@@ -348,12 +325,8 @@ var MailReplyAll = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -361,10 +334,7 @@ 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)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
hintMarkAsRead(runtime, mailboxID, messageId)
return nil
},

View File

@@ -23,12 +23,10 @@ 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. Mutually exclusive with --body-file. Required unless --template-id supplies a non-empty body."},
bodyFileFlag,
{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: "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"},
@@ -42,8 +40,7 @@ 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,
showLintDetailsFlag},
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
to := runtime.Str("to")
subject := runtime.Str("subject")
@@ -77,14 +74,12 @@ 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).
@@ -102,19 +97,7 @@ var MailSend = common.Shortcut{
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
return err
}
// 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 {
if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -125,10 +108,7 @@ var MailSend = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
to := runtime.Str("to")
subject := runtime.Str("subject")
body, err := resolveBodyFromFlags(runtime)
if err != nil {
return err
}
body := runtime.Str("body")
ccFlag := runtime.Str("cc")
bccFlag := runtime.Str("bcc")
plainText := runtime.Bool("plain-text")
@@ -226,10 +206,6 @@ 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))
@@ -244,14 +220,6 @@ 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)
@@ -315,12 +283,8 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to create draft: %w", err)
}
showLintDetails := runtime.Bool("show-lint-details")
if !confirmSend {
out := buildDraftSavedOutput(draftResult, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSavedOutput(draftResult, mailboxID), nil)
hintSendDraft(runtime, mailboxID, draftResult.DraftID)
return nil
}
@@ -328,10 +292,7 @@ var MailSend = common.Shortcut{
if err != nil {
return fmt.Errorf("failed to send email (draft %s created but not sent): %w", draftResult.DraftID, err)
}
out := buildDraftSendOutput(resData, mailboxID)
applyLintToEnvelope(out, lintApplied, lintBlocked, showLintDetails)
addComposeHint(out)
runtime.Out(out, nil)
runtime.Out(buildDraftSendOutput(resData, mailboxID), nil)
return nil
},
}

View File

@@ -4,13 +4,11 @@
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
@@ -51,57 +49,6 @@ 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)

View File

@@ -8,7 +8,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/signature"
@@ -154,14 +154,26 @@ func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetS
}
// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us").
// resolveLang maps the preference to a locale the mail API accepts (it supports
// only zh_cn / en_us / ja_jp; anything else falls back to zh_cn).
func resolveLang(runtime *common.RuntimeContext) string {
switch runtime.Lang() {
case i18n.LangEnUS, i18n.LangJaJP:
return string(runtime.Lang())
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "zh_cn"
}
cfg, err := runtime.Factory.Config()
if err != nil {
return "zh_cn"
}
app := multi.FindApp(cfg.ProfileName)
if app == nil {
return "zh_cn"
}
switch app.Lang {
case "en":
return "en_us"
case "ja":
return "ja_jp"
default:
return string(i18n.LangZhCN)
return "zh_cn"
}
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/shortcuts/common"
)
func TestResolveLang(t *testing.T) {
tests := []struct {
name string
stored i18n.Lang
want string
}{
{"english", i18n.LangEnUS, "en_us"},
{"japanese", i18n.LangJaJP, "ja_jp"},
{"chinese", i18n.LangZhCN, "zh_cn"},
{"legacy short en", "en", "en_us"},
{"legacy fr_fr falls back to zh_cn", i18n.Lang("fr_fr"), "zh_cn"},
{"unset falls back to zh_cn", "", "zh_cn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rt := &common.RuntimeContext{Config: &core.CliConfig{Lang: tt.stored}}
if got := resolveLang(rt); got != tt.want {
t.Errorf("resolveLang(stored=%q) = %q, want %q", tt.stored, got, tt.want)
}
})
}
}

View File

@@ -17,7 +17,6 @@ func Shortcuts() []common.Shortcut {
MailReplyAll,
MailSend,
MailDraftCreate,
MailDraftSend,
MailDraftEdit,
MailForward,
MailSendReceipt,
@@ -26,6 +25,5 @@ func Shortcuts() []common.Shortcut {
MailShareToChat,
MailTemplateCreate,
MailTemplateUpdate,
MailLintHTML,
}
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -264,7 +265,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color", Required: true},
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
},
@@ -272,7 +273,7 @@ var SheetCreateFilterViewCondition = common.Shortcut{
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
return validateExpectedFlag(runtime.Str("expected"))
return validateFilterViewConditionFlags(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
@@ -306,7 +307,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
{Name: "filter-type", Desc: "filter type: multiValue, number, text, color"},
{Name: "compare-type", Desc: "comparison operator"},
{Name: "expected", Desc: "filter values JSON array"},
},
@@ -319,10 +320,7 @@ var SheetUpdateFilterViewCondition = common.Shortcut{
!hasNonEmptyStringFlag(runtime, "expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
}
return nil
return validateFilterViewConditionFlags(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
@@ -469,6 +467,55 @@ func validateExpectedFlag(s string) error {
return nil
}
func validateFilterViewConditionFlags(runtime *common.RuntimeContext) error {
filterType := strings.TrimSpace(runtime.Str("filter-type"))
if filterType != "" {
switch filterType {
case "multiValue", "number", "text", "color":
case "hiddenValue":
return output.ErrValidation("--filter-type hiddenValue is no longer supported by Lark Sheets filter view conditions; use --filter-type multiValue with --expected values to show, and omit --compare-type")
default:
return output.ErrValidation("--filter-type must be one of multiValue, number, text, color; got %q", filterType)
}
}
expected := runtime.Str("expected")
if filterType == "multiValue" {
if strings.TrimSpace(runtime.Str("compare-type")) != "" {
return output.ErrValidation("--compare-type must be omitted when --filter-type multiValue")
}
values, err := parseExpectedStringArray(expected)
if err != nil {
return err
}
if len(values) == 0 {
return output.ErrValidation("--expected must contain at least one value when --filter-type multiValue")
}
for i, value := range values {
if utf8.RuneCountInString(value) > 50000 {
return output.ErrValidation("--expected[%d] must be 50000 characters or fewer when --filter-type multiValue", i)
}
}
return nil
}
if expected != "" {
return validateExpectedFlag(expected)
}
return nil
}
func parseExpectedStringArray(s string) ([]string, error) {
if strings.TrimSpace(s) == "" {
return nil, output.ErrValidation("--expected is required when --filter-type multiValue")
}
var values []string
if err := json.Unmarshal([]byte(s), &values); err != nil {
return nil, output.ErrValidation("--expected must be a JSON string array when --filter-type multiValue (e.g. [\"A\",\"B\"]), got: %s", s)
}
return values, nil
}
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {

View File

@@ -334,6 +334,104 @@ func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
}
}
func TestCreateFilterViewConditionMultiValueExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "C", "filter_type": "multiValue"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "multiValue",
"--expected", `["A","B"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["filter_type"] != "multiValue" {
t.Fatalf("unexpected filter_type: %v", body["filter_type"])
}
if _, ok := body["compare_type"]; ok {
t.Fatalf("multiValue body must omit compare_type: %v", body)
}
expected, ok := body["expected"].([]interface{})
if !ok || len(expected) != 2 || expected[0] != "A" || expected[1] != "B" {
t.Fatalf("unexpected expected values: %#v", body["expected"])
}
}
func TestCreateFilterViewConditionRejectsHiddenValue(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "hiddenValue",
"--expected", `["A"]`, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected hiddenValue validation error, got nil")
}
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
t.Fatalf("unexpected error message: %v", err)
}
}
func TestCreateFilterViewConditionRejectsInvalidMultiValueFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantText string
}{
{
name: "compare type",
args: []string{"--compare-type", "less", "--expected", `["A"]`},
wantText: "--compare-type must be omitted",
},
{
name: "empty expected",
args: []string{"--expected", `[]`},
wantText: "at least one value",
},
{
name: "non-string expected",
args: []string{"--expected", `[1]`},
wantText: "JSON string array",
},
{
name: "too long expected",
args: []string{"--expected", `["` + strings.Repeat("x", 50001) + `"]`},
wantText: "50000 characters",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
args := []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "C", "--filter-type", "multiValue",
}
args = append(args, tc.args...)
args = append(args, "--as", "user")
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, args, f, stdout)
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tc.name)
}
if !strings.Contains(err.Error(), tc.wantText) {
t.Fatalf("unexpected error message: %v", err)
}
})
}
}
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
@@ -384,6 +482,21 @@ func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
}
}
func TestUpdateFilterViewConditionRejectsHiddenValue(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "C",
"--filter-type", "hiddenValue", "--expected", `["A"]`, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected hiddenValue validation error, got nil")
}
if !strings.Contains(err.Error(), "--filter-type hiddenValue is no longer supported") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViewConditions ─────────────────────────────────────────────────
func TestListFilterViewConditionsDryRun(t *testing.T) {

View File

@@ -33,10 +33,6 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -322,57 +322,6 @@ 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 消耗。

View File

@@ -47,10 +47,6 @@ When using bot identity (`--as bot`) to fetch messages (e.g. `+chat-messages-lis
**Solution**: Check the app's visibility settings in the Lark Developer Console — ensure the app's visible range covers the users whose names need to be resolved. Alternatively, use `--as user` to fetch messages with user identity, which typically has broader contact access.
### Default message enrichment (reactions / update_time)
The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md).
### Card Messages (Interactive)
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.

View File

@@ -4,8 +4,6 @@
Fetch the message list for a conversation. Supports both group chats and direct messages.
By default the response carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Thread replies expanded via auto-`thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +chat-messages-list` (internally calls `GET /open-apis/im/v1/messages`, and automatically resolves the p2p chat_id when needed).
## Commands

View File

@@ -1,28 +0,0 @@
# im default message enrichment (reactions / update_time)
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
This is the single source of truth for the automatic message-enrichment contract shared by the four message-pulling shortcuts — [`+messages-mget`](lark-im-messages-mget.md), [`+chat-messages-list`](lark-im-chat-messages-list.md), [`+messages-search`](lark-im-messages-search.md), [`+threads-messages-list`](lark-im-threads-messages-list.md). They automatically attach `reactions` and `update_time` to each returned message, so callers do **not** need to invoke the raw [`im.reactions.batch_query`](lark-im-reactions.md) API separately.
- **`reactions`** — populated from one batched `im.reactions.batch_query` call as `{counts, details}`. The field is only attached when the server actually returns data; messages with no reactions omit it. Replies inside `thread_replies` are enriched in the **same batched call** as their parent, so outer and inner messages follow identical semantics.
- **`update_time`** — emitted only when `updated == true` (message was actually edited). The server echoes `update_time == create_time` for unedited messages too, but the CLI gates that output away so consumers don't misread every message as "edited".
- **Opt-out** — each shortcut accepts `--no-reactions` to skip the extra round-trip when the caller only needs message bodies.
## Scope requirement
The default enrichment requires `im:message.reactions:read`, already declared in each shortcut's `UserScopes` / `BotScopes` (or `Scopes` for the user-only search command), so the framework's pre-flight check surfaces a `missing_scope` error before the request is sent. Bots that were registered before this scope was added need an incremental authorization in the Feishu developer console; users can run:
```bash
lark-cli auth login --scope "im:message.reactions:read"
```
## Data contract — missing field ≠ fetch failure
| Situation | Output |
|---|---|
| Message has no reactions | `reactions` field is omitted (not `{}`, not an empty list) |
| Message was never edited | `update_time` field is omitted |
| Whole batch failed | Messages in that batch carry no `reactions`; one line on stderr: `warning: reactions_batch_query_failed: ...` |
| Some message IDs failed | Failed IDs go to stderr: `warning: reactions_partial_failed: N message(s) failed (...)` |
When deciding "has the user already reacted?", branch on the **presence of the `reactions` field plus its `counts` contents**, not on whether a value is `null` — the field's absence means "no data attached" (which usually means "no reactions exist"), not "fetch failed".

View File

@@ -4,8 +4,6 @@
Fetch message details in batch. Given a list of message IDs, this returns the full content for multiple messages in one call and automatically resolves sender names.
By default the response also carries a `reactions` block (counts + details from `im.reactions.batch_query`) on every message that has reactions, and `update_time` on messages that were actually edited. Replies inside `thread_replies` participate in the same batched enrichment. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **Supports both `--as user` (default) and `--as bot`.**
This skill maps to the shortcut: `lark-cli im +messages-mget` (internally calls `GET /open-apis/im/v1/messages/mget`).

View File

@@ -4,8 +4,6 @@
Search Feishu messages across conversations. This shortcut automatically performs a multi-step workflow: search for message IDs, batch fetch message details, then enrich the results with chat context.
By default each result message also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. With `--page-all`, every page is enriched; pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
> **User identity only** (`--as user`). Bot identity is not supported.
This skill maps to the shortcut: `lark-cli im +messages-search` (internally calls `POST /open-apis/im/v1/messages/search` + batched `GET /open-apis/im/v1/messages/mget`, then batch-fetches chat context).

View File

@@ -2,8 +2,6 @@
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
> **Heads-up — don't reach for `batch_query` by default.** The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) already call `im.reactions.batch_query` automatically and attach the result as a `reactions` block on each message (replies inside `thread_replies` included). Use those shortcuts for any "read reactions of messages I'm already pulling" task. Reach for the raw `batch_query` API only when you have a standalone `message_id` outside that pull flow. See the main [message enrichment](lark-im-message-enrichment.md) for the contract.
This reference is the shared annotation target for the IM reaction APIs:
- `im.reactions.create`

View File

@@ -4,8 +4,6 @@
Fetch the reply message list inside a thread. When `im +chat-messages-list` returns messages that include a `thread_id` field, use this command to inspect all replies in that thread.
By default each reply also carries a `reactions` block (counts + details from `im.reactions.batch_query`) when the server has reactions for it, and `update_time` for messages that were actually edited. Pass `--no-reactions` to skip the extra round-trip. See [message enrichment](lark-im-message-enrichment.md) for the full contract.
This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internally calls `GET /open-apis/im/v1/messages` with `container_id_type=thread` to fetch thread messages).
## Commands

View File

@@ -12,8 +12,6 @@ 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`
@@ -101,10 +99,9 @@ metadata:
4. **回复**`+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发**`+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件**`+send` 存草稿(默认),加 `--confirm-send` 发送
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. **已读回执**
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
8. **编辑草稿**`+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
9. **已读回执**
- **请求回执(写信侧)**`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
- **响应回执(拉信侧)**:拉信看到 `label_ids``READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
@@ -339,12 +336,6 @@ 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 消耗。
@@ -363,8 +354,6 @@ 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。
@@ -483,7 +472,6 @@ 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

View File

@@ -1,33 +0,0 @@
<!--
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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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>

View File

@@ -1,50 +0,0 @@
<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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(36,91,219)">&nbsp; 行业动态 &nbsp;</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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,Arial,sans-serif"><span style="color:rgb(81,86,93)">[摘要 12-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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(0,180,42)">&nbsp; 技术前沿 &nbsp;</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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,Arial,sans-serif"><span style="color:rgb(255,255,255)"><span style="background-color:rgb(124,77,255)">&nbsp; 内部动态 &nbsp;</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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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,&quot;PingFang SC&quot;,&quot;Hiragino Sans GB&quot;,&quot;Microsoft YaHei&quot;,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>

View File

@@ -1,256 +0,0 @@
<!--
=============================================================================
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>

View File

@@ -1,43 +0,0 @@
<!--
=============================================================================
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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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,&quot;Helvetica Neue&quot;,Tahoma,&quot;PingFang SC&quot;,&quot;Microsoft Yahei&quot;,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

View File

@@ -8,8 +8,6 @@
如需修改已有草稿,不要使用此命令,请使用 `lark-cli mail +draft-edit`
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
## 安全约束
此命令创建草稿——**不会**发送邮件。用户可以在飞书邮件 UI 中打开草稿查看详情,确认后再进入后续操作。因此:
@@ -46,8 +44,7 @@ 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-file` 互斥 |
| `--body-file <path>` | 二选一 | 从文件读取邮件正文 HTML相对路径仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
| `--body <text>` | | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。省略时使用邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |

View File

@@ -10,13 +10,11 @@
- `--set-cc`
- `--set-bcc`
**正文整体替换的快捷方式:** `--body <text>` / `--body-file <path>`(二选一互斥)会自动展开为 `set_body` op。如果只想做整段正文替换且不需要保留引用区用这两个 flag 即可,无需写 patch-file。要保留引用区或做更精细的 op 组合,仍走 `--patch-file`。两个入口与 `--patch-file` 内的 `set_body` / `set_reply_body` 互斥
**正文编辑和其他高级操作必须通过 `--patch-file`**。没有 `--set-body` flag
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
### 正文编辑:两个 op 的选择
## 正文编辑:快捷 flag 与 typed op 的选择
整段替换正文且不需要保留引用区时,可直接使用 `--body` / `--body-file`。需要保留引用区、修改引用区或组合高级正文编辑时,通过 `--patch-file` 传入 typed body op有两个 op 可选:
正文编辑通过 `--patch-file` 传入,有两个 op 可选:
| 情况 | op | 行为 |
|------|-----|------|
@@ -51,10 +49,7 @@
# 编辑草稿元数据(主题、收件人)
lark-cli mail +draft-edit --draft-id <draft-id> --set-subject '更新后的主题' --set-to alice@example.com,bob@example.com
# 快速完整替换正文
lark-cli mail +draft-edit --draft-id <draft-id> --body '<p>更新后的正文</p>'
# 高级正文编辑(如保留回复/转发引用区)
# 编辑草稿正文(必须通过 patch-file
lark-cli mail +draft-edit --draft-id <draft-id> --patch-file ./patch.json
# 查看草稿(只读)— 返回包含 has_quoted_content、attachments_summary 和 inline_summary 的投影
@@ -77,15 +72,13 @@ 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>` | 否 | typed body op`set_body` / `set_reply_body`、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--print-patch-template` 查看 JSON 结构 |
| `--patch-file <path>` | 否 | 所有正文编辑、增量收件人编辑、邮件头编辑、附件变更和内嵌图片变更的入口。相对路径。先运行 `--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` 编辑组合,也可以单独使用 |
@@ -258,8 +251,8 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
- `ops` 按顺序执行
- `target` 接受 `part_id``cid`;优先级:`part_id` > `cid`
- **所有文件路径(`--body-file``--patch-file` 及 ops 中的 `path`)必须为相对路径**
- **快速完整正文替换可用 `--body` / `--body-file`;高级正文编辑使用 `--patch-file`**
- **所有文件路径(`--patch-file` 及 ops 中的 `path`)必须为相对路径**
- **正文编辑没有 flag必须通过 `--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` 定位)
@@ -294,8 +287,11 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
# 1. 查看草稿当前状态
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
# 2. 编辑草稿(元数据和快速正文替换
lark-cli mail +draft-edit --draft-id <draft_id> --set-subject '最终版本' --body '<p>更新后的内容</p>'
# 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
# 3. 发送草稿
lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}'

View File

@@ -13,8 +13,6 @@
## CRITICAL — 发送工作流(必须遵循)
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
此命令默认**只保存草稿**,不会发送邮件。转发会将原邮件内容发送给新收件人,需要发送时有两种合规方式:
**方式 A推荐** — 创建转发草稿(不带 `--confirm-send`
@@ -62,8 +60,7 @@ 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-file` 互斥 |
| `--body-file <path>` | 否 | 从文件读取转发说明 HTML相对路径仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |

View File

@@ -1,333 +0,0 @@
# 邮件 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)

View File

@@ -1,243 +0,0 @@
# 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` | 修复后的 HTMLautofix 始终启用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)

View File

@@ -13,8 +13,6 @@
## CRITICAL — 发送工作流(必须遵循)
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
此命令默认**只保存草稿**,不会发送邮件。回复全部会发送给**所有**原始收件人,需要发送时有两种合规方式:
**方式 A推荐** — 创建回复全部草稿(不带 `--confirm-send`
@@ -64,8 +62,7 @@ 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-file` 互斥 |
| `--body-file <path>` | 二选一 | 从文件读取回复正文 HTML相对路径仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
| `--body <text>` | | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) |

View File

@@ -17,8 +17,6 @@
## CRITICAL — 发送工作流(必须遵循)
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
此命令默认**只保存草稿**,不会发送邮件。需要发送时,有两种合规方式:
**方式 A推荐** — 创建回复草稿(不带 `--confirm-send`
@@ -68,8 +66,7 @@ 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-file` 互斥 |
| `--body-file <path>` | 二选一 | 从文件读取回复正文 HTML相对路径仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
| `--body <text>` | | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) |

View File

@@ -12,8 +12,6 @@
## CRITICAL — 发送工作流(必须遵循)
**CRITICAL - 编辑邮件内容前 MUST 先用 Read 工具读取 [references/lark-mail-html.md](references/lark-mail-html.md),其中包含邮件书写规范**
此命令默认**只保存草稿**,不会发送邮件。需要发送时,有两种合规方式:
**方式 A推荐** — 先创建草稿,再确认发送:
@@ -69,8 +67,7 @@ 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-file` 互斥 |
| `--body-file <path>` | 二选一 | 从文件读取邮件正文 HTML相对路径仅限 cwd 子树)。与 `--body` 互斥。文件大小上限 32 MB |
| `--body <text>` | | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址EML From 头。使用别名send_as发信时设为别名地址并配合 `--mailbox` 指定所属邮箱。默认读取邮箱主地址 |
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |

View File

@@ -123,6 +123,11 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
# 多值筛选:只展示 Grade 为 A 或 B 的行multiValue 不传 compare-type
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
--condition-id "C" --filter-type "multiValue" --expected '["A","B"]'
```
参数:
@@ -134,7 +139,7 @@ lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx"
| `--sheet-id` | 是 | 工作表 ID |
| `--filter-view-id` | 是 | 筛选视图 ID |
| `--condition-id` | 是 | 列字母,如 `E` |
| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` |
| `--filter-type` | 是 | `multiValue` / `number` / `text` / `color` |
| `--compare-type` | 否 | 比较运算符 |
| `--expected` | 是 | 筛选值 JSON 数组 |

View File

@@ -44,7 +44,7 @@ lark-cli task +create --summary "Test Task" --dry-run
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting the `userOpenId` or `open_id`, and then passing it to the `--assignee` parameter.
2. Execute `lark-cli task +create --summary "..." ...`
3. Report the result: task ID and summary.

View File

@@ -1,13 +1,12 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 63 leaf commands
- Covered: 14
- Coverage: 22.2%
- Denominator: 62 leaf commands
- Covered: 13
- Coverage: 21.0%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`.
- TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`.
- Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-alls recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows.
@@ -17,7 +16,6 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients |
| ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update |
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestMail_DraftSendDryRun(t *testing.T) {
setMailDraftSendDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", "alias@example.com",
"--draft-id", " draft_001, draft_002 ",
"--draft-id", " draft_003 ",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
wantURLs := []string{
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send",
}
assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
for i, wantURL := range wantURLs {
idx := strconv.Itoa(i)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout)
assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout)
}
}
func TestMail_DraftSendDryRunValidation(t *testing.T) {
setMailDraftSendDryRunEnv(t)
tests := []struct {
name string
args []string
wantMsg string
}{
{
name: "reject whitespace draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", " ",
"--dry-run",
},
wantMsg: "--draft-id contains empty value",
},
{
name: "reject too many draft ids",
args: []string{
"mail", "+draft-send",
"--draft-id", manyDraftIDsForE2E(51),
"--dry-run",
},
wantMsg: "too many drafts",
},
{
name: "reject duplicate draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", "draft_001,draft_002,draft_001",
"--dry-run",
},
wantMsg: "--draft-id contains duplicate value: draft_001",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + result.Stderr
assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
func setMailDraftSendDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func manyDraftIDsForE2E(n int) string {
ids := make([]byte, 0, n*4)
for i := 0; i < n; i++ {
if i > 0 {
ids = append(ids, ',')
}
ids = append(ids, 'd')
ids = strconv.AppendInt(ids, int64(i), 10)
}
return string(ids)
}

View File

@@ -1,166 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestMail_DraftSendWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
const mailboxID = "me"
suffix := clie2e.GenerateSuffix()
subject := "lark-cli-e2e-mail-draft-send-" + suffix
body := "draft-send workflow body " + suffix
var primaryEmail string
var draftID string
var draftSent bool
var sentMessageID string
var inboxMessageID string
parentT.Cleanup(func() {
if draftID != "" && !draftSent {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
}
var messageIDs []string
if sentMessageID != "" {
messageIDs = append(messageIDs, sentMessageID)
}
if inboxMessageID != "" && inboxMessageID != sentMessageID {
messageIDs = append(messageIDs, inboxMessageID)
}
if len(messageIDs) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
Data: map[string]any{"message_ids": messageIDs},
})
clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err)
})
t.Run("get mailbox profile as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailboxes", "profile"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
})
t.Run("create self-addressed draft as user", func(t *testing.T) {
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-create",
"--to", primaryEmail,
"--subject", subject,
"--body", body,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
})
t.Run("send draft with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before +draft-send")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", mailboxID,
"--draft-id", draftID,
},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout)
sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String()
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
draftSent = true
})
t.Run("find self-received message for cleanup", func(t *testing.T) {
require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup")
for attempt := 0; attempt < 12; attempt++ {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+triage",
"--mailbox", mailboxID,
"--query", subject,
"--max", "10",
"--format", "data",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, item := range gjson.Get(result.Stdout, "messages").Array() {
if item.Get("subject").String() != subject {
continue
}
messageID := item.Get("message_id").String()
if messageID != "" && messageID != sentMessageID {
inboxMessageID = messageID
return
}
}
time.Sleep(2 * time.Second)
}
})
}