diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 9b375ab2..33b32f56 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 4ea728e0..17208bff 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 eae96951..68ad13bc 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 ``` ## 参考