diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index a4048ff15e..0d476e898f 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -59,7 +59,8 @@ var MailTriage = common.Shortcut{ {Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"}, {Name: "page-size", Type: "int", Desc: "alias for --max"}, {Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"}, - {Name: "filter", Desc: `exact-match condition filter (JSON). Narrow results by folder, label, sender, recipient, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`}, + {Name: "filter", Desc: `exact-match condition filter (JSON or shorthand). Examples: {"folder":"INBOX","from":["alice@example.com"]}, folder_id=DRAFT, is_unread, is_read. Run --print-filter-schema to see all fields.`}, + {Name: "folder", Desc: `folder name or ID shortcut merged into --filter. Example: --folder=INBOX --filter=is_unread. Cannot be combined with --filter folder/folder_id.`}, {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, {Name: "query", Desc: `full-text keyword search across from/to/subject/body (max 50 chars). Example: "budget report"`}, {Name: "labels", Type: "bool", Desc: "include label IDs in output"}, @@ -74,8 +75,8 @@ var MailTriage = common.Shortcut{ showLabels := runtime.Bool("labels") maxCount := resolveTriagePageSize(runtime) parsed, parseErr := parseTriagePageToken(runtime.Str("page-token")) - filter, err := parseTriageFilter(runtime.Str("filter")) - d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")) + filter, err := triageFilterFromRuntime(runtime) + d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")).Set("input_folder", runtime.Str("folder")) if parseErr != nil { return d.Set("filter_error", parseErr.Error()) } @@ -146,7 +147,7 @@ var MailTriage = common.Shortcut{ } } showLabels := runtime.Bool("labels") - filter, err := parseTriageFilter(runtime.Str("filter")) + filter, err := triageFilterFromRuntime(runtime) if err != nil { return err } @@ -328,6 +329,9 @@ var MailTriage = common.Shortcut{ if filterStr := runtime.Str("filter"); filterStr != "" { hint.WriteString(" --filter " + shellQuote(filterStr)) } + if folder := runtime.Str("folder"); folder != "" { + hint.WriteString(" --folder " + shellQuote(folder)) + } hint.WriteString(" --page-token " + shellQuote(nextPageToken)) fmt.Fprintln(runtime.IO().ErrOut, hint.String()) } @@ -344,7 +348,7 @@ var MailTriage = common.Shortcut{ func printTriageFilterSchema(runtime *common.RuntimeContext) { schema := map[string]interface{}{ - "_description": "--filter field reference for mail +triage. All fields are optional. --filter narrows results by exact conditions; --query does full-text search.", + "_description": "--filter field reference for mail +triage. All fields are optional. --filter narrows results by exact conditions; --folder is a shortcut for folder filtering; --query does full-text search.", "fields": map[string]interface{}{ "folder": map[string]string{ "type": "string", @@ -398,7 +402,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "is_unread": map[string]string{ "type": "bool", - "desc": "Filter by read status. On list path only is_unread=true is supported; on search path both true/false work.", + "desc": "Filter by read status. is_unread=true may use the list path; is_unread=false always uses the search path.", "example": "true", }, "time_range": map[string]string{ @@ -409,12 +413,17 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "notes": []string{ "folder/folder_id and label/label_id work on both list and search paths.", - "from, to, cc, bcc, subject, has_attachment, time_range trigger the search path.", + "from, to, cc, bcc, subject, has_attachment, time_range, and is_unread=false trigger the search path.", + "--filter also accepts shorthand: folder_id=DRAFT, folder=INBOX, is_unread/unread, is_read/read.", + "--folder=INBOX can be combined with --filter=is_unread, but conflicts with --filter folder/folder_id.", "--query and search-path filter fields can be combined.", "folder and label cannot be set at the same time on the list path.", "System labels (IMPORTANT/FLAGGED/OTHER) are automatically passed as folder (priority/flagged/other) in search.", }, "examples": []string{ + `folder_id=DRAFT`, + `is_unread`, + `is_read`, `{"folder":"INBOX"}`, `{"folder":"INBOX","from":["alice@example.com"]}`, `{"label":"FLAGGED","is_unread":true}`, @@ -426,20 +435,76 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { func parseTriageFilter(filterStr string) (triageFilter, error) { var filter triageFilter - if strings.TrimSpace(filterStr) == "" { + raw := strings.TrimSpace(filterStr) + if raw == "" { return filter, nil } - dec := json.NewDecoder(strings.NewReader(filterStr)) + dec := json.NewDecoder(strings.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&filter); err != nil { if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" { return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint) } - return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", err) + shorthand, shorthandErr := parseTriageFilterShorthand(raw) + if shorthandErr == nil { + return shorthand, nil + } + return triageFilter{}, shorthandErr } return filter, nil } +func triageFilterFromRuntime(runtime *common.RuntimeContext) (triageFilter, error) { + filter, err := parseTriageFilter(runtime.Str("filter")) + if err != nil { + return triageFilter{}, err + } + return mergeTriageFolderFlag(filter, runtime.Str("folder")) +} + +func mergeTriageFolderFlag(filter triageFilter, folder string) (triageFilter, error) { + folder = strings.TrimSpace(folder) + if folder == "" { + return filter, nil + } + if strings.TrimSpace(filter.Folder) != "" || strings.TrimSpace(filter.FolderID) != "" { + return triageFilter{}, mailValidationParamError("--folder", "invalid --folder: cannot combine with --filter folder/folder_id") + } + filter.Folder = folder + return filter, nil +} + +func parseTriageFilterShorthand(raw string) (triageFilter, error) { + value := strings.TrimSpace(raw) + switch strings.ToLower(value) { + case "is_unread", "unread": + return triageFilter{IsUnread: triageBoolPtr(true)}, nil + case "is_read", "read": + return triageFilter{IsUnread: triageBoolPtr(false)}, nil + } + if strings.Contains(value, "=") { + key, val, _ := strings.Cut(value, "=") + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if val == "" { + return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: shorthand field %q requires a non-empty value", key) + } + switch key { + case "folder": + return triageFilter{Folder: val}, nil + case "folder_id": + return triageFilter{FolderID: val}, nil + default: + return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unsupported shorthand field %q; use JSON for complex filters", key) + } + } + return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unsupported shorthand %q", value) +} + +func triageBoolPtr(v bool) *bool { + return &v +} + func triageFilterUnknownFieldHint(msg string) string { const unknownFieldPrefix = `json: unknown field "` if !strings.HasPrefix(msg, unknownFieldPrefix) || !strings.HasSuffix(msg, `"`) { @@ -471,6 +536,9 @@ func usesTriageSearchPath(query string, filter triageFilter) bool { if strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0 { return true } + if filter.IsUnread != nil && !*filter.IsUnread { + return true + } // System labels (important/flagged/other and their aliases) can appear in either // folder or label field. They always require the search path because the search API // treats them as folder values, and they are not real folder IDs for the list API. diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 50f08fae16..da32d3d7de 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -224,6 +224,100 @@ func TestParseTriageFilterUnknownFieldHintUnread(t *testing.T) { } } +func TestParseTriageFilterShorthand(t *testing.T) { + tests := []struct { + name string + input string + wantFolder string + wantFolderID string + wantUnread *bool + }{ + {name: "folder id", input: "folder_id=DRAFT", wantFolderID: "DRAFT"}, + {name: "folder", input: "folder=INBOX", wantFolder: "INBOX"}, + {name: "is unread", input: "is_unread", wantUnread: boolPtr(true)}, + {name: "unread alias", input: "unread", wantUnread: boolPtr(true)}, + {name: "is read", input: "is_read", wantUnread: boolPtr(false)}, + {name: "read alias", input: "read", wantUnread: boolPtr(false)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTriageFilter(tt.input) + if err != nil { + t.Fatalf("parseTriageFilter(%q): %v", tt.input, err) + } + if got.Folder != tt.wantFolder { + t.Fatalf("Folder = %q, want %q", got.Folder, tt.wantFolder) + } + if got.FolderID != tt.wantFolderID { + t.Fatalf("FolderID = %q, want %q", got.FolderID, tt.wantFolderID) + } + if tt.wantUnread == nil { + if got.IsUnread != nil { + t.Fatalf("IsUnread = %v, want nil", *got.IsUnread) + } + return + } + if got.IsUnread == nil || *got.IsUnread != *tt.wantUnread { + t.Fatalf("IsUnread = %v, want %v", got.IsUnread, *tt.wantUnread) + } + }) + } +} + +func TestParseTriageFilterShorthandRejectsInvalidInput(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "folder_id=", want: "requires a non-empty value"}, + {input: "label=important", want: "unsupported shorthand field"}, + {input: "unknown", want: "unsupported shorthand"}, + } + for _, tt := range tests { + _, err := parseTriageFilter(tt.input) + if err == nil { + t.Fatalf("parseTriageFilter(%q) expected error", tt.input) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("parseTriageFilter(%q) error = %v, want substring %q", tt.input, err, tt.want) + } + } +} + +func TestTriageFilterFromRuntimeMergesFolderFlag(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "folder": "INBOX", + "filter": "is_unread", + }) + got, err := triageFilterFromRuntime(runtime) + if err != nil { + t.Fatalf("triageFilterFromRuntime: %v", err) + } + if got.Folder != "INBOX" { + t.Fatalf("Folder = %q, want INBOX", got.Folder) + } + if got.IsUnread == nil || !*got.IsUnread { + t.Fatalf("IsUnread = %v, want true", got.IsUnread) + } +} + +func TestTriageFilterFromRuntimeRejectsFolderConflict(t *testing.T) { + tests := []string{"folder_id=DRAFT", `{"folder":"SENT"}`} + for _, filter := range tests { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "folder": "INBOX", + "filter": filter, + }) + _, err := triageFilterFromRuntime(runtime) + if err == nil { + t.Fatalf("filter %q expected conflict", filter) + } + if !strings.Contains(err.Error(), "cannot combine with --filter folder/folder_id") { + t.Fatalf("unexpected error for %q: %v", filter, err) + } + } +} + func TestBuildSearchParamsDoesNotSetUserMailboxIDInBody(t *testing.T) { runtime := runtimeForMailTriageTest(t, map[string]string{"query": "hello"}) params, body, err := buildSearchParams(runtime, "", runtime.Str("query"), triageFilter{}, 15, "", true) @@ -290,6 +384,52 @@ func TestMailTriageDryRunListPathUsesMessagesAndBatchGet(t *testing.T) { } } +func TestMailTriageDryRunFolderFlagAndUnreadShorthandUsesListPath(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "folder": "INBOX", + "filter": "is_unread", + }) + + apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime)) + if len(apis) < 1 { + t.Fatalf("expected at least 1 dry-run api, got %d", len(apis)) + } + if apis[0].Method != "GET" || apis[0].URL != mailboxPath("me", "messages") { + t.Fatalf("expected list path, got %s %s", apis[0].Method, apis[0].URL) + } + if apis[0].Params["folder_id"] != "INBOX" { + t.Fatalf("folder_id = %v, want INBOX", apis[0].Params["folder_id"]) + } + if apis[0].Params["only_unread"] != true { + t.Fatalf("only_unread = %v, want true", apis[0].Params["only_unread"]) + } +} + +func TestMailTriageDryRunReadShorthandUsesSearchPath(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": "is_read", + }) + + apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime)) + if len(apis) < 1 { + t.Fatalf("expected at least 1 dry-run api, got %d", len(apis)) + } + if apis[0].Method != "POST" || apis[0].URL != mailboxPath("me", "search") { + t.Fatalf("expected search path, got %s %s", apis[0].Method, apis[0].URL) + } + body, ok := apis[0].Body.(map[string]interface{}) + if !ok { + t.Fatalf("body = %#v, want object", apis[0].Body) + } + filterBody, ok := body["filter"].(map[string]interface{}) + if !ok { + t.Fatalf("filter body = %#v, want object", body["filter"]) + } + if filterBody["is_unread"] != false { + t.Fatalf("is_unread = %v, want false", filterBody["is_unread"]) + } +} + func TestMailTriageDryRunListPathCapsPageSizeAtAPILimit(t *testing.T) { runtime := runtimeForMailTriageTest(t, map[string]string{ "max": "50", @@ -1152,6 +1292,21 @@ func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) { } } +func TestResolveTriagePathReadStatusFalseUsesSearch(t *testing.T) { + useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{IsUnread: boolPtr(false)}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("is_unread=false must use search path") + } + + _, err = resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{IsUnread: boolPtr(false)}) + if err == nil { + t.Fatal("expected list: token conflict for is_unread=false") + } +} + func TestResolveTriagePathListTokenWithListFilter(t *testing.T) { // list: token + list-compatible filter → OK useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{Folder: "inbox"}) diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index 06ee7fc0b5..ed0856ce58 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -270,7 +270,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+message`](references/lark-mail-message.md) | Use only when reading full content for one email by one message ID. For multiple message IDs, use `mail +messages`; do not loop `mail +message`. | | [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Accepts comma-separated message IDs; CLI handles more than 20 IDs in batches and merges output. | | [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. | -| [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. | +| [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter JSON/shorthand or --folder for exact-match conditions. | | [`+watch`](references/lark-mail-watch.md) | Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output. | | [`+reply`](references/lark-mail-reply.md) | Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically. | | [`+reply-all`](references/lark-mail-reply-all.md) | Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically. | diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 100b8f581a..9524c02fcf 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -13,6 +13,7 @@ lark-cli mail +triage # 查看收件箱未读 lark-cli mail +triage --filter '{"folder":"inbox","is_unread":true}' +lark-cli mail +triage --folder=INBOX --filter=is_unread # 全文搜索 lark-cli mail +triage --query "合同审批" @@ -25,6 +26,7 @@ lark-cli mail +triage --query "项目评审" --filter '{"time_range":{"start_tim # 指定文件夹 lark-cli mail +triage --filter '{"folder":"sent"}' +lark-cli mail +triage --filter folder_id=DRAFT # 系统标签(可通过 folder 或 label 传入,搜索时自动转为 folder) lark-cli mail +triage --filter '{"folder":"flagged"}' @@ -47,7 +49,8 @@ lark-cli mail +triage --page-size 10 | 参数 | 默认 | 说明 | |------|------|------| -| `--filter ` | — | 筛选条件(见下方字段说明) | +| `--filter ` | — | 筛选条件(见下方字段说明)。支持 JSON,也支持 `folder_id=DRAFT`、`folder=INBOX`、`is_unread`/`unread`、`is_read`/`read` | +| `--folder ` | — | 文件夹名称或 ID 的拆分写法,会归并为 filter folder;可与 `--filter=is_unread` 搭配,不能与 `--filter` 中的 `folder`/`folder_id` 同时使用 | | `--query ` | — | 全文搜索关键词 | | `--format ` | `table` | `table` / `json` / `data`(`json` 和 `data` 均输出含分页信息的对象) | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | @@ -73,7 +76,7 @@ lark-cli mail +triage --page-size 10 > **系统标签说明**:`IMPORTANT`/`FLAGGED`/`OTHER` 可通过 `folder` 或 `label` 传入(也支持中文别名 `重要邮件`/`已加旗标`/`其他邮件`、搜索名 `priority`/`flagged`/`other`)。搜索时自动转为 folder 字段,列表时自动转为 label_id。label list 接口不返回这三个系统标签。 > -> **⚠️ 注意**:查询未读请用 `"is_unread":true`。 +> **简写说明**:常用筛选可直接写 `--filter folder_id=DRAFT`、`--filter=folder=INBOX`、`--filter=is_unread`、`--filter=is_read`。`is_read` 会转换为 `is_unread=false` 并走 search 路径,避免 list API 忽略已读过滤。 可运行 `mail +triage --print-filter-schema` 查看完整字段说明。 ## 输出