From 236249eb6d70c18d0657df7c2699a353ca975b42 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Tue, 7 Apr 2026 22:44:41 +0800 Subject: [PATCH 1/8] feat(mail): add --page-token and --page-size pagination support to mail +triage Support external pagination for mail +triage with two new flags: - --page-token: resume from a previous response's page token - --page-size: alias for --max Token carries a "search:" or "list:" prefix to identify the API path, with strict validation: conflicting parameters (e.g. list: token with --query) fail fast, and bare tokens without prefix are rejected. JSON/data output now returns an object with messages, total, has_more, and page_token fields. Table output shows next-page hint on stderr. --- shortcuts/mail/mail_triage.go | 98 ++++- shortcuts/mail/mail_triage_test.go | 345 ++++++++++++++++++ .../lark-mail/references/lark-mail-triage.md | 48 ++- 3 files changed, 470 insertions(+), 21 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 9b375ab22..33b32f562 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -54,8 +54,10 @@ var MailTriage = common.Shortcut{ Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "format", Default: "table", Desc: "output format: table | json | data (both json/data output messages array only)"}, + {Name: "format", Default: "table", Desc: "output format: table | json | data"}, {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: "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"`}, @@ -66,13 +68,18 @@ var MailTriage = common.Shortcut{ mailbox := resolveMailboxID(runtime) query := runtime.Str("query") showLabels := runtime.Bool("labels") - maxCount := normalizeTriageMax(runtime.Int("max")) + maxCount := resolveTriagePageSize(runtime) + inputPageToken := runtime.Str("page-token") filter, err := parseTriageFilter(runtime.Str("filter")) d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")) if err != nil { return d.Set("filter_error", err.Error()) } - if usesTriageSearchPath(query, filter) { + useSearch, pathErr := resolveTriagePath(inputPageToken, query, filter) + if pathErr != nil { + return d.Set("filter_error", pathErr.Error()) + } + if useSearch { resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, true) if err != nil { return d.Set("filter_error", err.Error()) @@ -81,7 +88,8 @@ var MailTriage = common.Shortcut{ if pageSize > searchPageMax { pageSize = searchPageMax } - searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true) + initialToken := strings.TrimPrefix(inputPageToken, "search:") + searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, initialToken, true) d = d.POST(mailboxPath(mailbox, "search")). Params(searchParams). Body(searchBody). @@ -101,7 +109,8 @@ var MailTriage = common.Shortcut{ if pageSize > listPageMax { pageSize = listPageMax } - listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true) + initialToken := strings.TrimPrefix(inputPageToken, "list:") + listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, initialToken, true) return d.GET(mailboxPath(mailbox, "messages")). Params(listParams). POST(mailboxPath(mailbox, "messages", "batch_get")). @@ -128,16 +137,24 @@ var MailTriage = common.Shortcut{ if err != nil { return err } - maxCount := normalizeTriageMax(runtime.Int("max")) + maxCount := resolveTriagePageSize(runtime) + inputPageToken := runtime.Str("page-token") var messages []map[string]interface{} + var hasMore bool + var nextPageToken string + + useSearch, err := resolveTriagePath(inputPageToken, query, filter) + if err != nil { + return err + } - if usesTriageSearchPath(query, filter) { + if useSearch { resolvedFilter, err := resolveSearchFilter(runtime, mailbox, filter, false) if err != nil { return err } - var pageToken string + pageToken := strings.TrimPrefix(inputPageToken, "search:") for len(messages) < maxCount { pageSize := maxCount - len(messages) if pageSize > searchPageMax { @@ -161,8 +178,12 @@ var MailTriage = common.Shortcut{ pageHasMore, _ := searchData["has_more"].(bool) pageToken, _ = searchData["page_token"].(string) if !pageHasMore || pageToken == "" { + hasMore = false + nextPageToken = "" break } + hasMore = pageHasMore + nextPageToken = "search:" + pageToken } if len(messages) > maxCount { messages = messages[:maxCount] @@ -185,7 +206,7 @@ var MailTriage = common.Shortcut{ } var ( messageIDs []string - pageToken string + pageToken = strings.TrimPrefix(inputPageToken, "list:") ) for len(messageIDs) < maxCount { pageSize := maxCount - len(messageIDs) @@ -209,8 +230,12 @@ var MailTriage = common.Shortcut{ pageHasMore, _ := listData["has_more"].(bool) pageToken, _ = listData["page_token"].(string) if !pageHasMore || pageToken == "" { + hasMore = false + nextPageToken = "" break } + hasMore = pageHasMore + nextPageToken = "list:" + pageToken } if len(messageIDs) > maxCount { messageIDs = messageIDs[:maxCount] @@ -221,9 +246,19 @@ var MailTriage = common.Shortcut{ } } + if messages == nil { + messages = []map[string]interface{}{} + } + switch outFormat { case "json", "data": - output.PrintJson(runtime.IO().Out, messages) + outData := map[string]interface{}{ + "messages": messages, + "total": len(messages), + "has_more": hasMore, + "page_token": nextPageToken, + } + output.PrintJson(runtime.IO().Out, outData) default: // "table" if len(messages) == 0 { fmt.Fprintln(runtime.IO().ErrOut, "No messages found.") @@ -244,6 +279,9 @@ var MailTriage = common.Shortcut{ } output.PrintTable(runtime.IO().Out, rows) fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages)) + if hasMore && nextPageToken != "" { + fmt.Fprintf(runtime.IO().ErrOut, "next page: mail +triage --page-token '%s' ...\n", nextPageToken) + } fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id to read full content") } return nil @@ -841,6 +879,46 @@ func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} { return createTime } +// resolveTriagePath determines whether to use the search API path, +// validating that --page-token prefix is consistent with query/filter params. +// +// Rules: +// - No token: path decided by usesTriageSearchPath(query, filter). +// - "search:" prefix: must not have list-only params (no query/search filter fields is OK for continuation). +// - "list:" prefix: must not have query or search-only filter fields that would be silently ignored. +// - Bare token (no prefix): rejected — all tokens emitted by triage carry a prefix. +func resolveTriagePath(pageToken, query string, filter triageFilter) (useSearch bool, err error) { + if pageToken == "" { + return usesTriageSearchPath(query, filter), nil + } + paramWantsSearch := usesTriageSearchPath(query, filter) + switch { + case strings.HasPrefix(pageToken, "search:"): + if !paramWantsSearch && (query != "" || len(triageQueryFilterFields(filter)) > 0) { + // This shouldn't normally happen (query/search fields → paramWantsSearch=true), + // but guard against future changes. + return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token") + } + return true, nil + case strings.HasPrefix(pageToken, "list:"): + if paramWantsSearch { + return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored — remove them or use a search: token") + } + return false, nil + default: + return false, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)") + } +} + +// resolveTriagePageSize returns the effective max count from --page-size or --max. +// --page-size is an alias for --max; if both are set, --page-size takes priority. +func resolveTriagePageSize(runtime *common.RuntimeContext) int { + if ps := runtime.Int("page-size"); ps > 0 { + return normalizeTriageMax(ps) + } + return normalizeTriageMax(runtime.Int("max")) +} + func normalizeTriageMax(maxCount int) int { if maxCount <= 0 { return 20 diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 4ea728e0c..17208bffe 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -967,4 +967,349 @@ func TestBuildSearchParamsPageToken(t *testing.T) { } } +// --- resolveTriagePageSize --- + +func TestResolveTriagePageSizeDefaultMax(t *testing.T) { + rt := runtimeForMailTriageTest(t, nil) // max defaults to "20" + got := resolveTriagePageSize(rt) + if got != 20 { + t.Fatalf("expected 20, got %d", got) + } +} + +func TestResolveTriagePageSizeFromMax(t *testing.T) { + rt := runtimeForMailTriageTest(t, map[string]string{"max": "30"}) + got := resolveTriagePageSize(rt) + if got != 30 { + t.Fatalf("expected 30, got %d", got) + } +} + +func TestResolveTriagePageSizeFromPageSize(t *testing.T) { + rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "10"}) + got := resolveTriagePageSize(rt) + if got != 10 { + t.Fatalf("expected 10, got %d", got) + } +} + +func TestResolveTriagePageSizePageSizeOverridesMax(t *testing.T) { + rt := runtimeForMailTriageTest(t, map[string]string{"max": "30", "page-size": "5"}) + got := resolveTriagePageSize(rt) + if got != 5 { + t.Fatalf("expected page-size=5 to override max=30, got %d", got) + } +} + +func TestResolveTriagePageSizeClamped(t *testing.T) { + rt := runtimeForMailTriageTest(t, map[string]string{"page-size": "999"}) + got := resolveTriagePageSize(rt) + if got != 400 { + t.Fatalf("expected clamped to 400, got %d", got) + } +} + +// --- page-token path validation --- + +func TestResolveTriagePathSearchTokenContinuation(t *testing.T) { + // search: token without --query is valid (continuation) + useSearch, err := resolveTriagePath("search:abc123", "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("search: prefix should select search path") + } +} + +func TestResolveTriagePathListTokenConflictsWithQuery(t *testing.T) { + // list: token + --query → error (query would be silently ignored) + _, err := resolveTriagePath("list:abc123", "hello", triageFilter{}) + if err == nil { + t.Fatal("expected error for list: token with --query") + } +} + +func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) { + // list: token + search-only filter field → error + _, err := resolveTriagePath("list:abc123", "", triageFilter{From: []string{"a@b.com"}}) + if err == nil { + t.Fatal("expected error for list: token with search-only filter") + } +} + +func TestResolveTriagePathListTokenWithListFilter(t *testing.T) { + // list: token + list-compatible filter → OK + useSearch, err := resolveTriagePath("list:abc123", "", triageFilter{Folder: "inbox"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if useSearch { + t.Fatal("list: prefix should select list path") + } +} + +func TestResolveTriagePathBareTokenRejected(t *testing.T) { + _, err := resolveTriagePath("baretoken123", "", triageFilter{}) + if err == nil { + t.Fatal("expected error for bare token without prefix") + } + if !strings.Contains(err.Error(), "prefix") { + t.Fatalf("error should mention prefix, got: %v", err) + } +} + +func TestResolveTriagePathEmptyToken(t *testing.T) { + // No token → falls back to usesTriageSearchPath + useSearch, err := resolveTriagePath("", "hello", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("query present → should use search path") + } + + useSearch, err = resolveTriagePath("", "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if useSearch { + t.Fatal("no query → should use list path") + } +} + +func TestPageTokenSearchPrefixStripped(t *testing.T) { + raw := "search:72d98412d30aa6af" + got := strings.TrimPrefix(raw, "search:") + if got != "72d98412d30aa6af" { + t.Fatalf("expected stripped token, got %q", got) + } +} + +func TestPageTokenListPrefixStripped(t *testing.T) { + raw := "list:FfccvoqPd_loLhtcRx8cx" + got := strings.TrimPrefix(raw, "list:") + if got != "FfccvoqPd_loLhtcRx8cx" { + t.Fatalf("expected stripped token, got %q", got) + } +} + +func TestPageTokenBareTokenRejected(t *testing.T) { + _, err := resolveTriagePath("FfccvoqPd_loLhtcRx8cx", "", triageFilter{}) + if err == nil { + t.Fatal("expected error for bare token without prefix") + } + if !strings.Contains(err.Error(), "prefix") { + t.Fatalf("error should mention prefix requirement, got: %v", err) + } +} + +// --- DryRun with page-size --- + +func TestMailTriageDryRunPageSizeOverridesMax(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "max": "50", + "page-size": "8", + "filter": `{"folder_id":"INBOX"}`, + }) + 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)) + } + got, ok := apis[0].Params["page_size"].(float64) + if !ok { + t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"]) + } + if int(got) != 8 { + t.Fatalf("expected page_size=8 (from --page-size), got %d", int(got)) + } +} + +func TestMailTriageDryRunSearchPathCapsPageSizeAt15(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "query": "hello", + "page-size": "30", + }) + 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)) + } + got, ok := apis[0].Params["page_size"].(float64) + if !ok { + t.Fatalf("page_size type mismatch, got %#v", apis[0].Params["page_size"]) + } + if int(got) != searchPageMax { + t.Fatalf("expected page_size capped at %d, got %d", searchPageMax, int(got)) + } +} + +// --- DryRun with page-token --- + +func TestMailTriageDryRunListPathWithPageToken(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": `{"folder_id":"INBOX"}`, + "page-token": "list:abc123token", + }) + 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)) + } + got, ok := apis[0].Params["page_token"] + if !ok { + t.Fatalf("expected page_token in params") + } + if got != "abc123token" { + t.Fatalf("expected stripped page_token='abc123token', got %v", got) + } +} + +func TestMailTriageDryRunSearchPathWithPageToken(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "query": "test", + "page-token": "search:def456token", + }) + 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)) + } + got, ok := apis[0].Params["page_token"] + if !ok { + t.Fatalf("expected page_token in params") + } + if got != "def456token" { + t.Fatalf("expected stripped page_token='def456token', got %v", got) + } +} + +func TestMailTriageDryRunBarePageTokenErrors(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": `{"folder_id":"INBOX"}`, + "page-token": "baretoken123", + }) + dry := MailTriage.DryRun(context.Background(), runtime) + b, _ := json.Marshal(dry) + s := string(b) + if !strings.Contains(s, "filter_error") { + t.Fatalf("expected filter_error for bare token, got %s", s) + } +} + +// --- resolveTriagePath --- + +func TestResolveTriagePathSearchPrefixWithoutQuery(t *testing.T) { + useSearch, err := resolveTriagePath("search:abc", "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("search: prefix should select search path") + } +} + +func TestResolveTriagePathListPrefixWithoutConflict(t *testing.T) { + useSearch, err := resolveTriagePath("list:abc", "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if useSearch { + t.Fatal("list: prefix should select list path") + } +} + +func TestResolveTriagePathListPrefixWithQueryErrors(t *testing.T) { + _, err := resolveTriagePath("list:abc", "hello", triageFilter{}) + if err == nil { + t.Fatal("expected error for list: token with --query") + } +} + +func TestResolveTriagePathListPrefixWithSearchFilterErrors(t *testing.T) { + _, err := resolveTriagePath("list:abc", "", triageFilter{Subject: "test"}) + if err == nil { + t.Fatal("expected error for list: token with search-only filter field") + } +} + +func TestResolveTriagePathBareTokenErrors(t *testing.T) { + _, err := resolveTriagePath("baretoken", "", triageFilter{}) + if err == nil { + t.Fatal("expected error for bare token") + } +} + +func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) { + useSearch, err := resolveTriagePath("", "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if useSearch { + t.Fatal("no query → should use list path") + } + + useSearch, err = resolveTriagePath("", "keyword", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("query present → should use search path") + } +} + +// --- DryRun: token prefix overrides path --- + +func TestMailTriageDryRunSearchTokenWithoutQueryUsesSearchPath(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "page-token": "search:abc123", + }) + 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].URL != mailboxPath("me", "search") { + t.Fatalf("search: prefix should force search path, got url %s", apis[0].URL) + } +} + +func TestMailTriageDryRunListTokenWithQueryErrors(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "query": "hello", + "page-token": "list:abc123", + }) + dry := MailTriage.DryRun(context.Background(), runtime) + b, _ := json.Marshal(dry) + s := string(b) + if !strings.Contains(s, "filter_error") { + t.Fatalf("expected filter_error for list token with query, got %s", s) + } +} + +// --- DryRun with no page-token has no page_token param --- + +func TestMailTriageDryRunNoPageTokenOmitsParam(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": `{"folder_id":"INBOX"}`, + }) + 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 _, ok := apis[0].Params["page_token"]; ok { + t.Fatalf("page_token should not be present when --page-token is empty") + } +} + +// --- Flag definition checks --- + +func TestMailTriageFlagsIncludePageTokenAndPageSize(t *testing.T) { + flagNames := make(map[string]bool) + for _, fl := range MailTriage.Flags { + flagNames[fl.Name] = true + } + for _, name := range []string{"page-token", "page-size", "max"} { + if !flagNames[name] { + t.Fatalf("expected flag --%s to be defined", name) + } + } +} + func boolPtr(v bool) *bool { return &v } diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index eae969513..68ad13bc7 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -32,7 +32,15 @@ lark-cli mail +triage --filter '{"label":"important"}' lark-cli mail +triage --filter '{"label":"重要邮件"}' # data 格式方便 jq 处理 -lark-cli mail +triage --format data | jq '.[].subject' +lark-cli mail +triage --format json | jq '.messages[].subject' + +# 分页:先取 10 条,再用 page_token 翻页 +lark-cli mail +triage --max 10 --format json +# 输出中包含 page_token,传入下一次请求 +lark-cli mail +triage --page-token 'list:FfccvoqPd...' --max 10 --format json + +# --page-size 是 --max 的别名 +lark-cli mail +triage --page-size 10 ``` ## 参数 @@ -41,8 +49,10 @@ lark-cli mail +triage --format data | jq '.[].subject' |------|------|------| | `--filter ` | — | 筛选条件(见下方字段说明) | | `--query ` | — | 全文搜索关键词 | -| `--format ` | `table` | `table` / `json` / `data`(`json` 和 `data` 都只输出 messages 数组) | +| `--format ` | `table` | `table` / `json` / `data` | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | +| `--page-size ` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 | +| `--page-token ` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:` 或 `list:` 前缀,标识来源路径,不可混用 | | `--labels` | — | table 格式时额外显示 labels 列 | | `--mailbox ` | `me` | 邮箱地址 | @@ -69,15 +79,31 @@ lark-cli mail +triage --format data | jq '.[].subject' ## 输出(`--format json` / `--format data`) ```json -[ - { - "message_id": "SEU2...", - "date": "Fri, 21 Mar 2026 11:40:00 +0800", - "from": "Alice ", - "subject": "Weekly update", - "labels": "INBOX,UNREAD" - } -] +{ + "messages": [ + { + "message_id": "SEU2...", + "date": "Fri, 21 Mar 2026 11:40:00 +0800", + "from": "Alice ", + "subject": "Weekly update", + "labels": "INBOX,UNREAD" + } + ], + "total": 20, + "has_more": true, + "page_token": "list:FfccvoqPd_loLhtcRx8cx..." +} +``` + +- `has_more`:是否还有下一页 +- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾 +- token 前缀 `search:` / `list:` 标识来源 API 路径,翻页时需保持参数一致(不能把 search token 用于 list 路径,反之亦然) + +**table 格式**下,`page_token` 信息输出在 stderr: +``` +15 message(s) +next page: mail +triage --page-token 'list:FfccvoqPd...' ... +tip: use mail +message --message-id to read full content ``` ## 参考 From 8faf9919496d0c433a11cfa47e3cc7504f5abc23 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 15:44:38 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(mail):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20keep=20data=20format=20as=20array,=20fix=20whitespa?= =?UTF-8?q?ce=20query=20edge=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --format data preserves backward-compatible flat array output - --format json returns the new envelope object with pagination fields - Align search: prefix guard with TrimSpace(query) to match usesTriageSearchPath --- shortcuts/mail/mail_triage.go | 8 +++++--- skills/lark-mail/references/lark-mail-triage.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 33b32f562..6fb32cbb4 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -54,7 +54,7 @@ var MailTriage = common.Shortcut{ Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "format", Default: "table", Desc: "output format: table | json | data"}, + {Name: "format", Default: "table", Desc: "output format: table | json (object with pagination) | data (messages array only)"}, {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"}, @@ -251,7 +251,7 @@ var MailTriage = common.Shortcut{ } switch outFormat { - case "json", "data": + case "json": outData := map[string]interface{}{ "messages": messages, "total": len(messages), @@ -259,6 +259,8 @@ var MailTriage = common.Shortcut{ "page_token": nextPageToken, } output.PrintJson(runtime.IO().Out, outData) + case "data": + output.PrintJson(runtime.IO().Out, messages) default: // "table" if len(messages) == 0 { fmt.Fprintln(runtime.IO().ErrOut, "No messages found.") @@ -894,7 +896,7 @@ func resolveTriagePath(pageToken, query string, filter triageFilter) (useSearch paramWantsSearch := usesTriageSearchPath(query, filter) switch { case strings.HasPrefix(pageToken, "search:"): - if !paramWantsSearch && (query != "" || len(triageQueryFilterFields(filter)) > 0) { + if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) { // This shouldn't normally happen (query/search fields → paramWantsSearch=true), // but guard against future changes. return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token") diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 68ad13bc7..851a58723 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10 |------|------|------| | `--filter ` | — | 筛选条件(见下方字段说明) | | `--query ` | — | 全文搜索关键词 | -| `--format ` | `table` | `table` / `json` / `data` | +| `--format ` | `table` | `table` / `json`(含分页信息的对象)/ `data`(纯 messages 数组,向后兼容) | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | | `--page-size ` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 | | `--page-token ` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:` 或 `list:` 前缀,标识来源路径,不可混用 | From 613535a88a6b30927b93a7521c21d84488f5f00d Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 16:35:33 +0800 Subject: [PATCH 3/8] fix(mail): simplify page-token format and fix page-size change data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove page_size encoding from token (search:abc → not search:5:abc) The search API token is a session cursor; page_size only controls how many items to return, not the cursor position. Encoding page_size caused data loss when users changed --page-size between requests. - Token format is now simply "search:" / "list:" - Add parseTriagePageToken/encodeTriagePageToken helpers for clean token handling with proper validation - next page hint in table output now includes --query and --filter for easy copy-paste continuation --- shortcuts/mail/mail_triage.go | 84 ++++++++++++++++------ shortcuts/mail/mail_triage_test.go | 111 +++++++++++++++++++++++++---- 2 files changed, 160 insertions(+), 35 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 6fb32cbb4..034dfdc62 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -69,13 +69,16 @@ var MailTriage = common.Shortcut{ query := runtime.Str("query") showLabels := runtime.Bool("labels") maxCount := resolveTriagePageSize(runtime) - inputPageToken := runtime.Str("page-token") + parsed, parseErr := parseTriagePageToken(runtime.Str("page-token")) filter, err := parseTriageFilter(runtime.Str("filter")) d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")) + if parseErr != nil { + return d.Set("filter_error", parseErr.Error()) + } if err != nil { return d.Set("filter_error", err.Error()) } - useSearch, pathErr := resolveTriagePath(inputPageToken, query, filter) + useSearch, pathErr := resolveTriagePath(parsed, query, filter) if pathErr != nil { return d.Set("filter_error", pathErr.Error()) } @@ -88,8 +91,7 @@ var MailTriage = common.Shortcut{ if pageSize > searchPageMax { pageSize = searchPageMax } - initialToken := strings.TrimPrefix(inputPageToken, "search:") - searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, initialToken, true) + searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true) d = d.POST(mailboxPath(mailbox, "search")). Params(searchParams). Body(searchBody). @@ -109,8 +111,7 @@ var MailTriage = common.Shortcut{ if pageSize > listPageMax { pageSize = listPageMax } - initialToken := strings.TrimPrefix(inputPageToken, "list:") - listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, initialToken, true) + listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true) return d.GET(mailboxPath(mailbox, "messages")). Params(listParams). POST(mailboxPath(mailbox, "messages", "batch_get")). @@ -138,13 +139,16 @@ var MailTriage = common.Shortcut{ return err } maxCount := resolveTriagePageSize(runtime) - inputPageToken := runtime.Str("page-token") + parsed, err := parseTriagePageToken(runtime.Str("page-token")) + if err != nil { + return err + } var messages []map[string]interface{} var hasMore bool var nextPageToken string - useSearch, err := resolveTriagePath(inputPageToken, query, filter) + useSearch, err := resolveTriagePath(parsed, query, filter) if err != nil { return err } @@ -154,7 +158,7 @@ var MailTriage = common.Shortcut{ if err != nil { return err } - pageToken := strings.TrimPrefix(inputPageToken, "search:") + pageToken := parsed.RawToken for len(messages) < maxCount { pageSize := maxCount - len(messages) if pageSize > searchPageMax { @@ -183,7 +187,7 @@ var MailTriage = common.Shortcut{ break } hasMore = pageHasMore - nextPageToken = "search:" + pageToken + nextPageToken = encodeTriagePageToken("search", pageToken) } if len(messages) > maxCount { messages = messages[:maxCount] @@ -206,7 +210,7 @@ var MailTriage = common.Shortcut{ } var ( messageIDs []string - pageToken = strings.TrimPrefix(inputPageToken, "list:") + pageToken = parsed.RawToken ) for len(messageIDs) < maxCount { pageSize := maxCount - len(messageIDs) @@ -235,7 +239,7 @@ var MailTriage = common.Shortcut{ break } hasMore = pageHasMore - nextPageToken = "list:" + pageToken + nextPageToken = encodeTriagePageToken("list", pageToken) } if len(messageIDs) > maxCount { messageIDs = messageIDs[:maxCount] @@ -282,7 +286,16 @@ var MailTriage = common.Shortcut{ output.PrintTable(runtime.IO().Out, rows) fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages)) if hasMore && nextPageToken != "" { - fmt.Fprintf(runtime.IO().ErrOut, "next page: mail +triage --page-token '%s' ...\n", nextPageToken) + var hint strings.Builder + hint.WriteString("next page: mail +triage") + if query != "" { + hint.WriteString(fmt.Sprintf(" --query '%s'", query)) + } + if filterStr := runtime.Str("filter"); filterStr != "" { + hint.WriteString(fmt.Sprintf(" --filter '%s'", filterStr)) + } + hint.WriteString(fmt.Sprintf(" --page-token '%s'", nextPageToken)) + fmt.Fprintln(runtime.IO().ErrOut, hint.String()) } fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id to read full content") } @@ -889,20 +902,18 @@ func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} { // - "search:" prefix: must not have list-only params (no query/search filter fields is OK for continuation). // - "list:" prefix: must not have query or search-only filter fields that would be silently ignored. // - Bare token (no prefix): rejected — all tokens emitted by triage carry a prefix. -func resolveTriagePath(pageToken, query string, filter triageFilter) (useSearch bool, err error) { - if pageToken == "" { +func resolveTriagePath(parsed triagePageToken, query string, filter triageFilter) (useSearch bool, err error) { + if parsed.RawToken == "" { return usesTriageSearchPath(query, filter), nil } paramWantsSearch := usesTriageSearchPath(query, filter) - switch { - case strings.HasPrefix(pageToken, "search:"): + switch parsed.Path { + case "search": if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) { - // This shouldn't normally happen (query/search fields → paramWantsSearch=true), - // but guard against future changes. return false, fmt.Errorf("--page-token has search: prefix but current --query/--filter parameters indicate list path; remove conflicting parameters or use the correct token") } return true, nil - case strings.HasPrefix(pageToken, "list:"): + case "list": if paramWantsSearch { return false, fmt.Errorf("--page-token has list: prefix but --query or --filter contains search-only fields (e.g. from/to/subject); these parameters would be silently ignored — remove them or use a search: token") } @@ -912,6 +923,39 @@ func resolveTriagePath(pageToken, query string, filter triageFilter) (useSearch } } +// triagePageToken represents a parsed pagination token. +type triagePageToken struct { + Path string // "search" or "list" + RawToken string // the actual API token +} + +// encodeTriagePageToken encodes a pagination token with path prefix. +// Format: "search:abc123" or "list:abc123". +func encodeTriagePageToken(path string, rawToken string) string { + if rawToken == "" { + return "" + } + return path + ":" + rawToken +} + +// parseTriagePageToken parses a token encoded by encodeTriagePageToken. +// Returns an error for bare tokens or malformed tokens. +func parseTriagePageToken(token string) (triagePageToken, error) { + if token == "" { + return triagePageToken{}, nil + } + idx := strings.IndexByte(token, ':') + if idx < 0 { + return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix (token was obtained from a previous mail +triage response)") + } + path := token[:idx] + raw := token[idx+1:] + if path != "search" && path != "list" { + return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path) + } + return triagePageToken{Path: path, RawToken: raw}, nil +} + // resolveTriagePageSize returns the effective max count from --page-size or --max. // --page-size is an alias for --max; if both are set, --page-size takes priority. func resolveTriagePageSize(runtime *common.RuntimeContext) int { diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 17208bffe..fd0f27b7c 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -1013,7 +1013,7 @@ func TestResolveTriagePageSizeClamped(t *testing.T) { func TestResolveTriagePathSearchTokenContinuation(t *testing.T) { // search: token without --query is valid (continuation) - useSearch, err := resolveTriagePath("search:abc123", "", triageFilter{}) + useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc123"), "", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1024,7 +1024,7 @@ func TestResolveTriagePathSearchTokenContinuation(t *testing.T) { func TestResolveTriagePathListTokenConflictsWithQuery(t *testing.T) { // list: token + --query → error (query would be silently ignored) - _, err := resolveTriagePath("list:abc123", "hello", triageFilter{}) + _, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "hello", triageFilter{}) if err == nil { t.Fatal("expected error for list: token with --query") } @@ -1032,7 +1032,7 @@ func TestResolveTriagePathListTokenConflictsWithQuery(t *testing.T) { func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) { // list: token + search-only filter field → error - _, err := resolveTriagePath("list:abc123", "", triageFilter{From: []string{"a@b.com"}}) + _, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{From: []string{"a@b.com"}}) if err == nil { t.Fatal("expected error for list: token with search-only filter") } @@ -1040,7 +1040,7 @@ func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) { func TestResolveTriagePathListTokenWithListFilter(t *testing.T) { // list: token + list-compatible filter → OK - useSearch, err := resolveTriagePath("list:abc123", "", triageFilter{Folder: "inbox"}) + useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{Folder: "inbox"}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1050,7 +1050,8 @@ func TestResolveTriagePathListTokenWithListFilter(t *testing.T) { } func TestResolveTriagePathBareTokenRejected(t *testing.T) { - _, err := resolveTriagePath("baretoken123", "", triageFilter{}) + // Bare tokens are rejected at parse time, not at resolveTriagePath time + _, err := parseTriagePageToken("baretoken123") if err == nil { t.Fatal("expected error for bare token without prefix") } @@ -1061,7 +1062,7 @@ func TestResolveTriagePathBareTokenRejected(t *testing.T) { func TestResolveTriagePathEmptyToken(t *testing.T) { // No token → falls back to usesTriageSearchPath - useSearch, err := resolveTriagePath("", "hello", triageFilter{}) + useSearch, err := resolveTriagePath(triagePageToken{}, "hello", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1069,7 +1070,7 @@ func TestResolveTriagePathEmptyToken(t *testing.T) { t.Fatal("query present → should use search path") } - useSearch, err = resolveTriagePath("", "", triageFilter{}) + useSearch, err = resolveTriagePath(triagePageToken{}, "", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1095,7 +1096,7 @@ func TestPageTokenListPrefixStripped(t *testing.T) { } func TestPageTokenBareTokenRejected(t *testing.T) { - _, err := resolveTriagePath("FfccvoqPd_loLhtcRx8cx", "", triageFilter{}) + _, err := parseTriagePageToken("FfccvoqPd_loLhtcRx8cx") if err == nil { t.Fatal("expected error for bare token without prefix") } @@ -1197,7 +1198,7 @@ func TestMailTriageDryRunBarePageTokenErrors(t *testing.T) { // --- resolveTriagePath --- func TestResolveTriagePathSearchPrefixWithoutQuery(t *testing.T) { - useSearch, err := resolveTriagePath("search:abc", "", triageFilter{}) + useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "search:abc"), "", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1207,7 +1208,7 @@ func TestResolveTriagePathSearchPrefixWithoutQuery(t *testing.T) { } func TestResolveTriagePathListPrefixWithoutConflict(t *testing.T) { - useSearch, err := resolveTriagePath("list:abc", "", triageFilter{}) + useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1217,28 +1218,28 @@ func TestResolveTriagePathListPrefixWithoutConflict(t *testing.T) { } func TestResolveTriagePathListPrefixWithQueryErrors(t *testing.T) { - _, err := resolveTriagePath("list:abc", "hello", triageFilter{}) + _, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "hello", triageFilter{}) if err == nil { t.Fatal("expected error for list: token with --query") } } func TestResolveTriagePathListPrefixWithSearchFilterErrors(t *testing.T) { - _, err := resolveTriagePath("list:abc", "", triageFilter{Subject: "test"}) + _, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc"), "", triageFilter{Subject: "test"}) if err == nil { t.Fatal("expected error for list: token with search-only filter field") } } func TestResolveTriagePathBareTokenErrors(t *testing.T) { - _, err := resolveTriagePath("baretoken", "", triageFilter{}) + _, err := parseTriagePageToken("baretoken") if err == nil { t.Fatal("expected error for bare token") } } func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) { - useSearch, err := resolveTriagePath("", "", triageFilter{}) + useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1246,7 +1247,7 @@ func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) { t.Fatal("no query → should use list path") } - useSearch, err = resolveTriagePath("", "keyword", triageFilter{}) + useSearch, err = resolveTriagePath(triagePageToken{}, "keyword", triageFilter{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1312,4 +1313,84 @@ func TestMailTriageFlagsIncludePageTokenAndPageSize(t *testing.T) { } } +func mustParseTriagePageToken(t *testing.T, token string) triagePageToken { + t.Helper() + parsed, err := parseTriagePageToken(token) + if err != nil { + t.Fatalf("parseTriagePageToken(%q) failed: %v", token, err) + } + return parsed +} + +// --- parseTriagePageToken / encodeTriagePageToken --- + +func TestEncodeTriagePageToken(t *testing.T) { + got := encodeTriagePageToken("search", "abc123") + if got != "search:abc123" { + t.Fatalf("expected search:abc123, got %q", got) + } +} + +func TestEncodeTriagePageTokenEmpty(t *testing.T) { + got := encodeTriagePageToken("search", "") + if got != "" { + t.Fatalf("expected empty for empty raw token, got %q", got) + } +} + +func TestParseTriagePageTokenSearch(t *testing.T) { + parsed, err := parseTriagePageToken("search:abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Path != "search" || parsed.RawToken != "abc123" { + t.Fatalf("unexpected parsed: %+v", parsed) + } +} + +func TestParseTriagePageTokenList(t *testing.T) { + parsed, err := parseTriagePageToken("list:longtoken123xyz") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Path != "list" || parsed.RawToken != "longtoken123xyz" { + t.Fatalf("unexpected parsed: %+v", parsed) + } +} + +func TestParseTriagePageTokenWithColonsInRawToken(t *testing.T) { + // Raw token may contain colons + parsed, err := parseTriagePageToken("search:abc:def:ghi") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.Path != "search" || parsed.RawToken != "abc:def:ghi" { + t.Fatalf("unexpected parsed: %+v", parsed) + } +} + +func TestParseTriagePageTokenBareRejected(t *testing.T) { + _, err := parseTriagePageToken("baretoken") + if err == nil { + t.Fatal("expected error for bare token") + } +} + +func TestParseTriagePageTokenEmpty(t *testing.T) { + parsed, err := parseTriagePageToken("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if parsed.RawToken != "" { + t.Fatalf("expected empty parsed, got %+v", parsed) + } +} + +func TestParseTriagePageTokenInvalidPrefix(t *testing.T) { + _, err := parseTriagePageToken("unknown:abc123") + if err == nil { + t.Fatal("expected error for unknown prefix") + } +} + func boolPtr(v bool) *bool { return &v } From 99dfebde0b087dbb780d9174feb36a892e504650 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 17:04:19 +0800 Subject: [PATCH 4/8] docs(mail): update triage skill doc for json/data format split and search pagination note - Separate --format json (object with pagination) and --format data (array) examples - Update table next-page hint example to show --query/--filter inclusion - Add search pagination caveat about cross-session result ordering --- .../lark-mail/references/lark-mail-triage.md | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 851a58723..1c3518cbc 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -76,7 +76,9 @@ lark-cli mail +triage --page-size 10 > **⚠️ 注意**:查询未读请用 `"is_unread":true`。 可运行 `mail +triage --print-filter-schema` 查看完整字段说明。 -## 输出(`--format json` / `--format data`) +## 输出 + +### `--format json`(含分页信息) ```json { @@ -97,15 +99,31 @@ lark-cli mail +triage --page-size 10 - `has_more`:是否还有下一页 - `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾 -- token 前缀 `search:` / `list:` 标识来源 API 路径,翻页时需保持参数一致(不能把 search token 用于 list 路径,反之亦然) +- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用 + +### `--format data`(纯数组,向后兼容) -**table 格式**下,`page_token` 信息输出在 stderr: +```json +[ + { "message_id": "SEU2...", "date": "...", "from": "...", "subject": "..." } +] +``` + +> `--format data` 不包含分页信息,适合管道处理。需要分页时请用 `--format json`。 + +### `table` 格式 + +`page_token` 信息输出在 stderr,自动携带 `--query`/`--filter` 参数方便续页: ``` 15 message(s) -next page: mail +triage --page-token 'list:FfccvoqPd...' ... +next page: mail +triage --query '合同审批' --page-token 'search:abc123...' tip: use mail +message --message-id to read full content ``` +### 搜索分页注意事项 + +搜索路径(使用 `--query` 或 `from`/`to`/`subject` 等 filter)的分页结果在**同一翻页链内**保持一致(无重复、无丢失)。但不同 `--max` 值发起的独立搜索可能返回不同排序,这是搜索 API 的固有行为。列表路径(仅 `folder`/`label` 筛选)无此限制。 + ## 参考 - [lark-mail](../SKILL.md) — 邮箱域总览 From b273ad55351944b4f3ecea6f881f8d0cea7fa11f Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 17:08:11 +0800 Subject: [PATCH 5/8] fix(mail): make --format data include pagination fields same as json --- shortcuts/mail/mail_triage.go | 6 ++---- skills/lark-mail/references/lark-mail-triage.md | 16 ++++------------ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 034dfdc62..85b8935b6 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -54,7 +54,7 @@ var MailTriage = common.Shortcut{ Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "format", Default: "table", Desc: "output format: table | json (object with pagination) | data (messages array only)"}, + {Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"}, {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"}, @@ -255,7 +255,7 @@ var MailTriage = common.Shortcut{ } switch outFormat { - case "json": + case "json", "data": outData := map[string]interface{}{ "messages": messages, "total": len(messages), @@ -263,8 +263,6 @@ var MailTriage = common.Shortcut{ "page_token": nextPageToken, } output.PrintJson(runtime.IO().Out, outData) - case "data": - output.PrintJson(runtime.IO().Out, messages) default: // "table" if len(messages) == 0 { fmt.Fprintln(runtime.IO().ErrOut, "No messages found.") diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 1c3518cbc..026e2e31a 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10 |------|------|------| | `--filter ` | — | 筛选条件(见下方字段说明) | | `--query ` | — | 全文搜索关键词 | -| `--format ` | `table` | `table` / `json`(含分页信息的对象)/ `data`(纯 messages 数组,向后兼容) | +| `--format ` | `table` | `table` / `json` / `data`(`json` 和 `data` 均输出含分页信息的对象) | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | | `--page-size ` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 | | `--page-token ` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:` 或 `list:` 前缀,标识来源路径,不可混用 | @@ -78,7 +78,9 @@ lark-cli mail +triage --page-size 10 ## 输出 -### `--format json`(含分页信息) +### `--format json` / `--format data` + +两者输出格式相同,均为含分页信息的对象: ```json { @@ -101,16 +103,6 @@ lark-cli mail +triage --page-size 10 - `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾 - token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用 -### `--format data`(纯数组,向后兼容) - -```json -[ - { "message_id": "SEU2...", "date": "...", "from": "...", "subject": "..." } -] -``` - -> `--format data` 不包含分页信息,适合管道处理。需要分页时请用 `--format json`。 - ### `table` 格式 `page_token` 信息输出在 stderr,自动携带 `--query`/`--filter` 参数方便续页: From a25fe9acc01189aa8ded7557a8a41b5d566fdcd8 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 20:17:05 +0800 Subject: [PATCH 6/8] fix(mail): address remaining PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject empty prefixed tokens (search: / list:) in parseTriagePageToken - Shell-escape query/filter in next-page hint to handle single quotes - Fix doc caption mismatch (data → json/data) and add language tag to code block - Fix test comment for TestResolveTriagePageSizeDefaultMax --- shortcuts/mail/mail_triage.go | 14 +++++++++++--- shortcuts/mail/mail_triage_test.go | 13 ++++++++++++- skills/lark-mail/references/lark-mail-triage.md | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 85b8935b6..a9cc39906 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -287,12 +287,12 @@ var MailTriage = common.Shortcut{ var hint strings.Builder hint.WriteString("next page: mail +triage") if query != "" { - hint.WriteString(fmt.Sprintf(" --query '%s'", query)) + hint.WriteString(" --query " + shellQuote(query)) } if filterStr := runtime.Str("filter"); filterStr != "" { - hint.WriteString(fmt.Sprintf(" --filter '%s'", filterStr)) + hint.WriteString(" --filter " + shellQuote(filterStr)) } - hint.WriteString(fmt.Sprintf(" --page-token '%s'", nextPageToken)) + hint.WriteString(" --page-token " + shellQuote(nextPageToken)) fmt.Fprintln(runtime.IO().ErrOut, hint.String()) } fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --message-id to read full content") @@ -892,6 +892,11 @@ func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} { return createTime } +// shellQuote wraps a string in single quotes, escaping any embedded single quotes. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + // resolveTriagePath determines whether to use the search API path, // validating that --page-token prefix is consistent with query/filter params. // @@ -951,6 +956,9 @@ func parseTriagePageToken(token string) (triagePageToken, error) { if path != "search" && path != "list" { return triagePageToken{}, fmt.Errorf("invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path) } + if raw == "" { + return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", path) + } return triagePageToken{Path: path, RawToken: raw}, nil } diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index fd0f27b7c..61d04c92d 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -970,7 +970,7 @@ func TestBuildSearchParamsPageToken(t *testing.T) { // --- resolveTriagePageSize --- func TestResolveTriagePageSizeDefaultMax(t *testing.T) { - rt := runtimeForMailTriageTest(t, nil) // max defaults to "20" + rt := runtimeForMailTriageTest(t, nil) // max=0 (unset) → normalizeTriageMax returns 20 got := resolveTriagePageSize(rt) if got != 20 { t.Fatalf("expected 20, got %d", got) @@ -1376,6 +1376,17 @@ func TestParseTriagePageTokenBareRejected(t *testing.T) { } } +func TestParseTriagePageTokenEmptyRawTokenRejected(t *testing.T) { + _, err := parseTriagePageToken("search:") + if err == nil { + t.Fatal("expected error for empty raw token after prefix") + } + _, err = parseTriagePageToken("list:") + if err == nil { + t.Fatal("expected error for empty raw token after prefix") + } +} + func TestParseTriagePageTokenEmpty(t *testing.T) { parsed, err := parseTriagePageToken("") if err != nil { diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 026e2e31a..1bc3106b8 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -31,7 +31,7 @@ lark-cli mail +triage --filter '{"folder":"flagged"}' lark-cli mail +triage --filter '{"label":"important"}' lark-cli mail +triage --filter '{"label":"重要邮件"}' -# data 格式方便 jq 处理 +# json/data 格式可配合 jq 处理 lark-cli mail +triage --format json | jq '.messages[].subject' # 分页:先取 10 条,再用 page_token 翻页 @@ -106,7 +106,7 @@ lark-cli mail +triage --page-size 10 ### `table` 格式 `page_token` 信息输出在 stderr,自动携带 `--query`/`--filter` 参数方便续页: -``` +```text 15 message(s) next page: mail +triage --query '合同审批' --page-token 'search:abc123...' tip: use mail +message --message-id to read full content From bdc1d55216c91fc0cfb808aee4a9dd3ea9cb0f59 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 20:33:36 +0800 Subject: [PATCH 7/8] fix(mail): rename total to count in triage pagination output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit total was misleading — it represented the current page count, not the global total. Renamed to count to match len(messages) semantics. --- shortcuts/mail/mail_triage.go | 2 +- skills/lark-mail/references/lark-mail-triage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index a9cc39906..3b1a469f8 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -258,7 +258,7 @@ var MailTriage = common.Shortcut{ case "json", "data": outData := map[string]interface{}{ "messages": messages, - "total": len(messages), + "count": len(messages), "has_more": hasMore, "page_token": nextPageToken, } diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 1bc3106b8..f427e1f1e 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -93,7 +93,7 @@ lark-cli mail +triage --page-size 10 "labels": "INBOX,UNREAD" } ], - "total": 20, + "count": 20, "has_more": true, "page_token": "list:FfccvoqPd_loLhtcRx8cx..." } From 530fb01ce28739d90656c830b97477622b7879c9 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Thu, 9 Apr 2026 21:21:10 +0800 Subject: [PATCH 8/8] fix(mail): improve dry-run desc when using --page-token --- shortcuts/mail/mail_triage.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 3b1a469f8..1274c5f11 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -91,11 +91,15 @@ var MailTriage = common.Shortcut{ if pageSize > searchPageMax { pageSize = searchPageMax } + searchDesc := "search messages (auto-paginates up to --max)" + if parsed.RawToken != "" { + searchDesc = "search messages (continues from --page-token, up to --max)" + } searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true) d = d.POST(mailboxPath(mailbox, "search")). Params(searchParams). Body(searchBody). - Desc("search messages (auto-paginates up to --max)") + Desc(searchDesc) if showLabels { d = d.POST(mailboxPath(mailbox, "messages", "batch_get")). Body(map[string]interface{}{"format": "metadata", "message_ids": []string{""}}). @@ -111,12 +115,16 @@ var MailTriage = common.Shortcut{ if pageSize > listPageMax { pageSize = listPageMax } + listDesc := "list message IDs (auto-paginates up to --max); batch_get with format=metadata" + if parsed.RawToken != "" { + listDesc = "list message IDs (continues from --page-token, up to --max); batch_get with format=metadata" + } listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true) return d.GET(mailboxPath(mailbox, "messages")). Params(listParams). POST(mailboxPath(mailbox, "messages", "batch_get")). Body(map[string]interface{}{"format": "metadata", "message_ids": []string{""}}). - Desc("list message IDs (auto-paginates up to --max); batch_get with format=metadata"). + Desc(listDesc). Set("resolve_note", "name→ID resolution for filter.folder/filter.label runs during execution; dry-run does not call folders/labels list APIs") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {