feat(mail): add --page-token and --page-size to mail +triage (#301)

* 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.

* fix(mail): address PR review — keep data format as array, fix whitespace query edge case

- --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

* fix(mail): simplify page-token format and fix page-size change data loss

- 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:<raw>" / "list:<raw>"
- 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

* 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

* fix(mail): make --format data include pagination fields same as json

* fix(mail): address remaining PR review comments

- 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

* fix(mail): rename total to count in triage pagination output

total was misleading — it represented the current page count, not the
global total. Renamed to count to match len(messages) semantics.

* fix(mail): improve dry-run desc when using --page-token
This commit is contained in:
wangzhengkui
2026-04-09 21:39:12 +08:00
committed by GitHub
parent 69cf9f206e
commit fd9ee6afd6
3 changed files with 636 additions and 25 deletions

View File

@@ -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{"<message_id>"}}).
@@ -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{"<message_id>"}}).
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
if usesTriageSearchPath(query, filter) {
useSearch, err := resolveTriagePath(parsed, query, filter)
if err != nil {
return err
}
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 <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

View File

@@ -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 }

View File

@@ -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 <json>` | — | 筛选条件(见下方字段说明) |
| `--query <text>` | — | 全文搜索关键词 |
| `--format <mode>` | `table` | `table` / `json` / `data``json``data` 都只输出 messages 数组 |
| `--format <mode>` | `table` | `table` / `json` / `data``json``data` 均输出含分页信息的对象 |
| `--max <n>` | `20` | 最大返回条数1-400内部自动分页拉取 |
| `--page-size <n>` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 |
| `--page-token <token>` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:``list:` 前缀,标识来源路径,不可混用 |
| `--labels` | — | table 格式时额外显示 labels 列 |
| `--mailbox <id>` | `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 <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
]
{
"messages": [
{
"message_id": "SEU2...",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"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 <id> to read full content
```
### 搜索分页注意事项
搜索路径(使用 `--query``from`/`to`/`subject` 等 filter的分页结果在**同一翻页链内**保持一致(无重复、无丢失)。但不同 `--max` 值发起的独立搜索可能返回不同排序,这是搜索 API 的固有行为。列表路径(仅 `folder`/`label` 筛选)无此限制。
## 参考
- [lark-mail](../SKILL.md) — 邮箱域总览