Compare commits

...

10 Commits

Author SHA1 Message Date
fangshuyu
78283cefbb docs: clarify doc block insert ordering 2026-06-16 11:31:18 +08:00
liangshuo-1
297b2a222e chore(release): v1.0.54 (#1476) 2026-06-15 21:58:07 +08:00
Zhang-986
80a5f30f4d fix(event): clarify remote bus blocker recovery (#1454) 2026-06-15 20:27:59 +08:00
xzcong0820
cf35d1e499 feat(mail): auto-attach default signature on send/reply/forward (#1415)
* feat(mail): auto-attach default signature on send/reply/forward

- Add exported PlainTextFromHTML wrapper in draft/htmltext.go
- Add DefaultSendID/DefaultReplyID in signature/provider.go
- Add noSignatureFlag, autoResolveSignatureID, validateNoSignatureConflict,
  injectPlainTextSignature in signature_compose.go; remove validateSignatureWithPlainText
- mail_send, mail_draft_create: add --no-signature flag, auto-resolve default
  signature when no --signature-id given, inject plain-text sig in plain-text branch
- mail_reply, mail_reply_all, mail_forward: same flag/validate changes + timing fix
  (resolveSignature moved to after senderEmail is finalized)
- Update 5 reference docs: add --no-signature row, update --plain-text and
  --signature-id descriptions

---------

Co-authored-by: xzcong0820 <278082089+xzcong0820@users.noreply.github.com>
2026-06-15 20:04:05 +08:00
fangshuyu-768
fd16cf106b clarify lark-doc create title guidance (#1474) 2026-06-15 19:38:56 +08:00
1uckypeach
53076733ec docs(skills): add rename prompt for import without --name (#1461)
When --name is omitted, remind user that the title defaults to the source
filename and may duplicate content headings, causing visual redundancy.
Ask whether to rename before executing the import.
2026-06-15 19:30:51 +08:00
陈家名
a3bee13ca9 fix(vfs): reject blank local paths (#1460) 2026-06-15 19:14:31 +08:00
fangshuyu-768
6217bd2c29 fix docs fetch and update ergonomics (#1466) 2026-06-15 17:47:34 +08:00
search_zhuhao
72c294712c feat: 【larksuite/cli】【drive 搜索支持 original_creator_ids】 M-7074213537 (#1046)
sa: none

fg: none

cfg: none

doc: none

test: ppe
Change-Id: I88bedd02a5daa3307b05c9b6f94748e1544d279a
2026-06-15 14:18:45 +08:00
sammi-bytedance
37f4f899b2 docs(lark-im): document @mention format per message type (text/post/card) (#1419)
Split the send/reply @Mention sections by message type:
- text: <at user_id="ou_xxx">name</at> (inner name optional), @all
- post: inline form in text/md elements, or a dedicated {"tag":"at"} node
- interactive card: card-native <at id=>, <at ids=>, <at email=>
2026-06-15 14:08:50 +08:00
41 changed files with 1044 additions and 290 deletions

View File

@@ -2,6 +2,32 @@
All notable changes to this project will be documented in this file.
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
@@ -1149,6 +1175,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51

View File

@@ -53,8 +53,8 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
if count > 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
"another event bus is already connected to this app (%d remote event connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
WithHint("remote event connection detected; `lark-cli event status` and `lark-cli event stop` only inspect local buses; stop the owner host/process, wait for the platform connection timeout, or use a separate app/profile")
}
}
} else {

View File

@@ -50,8 +50,16 @@ func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Hint, "event stop") {
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
wantHints := []string{
"remote event connection",
"`lark-cli event status` and `lark-cli event stop` only inspect local buses",
"stop the owner host/process",
"wait for the platform connection timeout",
}
for _, want := range wantHints {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint missing %q\ngot: %q", want, ve.Hint)
}
}
}

View File

@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: empty or blank paths → THEN: rejected ──
{"empty path", "", true},
{"blank path", " ", true},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},

View File

@@ -60,6 +60,10 @@ func safePath(raw, flagName string) (string, error) {
return "", err
}
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("%s must not be empty", flagName)
}
if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}

View File

@@ -26,6 +26,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: empty or blank paths → THEN: rejected ──
{"empty path", "", true},
{"blank path", " ", true},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},

View File

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

View File

@@ -38,9 +38,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
if err := validateFetchDetail(runtime); err != nil {
return err
}
if err := validateReadModeFlags(runtime); err != nil {
@@ -71,6 +68,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
@@ -90,7 +90,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body["revision_id"] = v
}
detail := runtime.Str("detail")
detail := effectiveFetchDetail(runtime)
switch detail {
case "", "simple":
body["export_option"] = map[string]interface{}{
@@ -146,17 +146,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} {
return ro
}
// validateFetchDetail 非 xml 格式markdown不承载 block_id 与样式属性,拒绝 with-ids/full。
func validateFetchDetail(runtime *common.RuntimeContext) error {
// effectiveFetchDetail degrades detail options that cannot be represented by
// non-XML exports. The original flag value is left intact so callers can still
// surface an explicit warning in execute output.
func effectiveFetchDetail(runtime *common.RuntimeContext) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return nil
return detail
}
if detail == "with-ids" || detail == "full" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail")
return "simple"
}
return nil
return detail
}
func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
detail := strings.TrimSpace(runtime.Str("detail"))
if format == "" || format == "xml" {
return ""
}
if detail != "with-ids" && detail != "full" {
return ""
}
warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format)
appendDocWarning(data, warning)
return warning
}
// validateReadModeFlags 客户端前置校验,服务端也会再校验一次。

View File

@@ -5,9 +5,12 @@ package doc
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -96,6 +99,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
for _, detail := range []string{"with-ids", "full"} {
t.Run(detail, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "markdown",
"detail": detail,
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
})
}
}
func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchWarning",
"revision_id": float64(1),
"content": "# hello",
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchWarning",
"--doc-format", "markdown",
"--detail", "with-ids",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
warnings, _ := data["warnings"].([]interface{})
if len(warnings) != 1 {
t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"])
}
if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") {
t.Fatalf("unexpected warning: %q", got)
}
}
func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchPrettyWarning",
"revision_id": float64(1),
"content": "# hello",
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchPrettyWarning",
"--doc-format", "markdown",
"--detail", "full",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != "# hello\n" {
t.Fatalf("stdout = %q, want markdown content only", got)
}
if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") ||
!strings.Contains(got, "returning markdown output") ||
!strings.Contains(got, "ignoring the unsupported detail option") {
t.Fatalf("stderr missing downgrade warning: %q", got)
}
}
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string

View File

@@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) {
}
return string(extra), nil
}
func appendDocWarning(data map[string]interface{}, warning string) {
if data == nil {
return
}
if strings.TrimSpace(warning) == "" {
return
}
switch existing := data["warnings"].(type) {
case []interface{}:
data["warnings"] = append(existing, warning)
case []string:
data["warnings"] = append(existing, warning)
case nil:
data["warnings"] = []string{warning}
default:
data["warnings"] = []interface{}{existing, warning}
}
}

View File

@@ -4,6 +4,7 @@
package doc
import (
"reflect"
"strings"
"testing"
)
@@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) {
t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want)
}
}
func TestAppendDocWarning(t *testing.T) {
t.Parallel()
appendDocWarning(nil, "ignored")
empty := map[string]interface{}{}
appendDocWarning(empty, " ")
if _, ok := empty["warnings"]; ok {
t.Fatalf("blank warning should be ignored: %#v", empty)
}
tests := []struct {
name string
data map[string]interface{}
want interface{}
}{
{
name: "missing warnings",
data: map[string]interface{}{},
want: []string{"new warning"},
},
{
name: "string slice warnings",
data: map[string]interface{}{"warnings": []string{"old warning"}},
want: []string{"old warning", "new warning"},
},
{
name: "interface slice warnings",
data: map[string]interface{}{"warnings": []interface{}{"old warning"}},
want: []interface{}{"old warning", "new warning"},
},
{
name: "scalar warning",
data: map[string]interface{}{"warnings": "old warning"},
want: []interface{}{"old warning", "new warning"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
appendDocWarning(tt.data, "new warning")
if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) {
t.Fatalf("warnings = %#v, want %#v", got, tt.want)
}
})
}
}

View File

@@ -20,9 +20,9 @@ import (
)
// driveSearchErrUserNotVisible is the Lark service code returned by
// doc_wiki/search when an open_id referenced in --creator-ids / --sharer-ids
// falls outside the app's user-visibility scope (different from the
// search:docs:read API scope).
// doc_wiki/search when an open_id referenced in an identity filter falls
// outside the app's user-visibility scope (different from the search:docs:read
// API scope).
const driveSearchErrUserNotVisible = 99992351
// open_time has a server-side cap of 3 months per request. Rather than
@@ -79,6 +79,8 @@ var DriveSearch = common.Shortcut{
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
{Name: "created-by-me", Type: "bool", Desc: "restrict to docs originally created by me (uses current user's open_id as original_creator_ids)"},
{Name: "original-creator-ids", Desc: "comma-separated original creator open_ids; mutually exclusive with --created-by-me"},
{Name: "edited-since", Desc: "start of [my edited] time window (e.g. 7d, 1m, 1y, 2026-04-01, RFC3339, unix seconds)"},
{Name: "edited-until", Desc: "end of [my edited] time window"},
@@ -108,7 +110,7 @@ var DriveSearch = common.Shortcut{
Tips: []string{
"Time flags accept relative (e.g. 7d, 1m, 1y), absolute (2026-04-01, RFC3339), or unix seconds.",
"my_edit_time and my_comment_time are hour-aggregated server-side; sub-hour inputs are snapped and a notice is printed to stderr.",
"Use --mine for a quick \"docs I own\" filter (owner semantic, not original creator). For other people, use --creator-ids ou_xxx,ou_yyy.",
"Use --created-by-me for \"docs I created\". Use --mine for \"docs I own\" (owner semantic).",
"--folder-tokens limits to doc-only search; --space-ids limits to wiki-only. They cannot be combined.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -164,6 +166,9 @@ type driveSearchSpec struct {
Mine bool
CreatorIDs []string
CreatedByMe bool
OriginalCreatorIDs []string
EditedSince string
EditedUntil string
CommentedSince string
@@ -193,6 +198,9 @@ func readDriveSearchSpec(runtime *common.RuntimeContext) driveSearchSpec {
Mine: runtime.Bool("mine"),
CreatorIDs: common.SplitCSV(runtime.Str("creator-ids")),
CreatedByMe: runtime.Bool("created-by-me"),
OriginalCreatorIDs: common.SplitCSV(runtime.Str("original-creator-ids")),
EditedSince: runtime.Str("edited-since"),
EditedUntil: runtime.Str("edited-until"),
CommentedSince: runtime.Str("commented-since"),
@@ -221,12 +229,18 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
if spec.Mine && len(spec.CreatorIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --mine and --creator-ids")
}
if spec.CreatedByMe && len(spec.OriginalCreatorIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --created-by-me and --original-creator-ids")
}
if len(spec.FolderTokens) > 0 && len(spec.SpaceIDs) > 0 {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --folder-tokens and --space-ids; doc and wiki scoped search cannot be combined")
}
if spec.Mine && userOpenID == "" {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--mine requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--mine")
}
if spec.CreatedByMe && userOpenID == "" {
return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--created-by-me requires a logged-in user open_id, but none is configured; run `lark-cli auth login` or set user open_id in config").WithParam("--created-by-me")
}
if err := validateDocTypes(spec.DocTypes); err != nil {
return nil, nil, err
@@ -256,13 +270,20 @@ func buildDriveSearchRequest(spec driveSearchSpec, userOpenID string, now time.T
notices = append(notices, n)
}
// Creator identity.
// Identity filters. creator_ids is owner; original_creator_ids is the
// immutable document creator.
switch {
case spec.Mine:
filter["creator_ids"] = []string{userOpenID}
case len(spec.CreatorIDs) > 0:
filter["creator_ids"] = spec.CreatorIDs
}
switch {
case spec.CreatedByMe:
filter["original_creator_ids"] = []string{userOpenID}
case len(spec.OriginalCreatorIDs) > 0:
filter["original_creator_ids"] = spec.OriginalCreatorIDs
}
// Time dimensions — each fills at most one filter key; hour-aggregated ones
// also contribute notices.
@@ -358,6 +379,11 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
for _, id := range spec.OriginalCreatorIDs {
if _, err := common.ValidateUserIDTyped("--original-creator-ids", id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--original-creator-ids %q: %s", id, err).WithParam("--original-creator-ids")
}
}
if n := len(spec.ChatIDs); n > driveSearchMaxChatIDs {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
@@ -635,7 +661,7 @@ func enrichDriveSearchError(err error) error {
if !ok || p.Code != driveSearchErrUserNotVisible {
return err
}
p.Hint = "one or more open_ids in --creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
p.Hint = "one or more open_ids in --creator-ids / --original-creator-ids / --sharer-ids are outside this app's user-visibility scope (this is the app's contact visibility, not the search:docs:read API scope); ask an admin to grant the app visibility to those users in the developer console, or drop the unreachable open_ids"
return err
}

View File

@@ -245,9 +245,10 @@ func TestValidateDriveSearchIDs(t *testing.T) {
t.Run("all valid", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{
CreatorIDs: []string{"ou_aaa"},
ChatIDs: []string{"oc_xxx"},
SharerIDs: []string{"ou_bbb"},
CreatorIDs: []string{"ou_aaa"},
OriginalCreatorIDs: []string{"ou_ccc"},
ChatIDs: []string{"oc_xxx"},
SharerIDs: []string{"ou_bbb"},
}
if err := validateDriveSearchIDs(spec); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -275,6 +276,24 @@ func TestValidateDriveSearchIDs(t *testing.T) {
}
})
t.Run("bad original creator id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{OriginalCreatorIDs: []string{"u_bad"}})
if err == nil || !strings.Contains(err.Error(), "--original-creator-ids") {
t.Fatalf("expected --original-creator-ids error, got: %v", err)
}
var vErr *errs.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if vErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", vErr.Subtype, errs.SubtypeInvalidArgument)
}
if vErr.Param != "--original-creator-ids" {
t.Fatalf("Param = %q, want --original-creator-ids", vErr.Param)
}
})
t.Run("bad chat id format", func(t *testing.T) {
t.Parallel()
err := validateDriveSearchIDs(driveSearchSpec{ChatIDs: []string{"chat_bad"}})
@@ -727,6 +746,33 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me fills original_creator_ids from userOpenID", func(t *testing.T) {
t.Parallel()
req, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
got := req["doc_filter"].(map[string]interface{})["original_creator_ids"].([]string)
if len(got) != 1 || got[0] != userOpenID {
t.Fatalf("expected [userOpenID], got %v", got)
}
})
t.Run("--original-creator-ids fills original_creator_ids", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{OriginalCreatorIDs: []string{"ou_a", "ou_b"}}
req, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err != nil {
t.Fatalf("err: %v", err)
}
for _, filterKey := range []string{"doc_filter", "wiki_filter"} {
got := req[filterKey].(map[string]interface{})["original_creator_ids"].([]string)
if !reflect.DeepEqual(got, []string{"ou_a", "ou_b"}) {
t.Fatalf("%s: expected explicit original creator ids, got %v", filterKey, got)
}
}
})
t.Run("--mine without userOpenID errors", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{Mine: true}, "", now)
@@ -735,6 +781,14 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me without userOpenID errors", func(t *testing.T) {
t.Parallel()
_, _, err := buildDriveSearchRequest(driveSearchSpec{CreatedByMe: true}, "", now)
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
t.Fatalf("expected --created-by-me error, got: %v", err)
}
})
t.Run("--mine + --creator-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{Mine: true, CreatorIDs: []string{"ou_x"}}
@@ -756,6 +810,15 @@ func TestBuildDriveSearchRequest(t *testing.T) {
}
})
t.Run("--created-by-me + --original-creator-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{CreatedByMe: true, OriginalCreatorIDs: []string{"ou_x"}}
_, _, err := buildDriveSearchRequest(spec, userOpenID, now)
if err == nil || !strings.Contains(err.Error(), "--created-by-me") {
t.Fatalf("expected exclusion error, got: %v", err)
}
})
t.Run("--folder-tokens + --space-ids mutually exclusive", func(t *testing.T) {
t.Parallel()
spec := driveSearchSpec{

View File

@@ -123,6 +123,13 @@ func isHTMLBlockBoundary(n *xhtml.Node) bool {
}
}
// PlainTextFromHTML is the exported wrapper over plainTextFromHTML, so the
// mail package can render an HTML signature as a plain-text fallback when a
// message body is sent in plain-text mode. The conversion logic is unchanged.
func PlainTextFromHTML(raw string) string {
return plainTextFromHTML(raw)
}
// bodyLooksLikeHTML reports whether raw appears to contain HTML markup.
// This is intentionally heuristic: it exists to reject obvious plain-text
// input when a draft's authored body is text/html.

View File

@@ -102,3 +102,10 @@ func TestIsHTMLNonTextTag(t *testing.T) {
})
}
}
func TestPlainTextFromHTMLExported(t *testing.T) {
got := PlainTextFromHTML("<p>Hello world</p>")
if !strings.Contains(got, "Hello world") {
t.Fatalf("PlainTextFromHTML: expected to contain \"Hello world\", got %q", got)
}
}

View File

@@ -56,6 +56,7 @@ var MailDraftCreate = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{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,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag,
@@ -92,7 +93,7 @@ var MailDraftCreate = common.Shortcut{
if !hasTemplate && strings.TrimSpace(runtime.Str("subject")) == "" {
return mailValidationParamError("--subject", "--subject is required; pass the final email subject (or use --template-id)")
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -180,12 +181,22 @@ var MailDraftCreate = common.Shortcut{
if strings.TrimSpace(input.Body) == "" {
return mailValidationParamError("--body", "effective body is empty after applying template; pass --body explicitly")
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from"))
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
senderEmail := resolveComposeSenderEmail(runtime)
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !input.PlainText)
if err != nil {
return err
}
rawEML, lintApplied, lintBlocked, err := buildRawEMLForDraftCreate(ctx, runtime, input, sigResult, priority,
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments)
templateLargeAttachmentIDs, mailboxID, templateID, templateInlineAttachments, templateSmallAttachments, senderEmail)
if err != nil {
return err
}
@@ -241,13 +252,19 @@ func buildRawEMLForDraftCreate(
mailboxID, templateID string,
templateInlineAttachments []templateInlineRef,
templateSmallAttachments []templateAttachmentRef,
senderEmailHint string,
) (rawEMLOut string, lintApplied, lintBlocked []lint.Finding, err error) {
// Initialise lint findings as empty (non-nil) slices so callers can
// surface them through the envelope unconditionally even on the
// plain-text branch.
lintApplied, lintBlocked = emptyLintFindings()
senderEmail := resolveComposeSenderEmail(runtime)
// Use the pre-resolved senderEmail when available (avoids a duplicate
// profile API call when Execute already fetched it for auto-resolve).
senderEmail := senderEmailHint
if senderEmail == "" {
senderEmail = resolveComposeSenderEmail(runtime)
}
if senderEmail == "" {
return "", lintApplied, lintBlocked, mailValidationParamError("--from", "unable to determine sender email; please specify --from explicitly")
}
@@ -290,7 +307,7 @@ func buildRawEMLForDraftCreate(
var composedHTMLBody string
var composedTextBody string
if input.PlainText {
composedTextBody = input.Body
composedTextBody = injectPlainTextSignature(input.Body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(input.Body) || sigResult != nil {
htmlBody := input.Body

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)
}
@@ -237,7 +237,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptAddsHeader(t *testing.T) {
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil)
newRuntimeWithFromAndRequestReceipt("sender@example.com", true), input, nil, "", nil, "", "", nil, nil, "")
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
@@ -260,7 +260,7 @@ func TestBuildRawEMLForDraftCreate_RequestReceiptOmittedByDefault(t *testing.T)
}
rawEML, _, _, err := buildRawEMLForDraftCreate(context.Background(),
newRuntimeWithFromAndRequestReceipt("sender@example.com", false), input, nil, "", nil, "", "", nil, nil)
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

@@ -134,7 +134,7 @@ var MailDraftEdit = common.Shortcut{
for i := range patch.Ops {
switch patch.Ops[i].Op {
case "insert_signature":
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail, true, true)
if sigErr != nil {
return sigErr
}

View File

@@ -45,6 +45,7 @@ var MailForward = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Fw: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{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,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -96,7 +97,7 @@ var MailForward = common.Shortcut{
return err
}
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -127,12 +128,7 @@ var MailForward = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailForward = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
// --template-id merge (§5.5 Q1-Q5).
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
@@ -198,6 +206,14 @@ var MailForward = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
// Post-merge recipient check for --confirm-send + --template-id:
@@ -310,7 +326,7 @@ var MailForward = common.Shortcut{
return err
}
} else {
composedTextBody = buildForwardedMessage(&orig, body)
composedTextBody = buildForwardedMessage(&orig, injectPlainTextSignature(body, sigResult))
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -42,6 +42,7 @@ var MailReply = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{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,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -93,7 +94,7 @@ var MailReply = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -129,12 +130,7 @@ var MailReply = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -156,6 +152,18 @@ var MailReply = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
replyTo := orig.replyTo
if replyTo == "" {
replyTo = orig.headFrom
@@ -208,6 +216,14 @@ var MailReply = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
// --subject (explicit override) takes precedence over auto-generated.
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
@@ -311,7 +327,7 @@ var MailReply = common.Shortcut{
return err
}
} else {
composedTextBody = bodyStr + quoted
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -43,6 +43,7 @@ var MailReplyAll = common.Shortcut{
{Name: "subject", Desc: "Optional. Override the auto-generated Re: subject. When set, the shortcut uses this value verbatim instead of prefixing the original subject."},
{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,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -94,7 +95,7 @@ var MailReplyAll = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
if err := validateEventFlags(runtime); err != nil {
@@ -131,12 +132,7 @@ var MailReplyAll = common.Shortcut{
return err
}
signatureID := runtime.Str("signature-id")
mailboxID := resolveComposeMailboxID(runtime)
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from"))
if sigErr != nil {
return sigErr
}
sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId)
if err != nil {
return mailDecorateProblemMessage(err, "failed to fetch original message")
@@ -158,6 +154,18 @@ var MailReplyAll = common.Shortcut{
senderEmail = orig.headTo
}
// Signature ID is resolved here (after senderEmail is finalised) so DefaultReplyID
// matches the correct usage. The actual image download in resolveSignature is deferred
// to after applyTemplate so the final plainText value (which a template can override
// via IsPlainTextMode) is used for the downloadImages decision.
signatureID := runtime.Str("signature-id")
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, true /*isReply*/)
}
var removeList []string
for _, r := range strings.Split(removeFlag, ",") {
if s := strings.TrimSpace(r); s != "" {
@@ -218,6 +226,14 @@ var MailReplyAll = common.Shortcut{
"bccs_count": countAddresses(bccFlag),
})
}
// Resolve signature after template processing so plainText reflects any IsPlainTextMode
// override from the template. This avoids downloading HTML signature images when the
// template forces plain-text mode, which could cause CDN 403/5xx or timeout errors.
sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if sigErr != nil {
return sigErr
}
subjectOverride := strings.TrimSpace(runtime.Str("subject"))
if err := validateRecipientCount(toList, ccList, bccFlag); err != nil {
@@ -316,7 +332,7 @@ var MailReplyAll = common.Shortcut{
return err
}
} else {
composedTextBody = bodyStr + quoted
composedTextBody = injectPlainTextSignature(bodyStr, sigResult) + quoted
bld = bld.TextBody([]byte(composedTextBody))
}
// Embed template SMALL non-inline attachments regardless of body mode.

View File

@@ -40,6 +40,7 @@ var MailSend = common.Shortcut{
{Name: "request-receipt", Type: "bool", Desc: "Request a read receipt (Message Disposition Notification, RFC 3798) addressed to the sender. Recipient mail clients may prompt the user, send automatically, or silently ignore — delivery of a receipt is not guaranteed."},
{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,
noSignatureFlag,
priorityFlag,
eventSummaryFlag, eventStartFlag, eventEndFlag, eventLocationFlag,
showLintDetailsFlag},
@@ -98,7 +99,7 @@ var MailSend = common.Shortcut{
if err := validateSendTime(runtime); err != nil {
return err
}
if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil {
if err := validateNoSignatureConflict(runtime.Bool("no-signature"), runtime.Str("signature-id")); err != nil {
return err
}
// Resolve the body content first (reading --body-file if set) so
@@ -145,6 +146,14 @@ var MailSend = common.Shortcut{
mailboxID := resolveComposeMailboxID(runtime)
// Auto-resolve default signature when neither --no-signature nor --signature-id is set.
noSignature := runtime.Bool("no-signature")
if noSignature {
signatureID = ""
} else if signatureID == "" {
signatureID = autoResolveSignatureID(runtime, mailboxID, senderEmail, false)
}
// --template-id merge: fetch template and apply it to compose state.
var templateLargeAttachmentIDs []string
var templateInlineAttachments []templateInlineRef
@@ -195,7 +204,8 @@ var MailSend = common.Shortcut{
}
}
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail)
sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail,
runtime.Str("signature-id") != "", !plainText)
if err != nil {
return err
}
@@ -230,7 +240,7 @@ var MailSend = common.Shortcut{
// `lint_applied[]` / `original_blocked[]` even on the plain-text path.
lintApplied, lintBlocked := emptyLintEnvelopeFields()
if plainText {
composedTextBody = body
composedTextBody = injectPlainTextSignature(body, sigResult)
bld = bld.TextBody([]byte(composedTextBody))
} else if bodyIsHTML(body) || sigResult != nil {
// If signature is requested on plain-text body, auto-upgrade to HTML.

View File

@@ -6,6 +6,7 @@ package signature
import (
"encoding/json"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
@@ -55,6 +56,33 @@ func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error)
return resp.Signatures, nil
}
// DefaultSendID returns the default send-mail signature ID for the given
// sender email address. Returns "" if no default is configured.
// "0" and empty string are treated as "no default" (API convention).
func DefaultSendID(usages []SignatureUsage, emailAddr string) string {
for _, u := range usages {
if strings.EqualFold(u.EmailAddress, emailAddr) {
if u.SendMailSignatureID != "" && u.SendMailSignatureID != "0" {
return u.SendMailSignatureID
}
}
}
return ""
}
// DefaultReplyID returns the default reply/forward signature ID for the given
// sender email address. Returns "" if no default is configured.
func DefaultReplyID(usages []SignatureUsage, emailAddr string) string {
for _, u := range usages {
if strings.EqualFold(u.EmailAddress, emailAddr) {
if u.ReplySignatureID != "" && u.ReplySignatureID != "0" {
return u.ReplySignatureID
}
}
}
return ""
}
// Get returns a single signature by ID. Returns an error if not found.
func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) {
resp, err := ListAll(runtime, mailboxID)

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package signature
import (
"testing"
)
func TestDefaultSendID_Match(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-1"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-1" {
t.Fatalf("expected sig-send-1, got %q", got)
}
}
func TestDefaultSendID_CaseInsensitive(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "User@Example.COM", SendMailSignatureID: "sig-send-x"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "sig-send-x" {
t.Fatalf("expected case-insensitive match, got %q", got)
}
}
func TestDefaultSendID_NoMatch(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "other@example.com", SendMailSignatureID: "sig-other"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for no match, got %q", got)
}
}
func TestDefaultSendID_ZeroIDTreatedAsNone(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "0"},
}
if got := DefaultSendID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for ID=0, got %q", got)
}
}
func TestDefaultSendID_NilUsages(t *testing.T) {
if got := DefaultSendID(nil, "user@example.com"); got != "" {
t.Fatalf("expected empty string for nil usages, got %q", got)
}
}
func TestDefaultReplyID_Match(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", SendMailSignatureID: "sig-send-1", ReplySignatureID: "sig-reply-2"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-2" {
t.Fatalf("expected sig-reply-2, got %q", got)
}
}
func TestDefaultReplyID_CaseInsensitive(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "User@Example.COM", ReplySignatureID: "sig-reply-x"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "sig-reply-x" {
t.Fatalf("expected case-insensitive match, got %q", got)
}
}
func TestDefaultReplyID_NoMatch(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "other@example.com", ReplySignatureID: "sig-reply-other"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for no match, got %q", got)
}
}
func TestDefaultReplyID_ZeroIDTreatedAsNone(t *testing.T) {
usages := []SignatureUsage{
{EmailAddress: "user@example.com", ReplySignatureID: "0"},
}
if got := DefaultReplyID(usages, "user@example.com"); got != "" {
t.Fatalf("expected empty string for ID=0, got %q", got)
}
}
func TestDefaultReplyID_NilUsages(t *testing.T) {
if got := DefaultReplyID(nil, "user@example.com"); got != "" {
t.Fatalf("expected empty string for nil usages, got %q", got)
}
}

View File

@@ -5,6 +5,7 @@ package mail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
@@ -25,6 +26,54 @@ var signatureFlag = common.Flag{
Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.",
}
// noSignatureFlag is shared by all 5 compose shortcuts.
var noSignatureFlag = common.Flag{
Name: "no-signature",
Type: "bool",
Desc: "Skip automatic default signature insertion. Mutually exclusive with --signature-id.",
}
// validateNoSignatureConflict returns a structured validation error when
// --no-signature and --signature-id are both set; they are mutually exclusive.
func validateNoSignatureConflict(noSignature bool, signatureID string) error {
if noSignature && signatureID != "" {
return mailValidationParamError("--no-signature", "--no-signature and --signature-id are mutually exclusive")
}
return nil
}
// autoResolveSignatureID resolves the default signature ID for the given mailbox/sender.
// isReply=true uses DefaultReplyID (+reply/+reply-all/+forward);
// isReply=false uses DefaultSendID (+send/+draft-create).
// Returns "" on API failure (writes stderr warning) or when no default is configured.
func autoResolveSignatureID(runtime *common.RuntimeContext, mailboxID, senderEmail string, isReply bool) string {
resp, err := signature.ListAll(runtime, mailboxID)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"warning: failed to fetch default signature: %v; sending without signature\n", err)
return ""
}
if isReply {
return signature.DefaultReplyID(resp.Usages, senderEmail)
}
return signature.DefaultSendID(resp.Usages, senderEmail)
}
// injectPlainTextSignature appends a plain-text rendering of the signature to a
// plain-text body. The HTML signature (sig.RenderedContent) is converted via
// draftpkg.PlainTextFromHTML; inline images are dropped (plain text has none).
// Returns textBody unchanged when sig is nil.
func injectPlainTextSignature(textBody string, sig *signatureResult) string {
if sig == nil {
return textBody
}
sigText := strings.TrimRight(draftpkg.PlainTextFromHTML(sig.RenderedContent), "\n")
if sigText == "" {
return textBody
}
return textBody + "\n\n" + sigText
}
// signatureResult holds the pre-processed signature data ready for HTML injection.
type signatureResult struct {
ID string
@@ -32,16 +81,32 @@ type signatureResult struct {
Images []draftpkg.SignatureImage
}
// resolveSignature fetches, interpolates, and downloads images for a signature.
// resolveSignature fetches, interpolates, and optionally downloads images for a signature.
// fromEmail is the --from address (may be an alias); used to match the correct
// sender identity for template interpolation. Pass "" to use the primary address.
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) {
//
// userExplicit must be true when the caller obtained signatureID from a user-supplied flag
// (--signature-id); false when the ID was auto-resolved from default usages. When false,
// a "not found" error from the signatures API is treated as graceful degradation (no
// signature) rather than a hard failure — this protects against stale default IDs.
//
// includeImages controls whether inline image attachments are downloaded. Pass false for
// plain-text compose paths to avoid unnecessary network I/O (images are discarded in
// plain-text mode anyway).
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, userExplicit, includeImages bool) (*signatureResult, error) {
if signatureID == "" {
return nil, nil
}
sig, err := signature.Get(runtime, mailboxID, signatureID)
if err != nil {
if !userExplicit && errs.IsValidation(err) {
// Stale auto-resolved default signature ID — degrade gracefully instead of
// blocking the entire send/reply/forward operation.
fmt.Fprintf(runtime.IO().ErrOut,
"warning: default signature %q not found in current list; sending without signature\n", signatureID)
return nil, nil
}
return nil, err
}
@@ -50,23 +115,26 @@ func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailb
senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail)
rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail)
// Download signature inline images. The file_key field contains a
// direct download URL provided by the mail backend.
// Download signature inline images only when the compose path needs them.
// Plain-text paths discard images, so skip the download to avoid unnecessary
// network I/O (and potential failures from expired pre-signed URLs).
var images []draftpkg.SignatureImage
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
if includeImages {
for _, img := range sig.Images {
if img.DownloadURL == "" || img.CID == "" {
continue
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName)
if err != nil {
return nil, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
}
images = append(images, draftpkg.SignatureImage{
CID: img.CID,
ContentType: ct,
FileName: img.ImageName,
Data: data,
})
}
return &signatureResult{
@@ -243,15 +311,3 @@ func signatureCIDs(sig *signatureResult) []string {
}
return cids
}
// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set.
func validateSignatureWithPlainText(plainText bool, signatureID string) error {
if plainText && signatureID != "" {
return mailValidationError("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode").
WithParams(
mailInvalidParam("--plain-text", "mutually exclusive with --signature-id"),
mailInvalidParam("--signature-id", "requires HTML mode"),
)
}
return nil
}

View File

@@ -4,204 +4,287 @@
package mail
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
)
func TestDownloadSignatureImageRejectsInvalidURLs(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
func TestValidateNoSignatureConflictTypedError(t *testing.T) {
err := validateNoSignatureConflict(true, "sig_123")
if err == nil {
t.Fatal("expected error, got nil")
}
// mailValidationParamError returns *errs.ValidationError.
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if valErr.Param != "--no-signature" {
t.Errorf("expected Param \"--no-signature\", got %q", valErr.Param)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("error message = %q, want it to contain \"mutually exclusive\"", err.Error())
}
}
func TestValidateNoSignatureConflictNoError(t *testing.T) {
if err := validateNoSignatureConflict(false, "sig_123"); err != nil {
t.Fatalf("expected no error when noSignature=false, got %v", err)
}
if err := validateNoSignatureConflict(true, ""); err != nil {
t.Fatalf("expected no error when signatureID empty, got %v", err)
}
}
func TestInjectPlainTextSignatureNilSig(t *testing.T) {
body := "Hello world"
got := injectPlainTextSignature(body, nil)
if got != body {
t.Fatalf("expected unchanged body %q, got %q", body, got)
}
}
func TestInjectPlainTextSignatureEmptyHTML(t *testing.T) {
sig := &signatureResult{RenderedContent: " <br> "}
body := "Hello world"
got := injectPlainTextSignature(body, sig)
// PlainTextFromHTML on whitespace-only HTML collapses to empty — body unchanged.
if got != body {
t.Fatalf("expected unchanged body for empty HTML sig, got %q", got)
}
}
func TestInjectPlainTextSignatureAppendsWithBlankLine(t *testing.T) {
sig := &signatureResult{RenderedContent: "<div>Best,<br>Bob</div>"}
body := "Hello world"
got := injectPlainTextSignature(body, sig)
if !strings.HasPrefix(got, body+"\n\n") {
t.Fatalf("expected body followed by two newlines, got %q", got)
}
if !strings.Contains(got, "Best,") || !strings.Contains(got, "Bob") {
t.Fatalf("expected sig text in result, got %q", got)
}
}
func TestInjectPlainTextSignatureTrimsTrailingNewlines(t *testing.T) {
// RenderedContent whose plain-text rendering ends in newlines must be trimmed.
sig := &signatureResult{RenderedContent: "<p>Alice</p>"}
body := "My message"
got := injectPlainTextSignature(body, sig)
// Result must not end with a bare newline after the signature text.
if strings.HasSuffix(got, "\n") {
t.Fatalf("result should not end with newline, got %q", got)
}
if !strings.Contains(got, "Alice") {
t.Fatalf("expected sig text in result, got %q", got)
}
}
func TestContentTypeFromFilename(t *testing.T) {
cases := []struct {
name string
url string
want string
}{
{name: "invalid", url: "https://[::1"},
{name: "http", url: "http://example.com/sig.png"},
{name: "no host", url: "https:///sig.png"},
{"logo.png", "image/png"},
{"photo.jpg", "image/jpeg"},
{"photo.jpeg", "image/jpeg"},
{"anim.gif", "image/gif"},
{"icon.webp", "image/webp"},
{"draw.svg", "image/svg+xml"},
{"bitmap.bmp", "image/bmp"},
{"data.bin", "application/octet-stream"},
{"noext", "application/octet-stream"},
{"UPPER.PNG", "image/png"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, err := downloadSignatureImage(rt, tc.url, "sig.png")
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected internal error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidResponse)
}
})
got := contentTypeFromFilename(tc.name)
if got != tc.want {
t.Errorf("contentTypeFromFilename(%q) = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestDownloadSignatureImageHTTPErrorClassification(t *testing.T) {
for _, tc := range []struct {
name string
statusCode int
wantType any
wantSub errs.Subtype
retryable bool
}{
{
name: "server",
statusCode: http.StatusInternalServerError,
wantType: (*errs.NetworkError)(nil),
wantSub: errs.SubtypeNetworkServer,
retryable: true,
func TestSignatureCIDsNilSig(t *testing.T) {
if cids := signatureCIDs(nil); cids != nil {
t.Fatalf("expected nil slice for nil sig, got %v", cids)
}
}
func TestSignatureCIDsFiltersEmpty(t *testing.T) {
sig := &signatureResult{
Images: []draftpkg.SignatureImage{
{CID: "abc123"},
{CID: ""},
{CID: "<def456>"},
},
{
name: "not found",
statusCode: http.StatusNotFound,
wantType: (*errs.APIError)(nil),
wantSub: errs.SubtypeNotFound,
}
cids := signatureCIDs(sig)
// normalizeInlineCID strips angle brackets; empty CID is filtered out.
if len(cids) != 2 {
t.Fatalf("expected 2 CIDs, got %d: %v", len(cids), cids)
}
for _, c := range cids {
if c == "" {
t.Errorf("CID must not be empty string; got %v", cids)
}
}
}
func TestInjectSignatureIntoBodyNilSig(t *testing.T) {
html := "<div>body</div>"
got := injectSignatureIntoBody(html, nil)
if got != html {
t.Fatalf("expected unchanged body for nil sig, got %q", got)
}
}
func TestInjectSignatureIntoBodyInjectsSig(t *testing.T) {
html := "<div>Hello</div>"
sig := &signatureResult{
ID: "sig1",
RenderedContent: "<div>-- Alice</div>",
}
got := injectSignatureIntoBody(html, sig)
if !strings.Contains(got, "sig1") && !strings.Contains(got, "Alice") {
t.Fatalf("expected signature content in result, got %q", got)
}
}
func TestAddSignatureImagesToBuilderNilSig(t *testing.T) {
bld := emlbuilder.New()
got := addSignatureImagesToBuilder(bld, nil)
// nil sig must return the builder unchanged (no panic, no nil return).
_ = got
}
func TestAddSignatureImagesToBuilderWithImages(t *testing.T) {
bld := emlbuilder.New()
sig := &signatureResult{
Images: []draftpkg.SignatureImage{
{CID: "img1", ContentType: "image/png", FileName: "logo.png", Data: []byte("fake")},
{CID: "", ContentType: "image/jpeg", FileName: "skip.jpg", Data: []byte("fake")}, // empty CID skipped
},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "download failed", tc.statusCode)
}))
t.Cleanup(srv.Close)
rt := newDownloadRuntime(t, srv.Client())
}
// Should not panic; empty CID entry is silently skipped.
got := addSignatureImagesToBuilder(bld, sig)
_ = got
}
_, _, err := downloadSignatureImage(rt, srv.URL+"/sig.png", "sig.png")
switch tc.wantType.(type) {
case *errs.NetworkError:
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
case *errs.APIError:
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected API error, got %T (%v)", err, err)
}
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Code != tc.statusCode {
t.Fatalf("code = %d, want %d", p.Code, tc.statusCode)
}
if p.Subtype != tc.wantSub {
t.Fatalf("subtype = %q, want %q", p.Subtype, tc.wantSub)
}
if p.Retryable != tc.retryable {
t.Fatalf("retryable = %v, want %v", p.Retryable, tc.retryable)
}
})
// newSigTestRuntime creates a RuntimeContext backed by an httpmock.Registry for
// tests that exercise signature API code paths (autoResolveSignatureID, resolveSignature).
func newSigTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_sigtest"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+test"}, cfg, f, core.AsUser)
return rt, reg
}
// stubSigListResponse registers a signatures list stub for the given mailboxID.
func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[string]interface{}, usages []map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/mail/v1/user_mailboxes/" + mailboxID + "/settings/signatures",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"signatures": sigs,
"usages": usages,
},
},
})
}
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
rt, reg := newSigTestRuntime(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/mail/v1/user_mailboxes/mbx-api-fail/settings/signatures",
Status: 500,
Body: map[string]interface{}{"code": 500, "msg": "internal server error"},
})
got := autoResolveSignatureID(rt, "mbx-api-fail", "user@example.com", false)
if got != "" {
t.Fatalf("expected empty string on API failure, got %q", got)
}
}
func TestDownloadSignatureImageReadAndSizeErrors(t *testing.T) {
readErr := errors.New("socket closed")
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: signatureErrorBody{err: readErr},
Request: req,
}, nil
}),
func TestAutoResolveSignatureID_NoDefaultConfigured(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-no-default", nil, []map[string]interface{}{
{"email_address": "other@example.com", "send_mail_signature_id": "sig-other"},
})
_, _, err := downloadSignatureImage(rt, "https://example.com/sig.png", "sig.png")
var networkErr *errs.NetworkError
if !errors.As(err, &networkErr) {
t.Fatalf("expected network error, got %T (%v)", err, err)
}
if !errors.Is(err, readErr) {
t.Fatalf("read cause not preserved: %v", err)
}
rt = newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: &bodyFileTestFile{remaining: 10*1024*1024 + 1},
Request: req,
}, nil
}),
})
_, _, err = downloadSignatureImage(rt, "https://example.com/huge.png", "huge.png")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T", err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeFailedPrecondition)
got := autoResolveSignatureID(rt, "mbx-no-default", "user@example.com", false)
if got != "" {
t.Fatalf("expected empty string when no default configured for sender, got %q", got)
}
}
func TestDownloadSignatureImageSuccessUsesFilenameContentType(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{
Transport: signatureRoundTripper(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("gif-data")),
Request: req,
}, nil
}),
func TestAutoResolveSignatureID_ReturnsSendID(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-send-id", nil, []map[string]interface{}{
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
})
got := autoResolveSignatureID(rt, "mbx-send-id", "user@example.com", false)
if got != "sig-send-42" {
t.Fatalf("expected send default sig ID %q, got %q", "sig-send-42", got)
}
}
data, contentType, err := downloadSignatureImage(rt, "https://example.com/sig.gif", "sig.gif")
func TestAutoResolveSignatureID_ReturnsReplyID(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-reply-id", nil, []map[string]interface{}{
{"email_address": "user@example.com", "send_mail_signature_id": "sig-send-42", "reply_signature_id": "sig-reply-42"},
})
got := autoResolveSignatureID(rt, "mbx-reply-id", "user@example.com", true)
if got != "sig-reply-42" {
t.Fatalf("expected reply default sig ID %q, got %q", "sig-reply-42", got)
}
}
func TestResolveSignature_EmptyIDReturnsNil(t *testing.T) {
rt, _ := newSigTestRuntime(t)
result, err := resolveSignature(context.Background(), rt, "mbx-empty", "", "user@example.com", false, false)
if err != nil {
t.Fatalf("downloadSignatureImage failed: %v", err)
t.Fatalf("unexpected error for empty signatureID: %v", err)
}
if string(data) != "gif-data" {
t.Fatalf("data = %q", string(data))
}
if contentType != "image/gif" {
t.Fatalf("content type = %q, want image/gif", contentType)
if result != nil {
t.Fatalf("expected nil result for empty signatureID, got %+v", result)
}
}
func TestValidateSignatureWithPlainTextTypedError(t *testing.T) {
err := validateSignatureWithPlainText(true, "sig_123")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T (%v)", err, err)
func TestResolveSignature_StaleIDAutoDegradesGracefully(t *testing.T) {
rt, reg := newSigTestRuntime(t)
// API returns an empty list — stale ID not found → ValidationError in Get.
stubSigListResponse(reg, "mbx-stale-auto", nil, nil)
result, err := resolveSignature(context.Background(), rt, "mbx-stale-auto", "sig-stale", "user@example.com", false, false)
if err != nil {
t.Fatalf("expected graceful degradation (nil error), got: %v", err)
}
if len(validationErr.Params) != 2 {
t.Fatalf("params = %#v, want two conflicting params", validationErr.Params)
}
if validationErr.Params[0].Name != "--plain-text" || validationErr.Params[1].Name != "--signature-id" {
t.Fatalf("unexpected params: %#v", validationErr.Params)
if result != nil {
t.Fatalf("expected nil result for stale auto-resolved ID, got %+v", result)
}
}
type signatureRoundTripper func(*http.Request) (*http.Response, error)
func (rt signatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt(req)
}
type signatureErrorBody struct {
err error
}
func (b signatureErrorBody) Read([]byte) (int, error) {
return 0, b.err
}
func (b signatureErrorBody) Close() error {
return nil
func TestResolveSignature_StaleIDUserExplicitFails(t *testing.T) {
rt, reg := newSigTestRuntime(t)
stubSigListResponse(reg, "mbx-stale-explicit", nil, nil)
_, err := resolveSignature(context.Background(), rt, "mbx-stale-explicit", "sig-stale", "user@example.com", true, false)
if err == nil {
t.Fatal("expected error for stale ID with userExplicit=true, got nil")
}
if !errs.IsValidation(err) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
}

View File

@@ -23,7 +23,7 @@ lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<titl
# 创建到个人知识库XML
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
# 仅当用户明确要求时才使用 Markdown
# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
```
@@ -72,7 +72,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
## 最佳实践
- 文档标题从内容中自动提取XML `<title>`Markdown `#`),不要在内容开头重复写标题
- 文档标题从内容中自动提取XML 使用 `<title>`Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
- **创建较长的文档时只建骨架**`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
- **视觉丰富度**:必须遵循 [`lark-doc-style.md`](style/lark-doc-style.md) 中的样式指南,主动使用结构化 block 丰富文档

View File

@@ -2,6 +2,10 @@
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
## 创建文档标题
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`
## 转义规则
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
@@ -73,4 +77,4 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
## 参考
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范

View File

@@ -99,6 +99,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
### block_insert_after — 在指定 block 之后插入
> ⚠️ **同一锚点多次插入会反序**`block_insert_after` 每次都会把内容插入到 `--block-id` 指定块的正后方。如果多次复用同一个锚点,后一次插入会排在前一次插入之前(例如依次插入 A、B、C最终顺序是 anchor → C → B → A。若要保持自然顺序优先把同一位置的多个 block 合并到一次 `--content` 写入;必须分多次写入时,每次写入后重新 `fetch --detail with-ids`,用上一次新插入的最后一个 block 作为下一次锚点。
```bash
lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_insert_after \
--block-id "目标 block_id" \
@@ -113,6 +115,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
--content '<p>替换后的段落内容</p>'
```
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID不要复用旧 ID。
### block_delete — 删除指定 block
```bash
@@ -234,6 +238,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **同一插入位置优先合并写入**:不要对同一个 `--block-id` 多次执行 `block_insert_after` 来追加多段内容;这会让后插入的内容出现在前插入内容之前。把连续内容合并到一次 `--content`,或每次插入后重新获取最后一个新 block 的 ID 作为下一次锚点
- **block_replace 后重新获取 ID**`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
1. 用 `block_insert_after` 在目标位置插入新的富文本结构

View File

@@ -10,6 +10,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
| `<title>` | 文档标题(每篇唯一)| `align` |
| `<checkbox>` | 待办项| `done="true"\|"false"` |
## 创建文档标题
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`
## 容器标签
|标签|说明|关键属性|
|-|-|-|
@@ -77,6 +81,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
</ul>
```
## 代码块
- 代码块必须写成 `<pre lang="xxx" caption="可选说明"><code>代码内容</code></pre>`。
- 不要将代码文本直接放在 `<pre>` 下;应放在内层 `<code>` 中。
## 用户名写入规则

View File

@@ -22,7 +22,7 @@
2. 设计大纲——每个 h1/h2 章节至少规划 1 个非文本 block承载重要信息的章节优先规划画板
3. `docs +create --api-version v2` **只建骨架**:标题 + 开头 `<callout>` + 各级标题 + 每节一句占位摘要
- ⚠️ **不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到第二波,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
- 完整内容留到第二波,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 写入;同一章节内的连续内容优先合并成一次 `--content`,不要多次复用同一个章节标题 block_id 追加,否则后写入内容会排在前写入内容之前
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
### 第二波 — 内容撰写(并行 Agent
@@ -30,7 +30,7 @@
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、期望的 block 类型
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容;每个 Agent 对自己的章节尽量一次性写入完整片段。若必须分多次插入,第二次起必须先重新 `fetch --detail with-ids` 获取上一次新插入的最后一个 block ID并把它作为新的插入锚点。
### 第三波 — 整合审查 + 画板意图识别(串行)

View File

@@ -19,7 +19,7 @@ metadata:
## 快速决策
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--mine`实为 owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
@@ -110,7 +110,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|----------|
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象支持 `--edited-since``--mine``--doc-types` 等扁平 flag。 |
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象支持 `--edited-since``--created-by-me``--mine``--doc-types` 等扁平 flag;区分 original creator 与 owner 语义。 |
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |

View File

@@ -9,6 +9,11 @@
> 这是 Drive 导入场景,不是 `lark-base` 的建表 / 写记录场景。
> 只有导入完成并拿到新文档的 `token` / `url` 后,后续字段、记录、视图等表内操作才切换到 `lark-cli base +...`。
## 导入后标题确认
> [!IMPORTANT]
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
## 命令
```bash

View File

@@ -7,10 +7,10 @@
核心特性:
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 把常用过滤条件全部**扁平化为独立 flag**`--edited-since``--created-by-me``--mine``--doc-types``--folder-tokens` 等),不再要求用户或 AI 手写嵌套 `--filter` JSON
- 额外暴露了 4 个"我"维度:`my_edit_time`(我编辑过)、`my_comment_time`(我评论过)、`open_time`(我打开过)、`create_time`(文档创建时间)——直接对应用户自然语言里的"最近我编辑过的"、"我评论过的"等表达
- 自动处理 `my_edit_time` / `my_comment_time` 的小时级聚合(服务端存储粒度):亚小时输入会向整点 snap并在 stderr 打出提示
- `--mine` 一键从当前登录用户的 open_id 填 `creator_ids`不必再先去查 contact注意 `creator_ids` 服务端按 **owner / 文档归属人** 语义匹配,不是“最初创建人”,详见下文「身份维度」)
- `--created-by-me` 一键从当前登录用户的 open_id 填 `original_creator_ids`匹配“我最初创建的”;`--mine` 仍填 `creator_ids`,匹配 owner / 文档归属人
> **资源发现入口统一**`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill如 `lark-sheets`)做对象内部操作。
@@ -21,18 +21,19 @@
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --mine --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
| 最近一个月我编辑过 且 我评论过的 | `lark-cli drive +search --query "" --edited-since 1m --commented-since 1m` |
| 最近一周我打开过的表格 | `lark-cli drive +search --query "" --opened-since 7d --doc-types sheet` |
| 我 owner 的所有文档owner 语义,非"我最初创建" | `lark-cli drive +search --query "" --mine` |
| 我最初创建、后来转给王五 owner 的文档 | `lark-cli drive +search --query "" --created-by-me --creator-ids ou_wangwu` |
| 我 owner、30-60 天前创建的文档(粗略"上个月",按 30 天滑窗算;`--mine` 是 owner`--created-*` 才是文档创建时间) | `lark-cli drive +search --query "" --mine --created-since 2m --created-until 1m` |
| 我 owner、2026 年 3 月创建的文档精确日历月同上owner + 创建时间窗两个维度) | `lark-cli drive +search --query "" --mine --created-since 2026-03-01 --created-until 2026-04-01` |
| 关键词"预算",最近一周我打开过,按编辑时间降序 | `lark-cli drive +search --query 预算 --opened-since 7d --sort edit_time` |
@@ -77,7 +78,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
对"所有文档"、"按类型分类统计"、"最近更新过"这类请求,不要只跑一次搜索后直接回答。标准流程:
1. 先把自然语言拆成过滤条件:所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
1. 先把自然语言拆成过滤条件:原始创建者(`--created-by-me` / `--original-creator-ids`)、所有权(`--mine` / `--creator-ids`)、时间维度(`--created-*` / `--edited-*` / `--opened-*` / `--commented-*`)、类型(`--doc-types`)、空间或文件夹范围。
2. 没有真实业务关键词时保持 `--query ""`;不要把"所有文档"、"统计"、"最近更新"放进 query。
3. 检查返回结果的 `doc_type` / `result_meta.doc_types`、创建/编辑时间和 URL/token 是否与过滤目标一致;明显不符合的结果不要计入答案。
4. 用户要求"所有 / 全量 / 统计"时按 `has_more` 翻页并累积去重;不要只用第一页推断总量。返回体里的 `total` 不可靠,统计要以实际去重后的结果为准。
@@ -103,14 +104,16 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |
### 身份owner 维度API 字段名 `creator_ids`
### 身份维度
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然 OpenAPI 字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**:我创建后转交他人的文档不会命中,他人创建后转给我(我成为 owner的会命中。用户说“我的 / 我创建的 / 我负责的”文档都路由到 `--mine`,但要清楚它返回的是“我 owner 的”
> **语义说明(重要)**`creator_ids`(含 `--mine` / `--creator-ids`)虽然字段名是 “creator”但服务端实际按 **owner文档归属人 / 负责人)** 语义匹配,**不是“最初创建人”**。真正的原始创建者使用 `original_creator_ids`CLI 为 `--created-by-me` / `--original-creator-ids`
| 参数 | 映射 | 说明 |
|---|---|---|
| `--mine` | `creator_ids = [当前用户 open_id]` | bool。一键“我 owner 的”(**不是**“我最初创建的”);从当前登录用户身份(`runtime.UserOpenId()`)解析 open_id取不到直接报错提示运行 `lark-cli auth login` |
| `--creator-ids ou_x,ou_y` | `creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按 **owner** 匹配;**与 `--mine` 互斥** |
| `--created-by-me` | `original_creator_ids = [当前用户 open_id]` | bool。一键“我最初创建的”从当前登录用户身份解析 open_id取不到直接报错 |
| `--original-creator-ids ou_x,ou_y` | `original_creator_ids = [...]` | 显式 open_id 列表,逗号分隔,按**原始创建者**匹配;**与 `--created-by-me` 互斥** |
### 时间维度(每个维度一对 since/until
@@ -187,7 +190,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
## 决策规则
- **身份快捷方式**:用户说“我的 / 我建的 / 我负责的”文档,直接 `--mine` 即可,不需要先查 contact 拿 open_id。注意 `--mine`**owner** 语义(我归属/负责的),不是“我最初创建的”——转交出去的不算、转交给我的算。
- **身份快捷方式**:用户说“我创建的 / 我建的 / 我最初创建的”文档, `--created-by-me`;用户说“我的 / 我负责的 / 我 owner 的”文档,用 `--mine``--mine` 是 owner 语义:转交出去的不算、转交给我的算。
- **时间维度选择**
- "我编辑的"、"我修改的" → `--edited-since` / `--edited-until`
- "我评论的"、"我回复过的" → `--commented-since` / `--commented-until`
@@ -197,10 +200,10 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- "某个文件夹下" → `--folder-tokens`doc-only
- "某个 wiki 空间下" → `--space-ids`wiki-only
- 两者不能同时使用,混用会报错
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传,会直接报错。“我和张三的”(owner`--creator-ids ou_me,ou_zhangsan`(需要先拿到自己 open_id但这种场景少见
- **身份 flag 互斥**`--mine``--creator-ids` 不要同时传`--created-by-me``--original-creator-ids` 不要同时传。owner 维度与原始创建者维度可以组合,例如“我创建后转给王五 owner`--created-by-me --creator-ids ou_wangwu`
- **实体补全**
- 用户说"某个群里",先用 `lark-im``chat_id`
- 用户说“某人的 / 某人分享的”(非自己`--creator-ids` 按 owner 匹配),先用 `lark-contact` 查 open_id再填 `--creator-ids` / `--sharer-ids`
- 用户说“某人负责/owner 的 / 某人创建的 / 某人分享的”(非自己),先用 `lark-contact` 查 open_id按语义`--creator-ids` / `--original-creator-ids` / `--sharer-ids`
- **查询语义下推**`--query` 支持的服务端高级语法(`intitle:``""``OR``-`)优先使用,不要先模糊搜再在客户端二次过滤。
- **query 填写边界**:只有标题片段、业务名词、项目名、会议名、文件内容关键词才应进入 `--query`。仅描述动作、时间范围、所有权、统计方式的词不算关键词,保持 `--query ""` 并依赖 filters。
- **证据核验**:列表/统计类答案必须来自搜索结果中的实际 URL/token 和类型/时间字段;内容问答必须能指出使用了哪些非污染候选。没有可验证候选时先扩大 query 或翻页,不要直接编总结。
@@ -222,7 +225,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
| code | 含义 | 处理 |
|---|---|---|
| `99992351` | `--creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
| `99992351` | `--creator-ids` / `--original-creator-ids` / `--sharer-ids` 里有 open_id 超出**应用的通讯录可见范围**,服务端拒绝识别 | 让管理员在开发者后台把这些用户加进应用的"通讯录可见性"授权里;或把超出范围的 open_id 从参数里去掉。这和 `search:docs:read` scope 不是一回事 —— 是"应用能看见哪些人"而不是"应用能调用哪个接口" |
## 时间范围自动裁剪(`--opened-*` 专有)

View File

@@ -222,11 +222,27 @@ lark-cli im +messages-reply --message-id om_xxx --text "Let me take a look at th
The reply appears in the target message's thread and does not show up in the main chat stream.
## @Mention Format (text / post)
## @Mention Format
- Recommended format: `<at user_id="ou_xxx">name</at>`
The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
### `text`
- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
- @all: `<at user_id="all"></at>`
- The shortcut normalizes common variants like `<at id=...>` and `<at open_id=...>` into `user_id`, but `user_id` remains the recommended documented form
### `post`
- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
### `interactive` (card)
Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
- single user by open_id: `<at id=ou_xxx></at>`
- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
- by email: `<at email=user@example.com></at>`
## Notes

View File

@@ -224,11 +224,27 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
}
```
## @Mention Format (text / post)
## @Mention Format
- Recommended format: `<at user_id="ou_xxx">name</at>`
The `<at>` syntax differs by message type. The shortcut only normalizes mentions for `text` and `post`; `interactive` card content is passed through verbatim, so cards must use the card-native syntax below.
### `text`
- `<at user_id="ou_xxx">name</at>` — the inner text is the mentioned user's display name and is optional (`<at user_id="ou_xxx"></at>` also works)
- @all: `<at user_id="all"></at>`
- The shortcut normalizes common variants like `<at id=...>` and `<at open_id=...>` into `user_id`, but you should still document examples with `user_id`
### `post`
- Inside a `text` or `md` element, the same inline form as `text` works: `<at user_id="ou_xxx">name</at>`
- Or use a dedicated `at` element node: `{"tag":"at","user_id":"ou_xxx"}` (use `"all"` to mention everyone)
### `interactive` (card)
Card content is **not** normalized — use the card-native `<at>` syntax inside a `lark_md` / `markdown` element:
- single user by open_id: `<at id=ou_xxx></at>`
- multiple users: `<at ids=ou_xxx1,ou_xxx2></at>`
- by email: `<at email=user@example.com></at>`
## Notes

View File

@@ -52,10 +52,11 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用,如通过别名或 send_as 地址发信。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可`--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--request-receipt` | 否 | 请求已读回执RFC 3798 Message Disposition Notification。在草稿 EML 里写 `Disposition-Notification-To: <sender>` 头,发送时生效。收件人的邮件客户端可能弹出提示、自动发送或忽略——送达不保证 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |

View File

@@ -68,10 +68,11 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可`--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |

View File

@@ -72,10 +72,11 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
| `--cc <emails>` | 否 | 额外抄送,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可`--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |

View File

@@ -75,10 +75,11 @@ lark-cli mail +reply --message-id <邮件ID> --body '<p>测试</p>' --dry-run
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到原发件人) |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔。与 `--event-*` 不兼容(见 `+send` 日程邀请约束) |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可`--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601 |

View File

@@ -75,10 +75,11 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '<p>test</p
| `--mailbox <email>` | 否 | 邮箱地址,指定草稿所属的邮箱(默认回退到 `--from`,再回退到 `me`)。当发件人(`--from`)与邮箱不同时使用。可通过 `accessible_mailboxes` 查询可用邮箱 |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用。纯文本模式下也会自动追加纯文本签名HTML 签名经 `PlainTextFromHTML` 转换,内联图片丢弃) |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径。当附件导致 EML 总大小超过 25 MB 时超出部分自动上传为超大附件HTML 邮件插入下载卡片,纯文本邮件追加下载链接),单个文件上限 3 GB |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可`--plain-text` 同时使用 |
| `--signature-id <id>` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。与 `--no-signature` 互斥 |
| `--no-signature` | 否 | 跳过默认签名自动追加。与 `--signature-id` 互斥,同时使用时返回参数校验错误(退出码 2 |
| `--priority <level>` | 否 | 邮件优先级:`high``normal``low`。省略或 `normal` 时不设置优先级 |
| `--event-summary <text>` | 否 | 日程标题。设置此参数即在邮件中嵌入日程邀请text/calendar。需同时设置 `--event-start``--event-end` |
| `--event-start <time>` | 条件必填 | 日程开始时间ISO 8601`2026-04-20T14:00+08:00` |