mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) — 邮箱域总览
|
||||
|
||||
Reference in New Issue
Block a user