diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 9b375ab22..1274c5f11 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 (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"}, {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,21 @@ var MailTriage = common.Shortcut{ mailbox := resolveMailboxID(runtime) query := runtime.Str("query") showLabels := runtime.Bool("labels") - maxCount := normalizeTriageMax(runtime.Int("max")) + 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")) + if parseErr != nil { + return d.Set("filter_error", parseErr.Error()) + } if err != nil { return d.Set("filter_error", err.Error()) } - if usesTriageSearchPath(query, filter) { + useSearch, pathErr := resolveTriagePath(parsed, 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,11 +91,15 @@ var MailTriage = common.Shortcut{ if pageSize > searchPageMax { pageSize = searchPageMax } - searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, "", true) + 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{""}}). @@ -101,12 +115,16 @@ var MailTriage = common.Shortcut{ if pageSize > listPageMax { pageSize = listPageMax } - listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, "", true) + 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 { @@ -128,16 +146,27 @@ var MailTriage = common.Shortcut{ if err != nil { return err } - maxCount := normalizeTriageMax(runtime.Int("max")) + maxCount := resolveTriagePageSize(runtime) + 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(parsed, 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 := parsed.RawToken for len(messages) < maxCount { pageSize := maxCount - len(messages) if pageSize > searchPageMax { @@ -161,8 +190,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 = encodeTriagePageToken("search", pageToken) } if len(messages) > maxCount { messages = messages[:maxCount] @@ -185,7 +218,7 @@ var MailTriage = common.Shortcut{ } var ( messageIDs []string - pageToken string + pageToken = parsed.RawToken ) for len(messageIDs) < maxCount { pageSize := maxCount - len(messageIDs) @@ -209,8 +242,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 = encodeTriagePageToken("list", pageToken) } if len(messageIDs) > maxCount { messageIDs = messageIDs[:maxCount] @@ -221,9 +258,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, + "count": 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 +291,18 @@ 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 != "" { + var hint strings.Builder + hint.WriteString("next page: mail +triage") + if query != "" { + hint.WriteString(" --query " + shellQuote(query)) + } + if filterStr := runtime.Str("filter"); filterStr != "" { + hint.WriteString(" --filter " + shellQuote(filterStr)) + } + 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") } return nil @@ -841,6 +900,85 @@ 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. +// +// 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(parsed triagePageToken, query string, filter triageFilter) (useSearch bool, err error) { + if parsed.RawToken == "" { + return usesTriageSearchPath(query, filter), nil + } + paramWantsSearch := usesTriageSearchPath(query, filter) + switch parsed.Path { + case "search": + if !paramWantsSearch && (strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0) { + 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 "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)") + } +} + +// 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) + } + if raw == "" { + return triagePageToken{}, fmt.Errorf("invalid --page-token: token value is empty after '%s:' prefix", 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 { + 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..61d04c92d 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -967,4 +967,441 @@ func TestBuildSearchParamsPageToken(t *testing.T) { } } +// --- resolveTriagePageSize --- + +func TestResolveTriagePageSizeDefaultMax(t *testing.T) { + rt := runtimeForMailTriageTest(t, nil) // max=0 (unset) → normalizeTriageMax returns 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(mustParseTriagePageToken(t, "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(mustParseTriagePageToken(t, "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(mustParseTriagePageToken(t, "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(mustParseTriagePageToken(t, "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) { + // 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") + } + 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(triagePageToken{}, "hello", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !useSearch { + t.Fatal("query present → should use search path") + } + + useSearch, err = resolveTriagePath(triagePageToken{}, "", 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 := parseTriagePageToken("FfccvoqPd_loLhtcRx8cx") + 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(mustParseTriagePageToken(t, "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(mustParseTriagePageToken(t, "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(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(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 := parseTriagePageToken("baretoken") + if err == nil { + t.Fatal("expected error for bare token") + } +} + +func TestResolveTriagePathEmptyTokenFallsBack(t *testing.T) { + useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if useSearch { + t.Fatal("no query → should use list path") + } + + useSearch, err = resolveTriagePath(triagePageToken{}, "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 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 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 { + 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 } diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index eae969513..f427e1f1e 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -31,8 +31,16 @@ lark-cli mail +triage --filter '{"folder":"flagged"}' lark-cli mail +triage --filter '{"label":"important"}' lark-cli mail +triage --filter '{"label":"重要邮件"}' -# data 格式方便 jq 处理 -lark-cli mail +triage --format data | jq '.[].subject' +# json/data 格式可配合 jq 处理 +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`(`json` 和 `data` 均输出含分页信息的对象) | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | +| `--page-size ` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 | +| `--page-token ` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:` 或 `list:` 前缀,标识来源路径,不可混用 | | `--labels` | — | table 格式时额外显示 labels 列 | | `--mailbox ` | `me` | 邮箱地址 | @@ -66,20 +76,46 @@ lark-cli mail +triage --format data | jq '.[].subject' > **⚠️ 注意**:查询未读请用 `"is_unread":true`。 可运行 `mail +triage --print-filter-schema` 查看完整字段说明。 -## 输出(`--format json` / `--format data`) +## 输出 + +### `--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" + } + ], + "count": 20, + "has_more": true, + "page_token": "list:FfccvoqPd_loLhtcRx8cx..." +} +``` + +- `has_more`:是否还有下一页 +- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾 +- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用 + +### `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 ``` +### 搜索分页注意事项 + +搜索路径(使用 `--query` 或 `from`/`to`/`subject` 等 filter)的分页结果在**同一翻页链内**保持一致(无重复、无丢失)。但不同 `--max` 值发起的独立搜索可能返回不同排序,这是搜索 API 的固有行为。列表路径(仅 `folder`/`label` 筛选)无此限制。 + ## 参考 - [lark-mail](../SKILL.md) — 邮箱域总览