Files
larksuite__cli/shortcuts/mail/mail_triage.go
liangshuo-1 2a1613484a feat: add framework flag aliases and unified IM pagination (#2146)
* feat: add framework flag aliases and unified IM pagination

Introduce declarative exact-name flag aliases at the shortcut framework boundary while keeping semantic compatibility domain-owned. Add a shared, format-aware IM pagination pipeline with consistent flags, metadata, safety bounds, resumable cursors, and request throttling.

* fix: align alias attribution and pagination contracts

* fix: align alias contracts and documentation

* test: remove environment-dependent contact bot e2e

* test: restore contact bot e2e

* docs: reduce IM pagination guidance noise

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-08-03 19:20:40 +08:00

1405 lines
47 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// triageFilter represents shortcut filters that are mapped to list/search APIs.
type triageTimeRange struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
type triageFilter struct {
Folder string `json:"folder,omitempty"` // folder name
FolderID string `json:"folder_id,omitempty"` // explicit folder ID, higher priority than folder
Label string `json:"label,omitempty"` // label name
LabelID string `json:"label_id,omitempty"` // explicit label ID, higher priority than label
From []string `json:"from,omitempty"` // query path only
To []string `json:"to,omitempty"` // query path only
CC []string `json:"cc,omitempty"` // query path only
BCC []string `json:"bcc,omitempty"` // query path only
Subject string `json:"subject,omitempty"` // query path only
HasAttachment *bool `json:"has_attachment,omitempty"` // query path only
IsUnread *bool `json:"is_unread,omitempty"` // query path only
TimeRange *triageTimeRange `json:"time_range,omitempty"` // query path only
}
const (
searchPageMax = 15 // max items per search API page
listPageMax = 20 // max items per list API page
triageMaxLimit = 400
triageAPIRetries = 2 // retry count in addition to the first attempt
)
var MailTriage = common.Shortcut{
Service: "mail",
Command: "+triage",
Description: `List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions.`,
Risk: "read",
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", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "max", Aliases: []string{"page-size"}, Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
{Name: "filter", Desc: `exact-match condition filter (JSON or key=value). Narrow results by folder, label, sender, recipient, unread status, etc. Run --print-filter-schema to see all fields. Example: {"folder":"INBOX","from":["alice@example.com"]}`},
{Name: "folder", Desc: "folder name or system folder ID filter"},
{Name: "folder-id", Desc: "explicit folder ID filter"},
{Name: "is-unread", Type: "bool", Desc: "filter unread messages"},
{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"`},
{Name: "labels", Type: "bool", Desc: "include label IDs in output"},
{Name: "print-filter-schema", Type: "bool", Desc: "print --filter field reference and exit"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailbox := resolveMailboxID(runtime)
query := runtime.Str("query")
showLabels := runtime.Bool("labels")
maxCount := normalizeTriageMax(runtime.Int("max"))
parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
filter, err := buildTriageFilter(runtime)
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
if parseErr != nil {
return d.Set("filter_error", parseErr.Error())
}
if err != nil {
return d.Set("filter_error", err.Error())
}
useSearch, pathErr := resolveTriagePath(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())
}
pageSize := maxCount
if pageSize > searchPageMax {
pageSize = searchPageMax
}
searchDesc := "search messages (auto-paginates up to --max)"
if parsed.RawToken != "" {
searchDesc = "search messages (continues from --page-token, up to --max)"
}
searchParams, searchBody, _ := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, parsed.RawToken, true)
d = d.POST(mailboxPath(mailbox, "search")).
Params(searchParams).
Body(searchBody).
Desc(searchDesc)
if showLabels {
d = d.POST(mailboxPath(mailbox, "messages", "batch_get")).
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
Desc("batch_get messages with format=metadata to populate labels")
}
return d
}
resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, true)
if err != nil {
return d.Set("filter_error", err.Error())
}
pageSize := maxCount
if pageSize > listPageMax {
pageSize = listPageMax
}
listDesc := "list message IDs (auto-paginates up to --max); batch_get with format=metadata"
if parsed.RawToken != "" {
listDesc = "list message IDs (continues from --page-token, up to --max); batch_get with format=metadata"
}
listParams, _ := buildListParams(runtime, mailbox, resolvedFilter, pageSize, parsed.RawToken, true)
return d.GET(mailboxPath(mailbox, "messages")).
Params(listParams).
POST(mailboxPath(mailbox, "messages", "batch_get")).
Body(map[string]interface{}{"format": "metadata", "message_ids": []string{"<message_id>"}}).
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 {
if runtime.Bool("print-filter-schema") {
printTriageFilterSchema(runtime)
return nil
}
mailbox := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailbox)
outFormat := runtime.Str("format")
query := runtime.Str("query")
if query != "" {
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
return err
}
}
showLabels := runtime.Bool("labels")
filter, err := buildTriageFilter(runtime)
if err != nil {
return err
}
maxCount := normalizeTriageMax(runtime.Int("max"))
parsed, err := parseTriagePageToken(runtime.Str("page-token"))
if err != nil {
return err
}
var messages []map[string]interface{}
var hasMore bool
var nextPageToken string
var notice string
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
}
pageToken := parsed.RawToken
for len(messages) < maxCount {
pageSize := maxCount - len(messages)
if pageSize > searchPageMax {
pageSize = searchPageMax
}
searchParams, searchBody, err := buildSearchParams(runtime, mailbox, query, resolvedFilter, pageSize, pageToken, false)
if err != nil {
return err
}
searchData, err := doJSONAPI(runtime, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: mailboxPath(mailbox, "search"),
QueryParams: toQueryParams(searchParams),
Body: searchBody,
}, "API call failed")
if err != nil {
return err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
messages = append(messages, pageMessages...)
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]
}
if showLabels && len(messages) > 0 {
messageIDs := make([]string, len(messages))
for i, m := range messages {
messageIDs[i] = strVal(m["message_id"])
}
enriched, err := fetchMessageMetas(runtime, mailbox, messageIDs)
if err != nil {
return err
}
mergeTriageLabels(messages, enriched)
}
} else {
resolvedFilter, err := resolveListFilter(runtime, mailbox, filter, false)
if err != nil {
return err
}
var (
messageIDs []string
pageToken = parsed.RawToken
)
for len(messageIDs) < maxCount {
pageSize := maxCount - len(messageIDs)
if pageSize > listPageMax {
pageSize = listPageMax
}
listParams, err := buildListParams(runtime, mailbox, resolvedFilter, pageSize, pageToken, false)
if err != nil {
return err
}
listData, err := doJSONAPI(runtime, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: mailboxPath(mailbox, "messages"),
QueryParams: toQueryParams(listParams),
}, "API call failed")
if err != nil {
return err
}
ids := extractTriageMessageIDs(listData["items"])
messageIDs = append(messageIDs, ids...)
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]
}
messages, err = fetchMessageMetas(runtime, mailbox, messageIDs)
if err != nil {
return err
}
}
if messages == nil {
messages = []map[string]interface{}{}
}
// Inject mailbox_id into every message so downstream consumers
// (e.g. mail +message) can preserve the mailbox context for
// public/shared mailbox scenarios.
for _, msg := range messages {
msg["mailbox_id"] = mailbox
}
switch outFormat {
case "json", "data":
outData := map[string]interface{}{
"messages": messages,
"mailbox_id": mailbox,
"count": len(messages),
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
}
var rows []map[string]interface{}
for _, msg := range messages {
row := map[string]interface{}{
"date": sanitizeForTerminal(strVal(msg["date"])),
"from": sanitizeForTerminal(strVal(msg["from"])),
"subject": sanitizeForTerminal(strVal(msg["subject"])),
"message_id": msg["message_id"],
}
if mailbox != "me" {
row["mailbox_id"] = mailbox
}
if showLabels {
row["labels"] = msg["labels"]
}
rows = append(rows, row)
}
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 mailbox != "me" {
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
appendTriagePaginationFilterFlags(&hint, runtime)
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
}
return nil
},
}
func printTriageFilterSchema(runtime *common.RuntimeContext) {
schema := map[string]interface{}{
"_description": "--filter field reference for mail +triage. All fields are optional. --filter narrows results by exact conditions; --query does full-text search.",
"fields": map[string]interface{}{
"folder": map[string]string{
"type": "string",
"desc": "文件夹名称筛选,仅返回在所选目录下的邮件。仅支持传入系统文件夹名称(固定值 inbox/sent/draft/trash/spam/archive/priority/flagged/other/scheduled)、自定义文件夹名称。子文件夹需使用 parent_name/child_name 格式,可通过 folder list 接口查看文件夹名称。",
"example": "inbox",
},
"folder_id": map[string]string{
"type": "string",
"desc": "Folder ID (takes priority over folder). System IDs: INBOX, SENT, DRAFT, TRASH, SPAM, ARCHIVED. Custom folders use numeric IDs from the folder list API.",
"example": "INBOX",
},
"label": map[string]string{
"type": "string",
"desc": "自定义标签名称筛选,仅返回包含指定自定义标签的邮件。子标签需使用 parent_name/child_name 格式,可通过 label list 接口查看标签名称。",
"example": "my-label",
},
"label_id": map[string]string{
"type": "string",
"desc": "Label ID (takes priority over label). Custom labels use numeric IDs from the label list API.",
"example": "7543462602685287954",
},
"from": map[string]string{
"type": "[]string",
"desc": "Sender email addresses (OR logic within list). Triggers search path.",
"example": `["alice@example.com"]`,
},
"to": map[string]string{
"type": "[]string",
"desc": "To-recipient email addresses (OR logic within list). Triggers search path.",
"example": `["bob@example.com"]`,
},
"cc": map[string]string{
"type": "[]string",
"desc": "CC-recipient email addresses. Triggers search path.",
"example": `["cc@example.com"]`,
},
"bcc": map[string]string{
"type": "[]string",
"desc": "BCC-recipient email addresses. Triggers search path.",
"example": `["bcc@example.com"]`,
},
"subject": map[string]string{
"type": "string",
"desc": "Subject keyword match. Triggers search path.",
"example": "report",
},
"has_attachment": map[string]string{
"type": "bool",
"desc": "Filter by attachment presence (true/false). Triggers search path.",
"example": "true",
},
"is_unread": map[string]string{
"type": "bool",
"desc": "Filter unread messages. Use is_unread=true.",
"example": "true",
},
"time_range": map[string]string{
"type": "object",
"desc": "Time range filter with start_time and/or end_time (ISO 8601 with timezone). Triggers search path.",
"example": `{"start_time":"2026-03-10T00:00:00+08:00","end_time":"2026-03-17T23:59:59+08:00"}`,
},
},
"notes": []string{
"folder/folder_id and label/label_id work on both list and search paths.",
"from, to, cc, bcc, subject, has_attachment, time_range trigger the search path.",
"--query and search-path filter fields can be combined.",
"folder and label cannot be set at the same time on the list path.",
"System labels (IMPORTANT/FLAGGED/OTHER) are automatically passed as folder (priority/flagged/other) in search.",
},
"examples": []string{
`{"folder":"INBOX"}`,
`folder=INBOX`,
`is_unread`,
`{"is_read":false}`,
`{"folder":"INBOX","from":["alice@example.com"]}`,
`{"label":"FLAGGED","is_unread":true}`,
`{"folder":"SENT","time_range":{"start_time":"2026-03-01T00:00:00+08:00"}}`,
},
}
runtime.Out(schema, nil)
}
func parseTriageFilter(filterStr string) (triageFilter, error) {
raw := strings.TrimSpace(filterStr)
if raw == "" {
return triageFilter{}, nil
}
if !strings.HasPrefix(raw, "{") {
return parseTriageFilterToken(raw)
}
return parseTriageFilterJSON(raw)
}
func parseTriageFilterJSON(raw string) (triageFilter, error) {
var fields map[string]json.RawMessage
dec := json.NewDecoder(strings.NewReader(raw))
if err := dec.Decode(&fields); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s. Supported forms: JSON object, key=value, is_read, is_unread", err)
}
var extra interface{}
if err := dec.Decode(&extra); err != io.EOF {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: multiple JSON values. Supported forms: JSON object, key=value, is_read, is_unread")
}
var filter triageFilter
for _, key := range triageFilterJSONFieldOrder(fields) {
value := fields[key]
switch key {
case "folder":
if err := json.Unmarshal(value, &filter.Folder); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.folder: %s", err)
}
case "folder_id":
if err := json.Unmarshal(value, &filter.FolderID); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.folder_id: %s", err)
}
case "label":
if err := json.Unmarshal(value, &filter.Label); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.label: %s", err)
}
case "label_id":
if err := json.Unmarshal(value, &filter.LabelID); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.label_id: %s", err)
}
case "from":
if err := json.Unmarshal(value, &filter.From); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.from: %s", err)
}
case "to":
if err := json.Unmarshal(value, &filter.To); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.to: %s", err)
}
case "cc":
if err := json.Unmarshal(value, &filter.CC); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.cc: %s", err)
}
case "bcc":
if err := json.Unmarshal(value, &filter.BCC); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.bcc: %s", err)
}
case "subject":
if err := json.Unmarshal(value, &filter.Subject); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.subject: %s", err)
}
case "has_attachment":
var boolValue bool
if err := json.Unmarshal(value, &boolValue); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.has_attachment: %s", err)
}
filter.HasAttachment = &boolValue
case "is_unread":
var boolValue bool
if err := json.Unmarshal(value, &boolValue); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.is_unread: %s", err)
}
if err := mergeTriageUnreadFilter(&filter, boolValue, "is_unread"); err != nil {
return triageFilter{}, err
}
case "is_read":
var boolValue bool
if err := json.Unmarshal(value, &boolValue); err != nil {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.is_read: %s", err)
}
if err := mergeTriageUnreadFilter(&filter, !boolValue, "is_read"); err != nil {
return triageFilter{}, err
}
case "time_range":
timeRange, err := parseTriageTimeRange(value)
if err != nil {
return triageFilter{}, err
}
filter.TimeRange = timeRange
default:
if hint := triageFilterUnknownFieldHint(`json: unknown field "` + key + `"`); hint != "" {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
}
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unknown field %q. Run --print-filter-schema to see supported fields", key)
}
}
return filter, nil
}
func parseTriageTimeRange(value json.RawMessage) (*triageTimeRange, error) {
var timeRange triageTimeRange
dec := json.NewDecoder(bytes.NewReader(value))
dec.DisallowUnknownFields()
if err := dec.Decode(&timeRange); err != nil {
if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" {
return nil, mailValidationParamError("--filter", "invalid --filter.time_range: %s", hint)
}
return nil, mailValidationParamError("--filter", "invalid --filter.time_range: %s", err)
}
var extra interface{}
if err := dec.Decode(&extra); err != io.EOF {
return nil, mailValidationParamError("--filter", "invalid --filter.time_range: multiple JSON values")
}
return &timeRange, nil
}
func parseTriageFilterToken(raw string) (triageFilter, error) {
if !strings.Contains(raw, "=") {
switch strings.ToLower(raw) {
case "is_unread":
return triageFilter{IsUnread: boolPtrValue(true)}, nil
case "is_read":
return triageFilter{}, unsupportedTriageReadFilterError()
default:
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %q is not valid JSON, key=value, is_read, or is_unread. Run --print-filter-schema to see supported fields", raw)
}
}
if strings.Contains(raw, ",") {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: comma-separated key=value filters are not supported; pass a JSON object for multiple fields")
}
key, value, _ := strings.Cut(raw, "=")
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
var filter triageFilter
switch key {
case "folder":
filter.Folder = value
case "folder_id":
filter.FolderID = value
case "label":
filter.Label = value
case "label_id":
filter.LabelID = value
case "subject":
filter.Subject = value
case "has_attachment":
boolValue, err := parseTriageBoolKV(key, value)
if err != nil {
return triageFilter{}, err
}
filter.HasAttachment = &boolValue
case "is_unread":
boolValue, err := parseTriageBoolKV(key, value)
if err != nil {
return triageFilter{}, err
}
if err := mergeTriageUnreadFilter(&filter, boolValue, "is_unread"); err != nil {
return triageFilter{}, err
}
case "is_read":
boolValue, err := parseTriageBoolKV(key, value)
if err != nil {
return triageFilter{}, err
}
if err := mergeTriageUnreadFilter(&filter, !boolValue, "is_read"); err != nil {
return triageFilter{}, err
}
default:
if hint := triageFilterUnknownFieldHint(`json: unknown field "` + key + `"`); hint != "" {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
}
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unknown key %q. Run --print-filter-schema to see supported fields", key)
}
return filter, nil
}
func triageFilterJSONFieldOrder(fields map[string]json.RawMessage) []string {
preferred := []string{
"folder",
"folder_id",
"label",
"label_id",
"from",
"to",
"cc",
"bcc",
"subject",
"has_attachment",
"is_unread",
"is_read",
"time_range",
}
keys := make([]string, 0, len(fields))
seen := make(map[string]bool, len(fields))
for _, key := range preferred {
if _, ok := fields[key]; ok {
keys = append(keys, key)
seen[key] = true
}
}
var unknown []string
for key := range fields {
if !seen[key] {
unknown = append(unknown, key)
}
}
sort.Strings(unknown)
return append(keys, unknown...)
}
func parseTriageBoolKV(key, value string) (bool, error) {
switch strings.ToLower(value) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, mailValidationParamError("--filter", "invalid --filter.%s: %q must be true or false", key, value)
}
}
func mergeTriageStringFilter(current *string, canonical, value string) error {
if *current != "" && value != "" && *current != value {
return mailValidationParamError("--"+strings.ReplaceAll(canonical, "_", "-"), "conflicting %s filter: %q conflicts with %q", canonical, *current, value)
}
if value != "" {
*current = value
}
return nil
}
func mergeTriageUnreadFilter(filter *triageFilter, isUnread bool, source string) error {
if !isUnread {
return unsupportedTriageReadFilterError()
}
filter.IsUnread = &isUnread
return nil
}
func unsupportedTriageReadFilterError() error {
return mailValidationParamError("--filter", "only is_unread=true or is_read=false queries are supported")
}
func boolPtrValue(v bool) *bool {
return &v
}
func buildTriageFilter(runtime *common.RuntimeContext) (triageFilter, error) {
filter, err := parseTriageFilter(runtime.Str("filter"))
if err != nil {
return triageFilter{}, err
}
if runtime.Changed("folder") {
if err := mergeTriageStringFilter(&filter.Folder, "folder", strings.TrimSpace(runtime.Str("folder"))); err != nil {
return triageFilter{}, err
}
}
if runtime.Changed("folder-id") {
if err := mergeTriageStringFilter(&filter.FolderID, "folder_id", strings.TrimSpace(runtime.Str("folder-id"))); err != nil {
return triageFilter{}, err
}
}
if runtime.Changed("is-unread") {
value := runtime.Bool("is-unread")
if err := mergeTriageUnreadFilter(&filter, value, "is_unread"); err != nil {
return triageFilter{}, err
}
}
return filter, nil
}
func appendTriagePaginationFilterFlags(hint *strings.Builder, runtime *common.RuntimeContext) {
if runtime.Changed("folder") {
hint.WriteString(" --folder " + shellQuote(runtime.Str("folder")))
}
if runtime.Changed("folder-id") {
hint.WriteString(" --folder-id " + shellQuote(runtime.Str("folder-id")))
}
if runtime.Changed("is-unread") {
hint.WriteString(" --is-unread=" + shellQuote(fmt.Sprintf("%t", runtime.Bool("is-unread"))))
}
}
func triageFilterUnknownFieldHint(msg string) string {
const unknownFieldPrefix = `json: unknown field "`
if !strings.HasPrefix(msg, unknownFieldPrefix) || !strings.HasSuffix(msg, `"`) {
return ""
}
field := strings.TrimSuffix(strings.TrimPrefix(msg, unknownFieldPrefix), `"`)
if field == "" {
return ""
}
suggestions := map[string]string{
"unread": "is_unread",
"create_time": "time_range",
"start": "time_range.start_time",
"end": "time_range.end_time",
"after": "time_range.start_time",
"before": "time_range.end_time",
}
const validFields = "folder, folder_id, label, label_id, is_unread, is_read(false only), from, to, cc, bcc, subject, has_attachment, time_range. Run --print-filter-schema to see supported fields"
const timeRangeExample = ` Example: {"time_range":{"start_time":"2026-03-10T00:00:00+08:00","end_time":"2026-03-17T23:59:59+08:00"}}`
if suggestion, ok := suggestions[field]; ok {
msg := fmt.Sprintf("unknown field %q; did you mean %q? Valid fields: %s", field, suggestion, validFields)
if strings.HasPrefix(suggestion, "time_range.") || suggestion == "time_range" {
msg += timeRangeExample
}
return msg
}
return fmt.Sprintf("unknown field %q. Valid fields: %s", field, validFields)
}
func usesTriageSearchPath(query string, filter triageFilter) bool {
if strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0 {
return true
}
// System labels (important/flagged/other and their aliases) can appear in either
// folder or label field. They always require the search path because the search API
// treats them as folder values, and they are not real folder IDs for the list API.
if v := strings.TrimSpace(filter.Folder); v != "" {
if _, ok := resolveSystemLabel(v); ok {
return true
}
if searchOnlyFolderNames[strings.ToLower(v)] {
return true
}
}
if v := strings.TrimSpace(filter.Label); v != "" {
if _, ok := resolveSystemLabel(v); ok {
return true
}
}
if v := strings.TrimSpace(filter.LabelID); v != "" {
if _, ok := resolveSystemLabel(v); ok {
return true
}
}
return false
}
func fetchMessageMetas(runtime *common.RuntimeContext, mailbox string, messageIDs []string) ([]map[string]interface{}, error) {
if len(messageIDs) == 0 {
return nil, nil
}
const maxBatchGetIDs = 20
byID := make(map[string]map[string]interface{}, len(messageIDs))
for start := 0; start < len(messageIDs); start += maxBatchGetIDs {
end := start + maxBatchGetIDs
if end > len(messageIDs) {
end = len(messageIDs)
}
data, err := doJSONAPI(runtime, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: mailboxPath(mailbox, "messages", "batch_get"),
Body: map[string]interface{}{
"format": "metadata",
"message_ids": messageIDs[start:end],
},
}, "API call failed")
if err != nil {
return nil, err
}
rawMessages, _ := data["messages"].([]interface{})
for _, item := range rawMessages {
msg, ok := item.(map[string]interface{})
if !ok {
continue
}
messageID := strVal(msg["message_id"])
if messageID == "" {
continue
}
byID[messageID] = buildTriageMessageMeta(msg, messageID)
}
}
messages := make([]map[string]interface{}, 0, len(messageIDs))
for _, messageID := range messageIDs {
if msg, ok := byID[messageID]; ok {
messages = append(messages, msg)
continue
}
messages = append(messages, map[string]interface{}{"message_id": messageID, "error": "metadata not returned by batch_get"})
}
return messages, nil
}
func buildTriageMessageMeta(msg map[string]interface{}, fallbackMessageID string) map[string]interface{} {
item := map[string]interface{}{
"message_id": fallbackMessageID,
}
if v := strVal(msg["message_id"]); v != "" {
item["message_id"] = v
}
item["thread_id"] = strVal(msg["thread_id"])
item["subject"] = strVal(msg["subject"])
item["folder"] = strVal(msg["folder_id"])
if d := strVal(msg["date"]); d != "" {
item["date"] = d
} else if ts, ok := msg["internal_date"]; ok {
item["date"] = common.FormatTime(ts)
}
if from, ok := msg["head_from"].(map[string]interface{}); ok {
item["from"] = formatAddress(from)
}
var labelIDs []string
if labels, ok := msg["label_ids"].([]interface{}); ok {
for _, l := range labels {
if s, ok := l.(string); ok {
labelIDs = append(labelIDs, s)
}
}
}
item["labels"] = strings.Join(labelIDs, ",")
return item
}
func buildTriageMessagesFromSearchItems(raw interface{}) []map[string]interface{} {
rawItems, _ := raw.([]interface{})
messages := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
searchItem, ok := item.(map[string]interface{})
if !ok {
continue
}
meta, _ := searchItem["meta_data"].(map[string]interface{})
messageID := ""
if meta != nil {
messageID = strVal(meta["message_biz_id"])
}
if messageID == "" {
continue
}
message := map[string]interface{}{
"message_id": messageID,
"labels": "",
}
if meta != nil {
message["thread_id"] = strVal(meta["thread_id"])
message["subject"] = strVal(meta["title"])
message["date"] = strVal(meta["create_time"])
if from, ok := meta["from"].(map[string]interface{}); ok {
message["from"] = formatAddress(from)
}
// Decode body fields when present in search meta_data (base64url-encoded by the server).
decodeBodyFields(meta, message)
}
messages = append(messages, message)
}
return messages
}
func mergeTriageLabels(messages, enriched []map[string]interface{}) {
labelsByID := make(map[string]string, len(enriched))
for _, item := range enriched {
messageID := strVal(item["message_id"])
if messageID == "" {
continue
}
labelsByID[messageID] = strVal(item["labels"])
}
for _, item := range messages {
messageID := strVal(item["message_id"])
if messageID == "" {
continue
}
if labels, ok := labelsByID[messageID]; ok {
item["labels"] = labels
}
}
}
func resolveListFilter(runtime *common.RuntimeContext, mailboxID string, f triageFilter, dryRun bool) (triageFilter, error) {
resolved := f
if dryRun {
if value := strings.TrimSpace(f.FolderID); value != "" {
if sysID, ok := resolveFolderSystemAliasOrID(value); ok {
resolved.FolderID = sysID
} else {
resolved.FolderID = value
}
resolved.Folder = ""
} else if value := strings.TrimSpace(f.Folder); value != "" {
if sysID, ok := resolveFolderSystemAliasOrID(value); ok {
resolved.FolderID = sysID
resolved.Folder = ""
}
}
if value := strings.TrimSpace(f.LabelID); value != "" {
if sysID, ok := resolveLabelSystemID(value); ok {
resolved.LabelID = sysID
} else {
resolved.LabelID = value
}
resolved.Label = ""
} else if value := strings.TrimSpace(f.Label); value != "" {
if sysID, ok := resolveLabelSystemID(value); ok {
resolved.LabelID = sysID
resolved.Label = ""
}
}
return resolved, nil
}
if value := strings.TrimSpace(f.FolderID); value != "" {
id, err := resolveFolderID(runtime, mailboxID, value)
if err != nil {
return triageFilter{}, err
}
resolved.FolderID = id
resolved.Folder = ""
} else if value := strings.TrimSpace(f.Folder); value != "" {
id, err := resolveFolderName(runtime, mailboxID, value)
if err != nil {
return triageFilter{}, err
}
resolved.FolderID = id
resolved.Folder = ""
}
if value := strings.TrimSpace(f.LabelID); value != "" {
id, err := resolveLabelID(runtime, mailboxID, value)
if err != nil {
return triageFilter{}, err
}
resolved.LabelID = id
resolved.Label = ""
} else if value := strings.TrimSpace(f.Label); value != "" {
id, err := resolveLabelName(runtime, mailboxID, value)
if err != nil {
return triageFilter{}, err
}
resolved.LabelID = id
resolved.Label = ""
}
return resolved, nil
}
func resolveSearchFilter(runtime *common.RuntimeContext, mailboxID string, f triageFilter, dryRun bool) (triageFilter, error) {
resolved := f
// Step 1: Check if folder or label/label_id contains a system label.
// System labels (important/flagged/other) are passed as folder in the search API.
var systemLabelFolder string
if v := strings.TrimSpace(f.Folder); v != "" {
if id, ok := resolveSystemLabel(v); ok {
systemLabelFolder = systemLabelSearchName[id]
}
}
if systemLabelFolder == "" {
if v := strings.TrimSpace(f.LabelID); v != "" {
if id, ok := resolveSystemLabel(v); ok {
systemLabelFolder = systemLabelSearchName[id]
}
}
}
if systemLabelFolder == "" {
if v := strings.TrimSpace(f.Label); v != "" {
if id, ok := resolveSystemLabel(v); ok {
systemLabelFolder = systemLabelSearchName[id]
}
}
}
if systemLabelFolder != "" {
// System label found: clear label fields and set as folder.
resolved.Label = ""
resolved.LabelID = ""
// If the user also specified a real folder, keep the system label as folder
// (it takes precedence since system labels are the primary intent).
resolved.Folder = systemLabelFolder
resolved.FolderID = ""
return resolved, nil
}
// Step 2: Normal folder resolution.
folderName, err := resolveSearchFolderFilter(runtime, mailboxID, f, dryRun)
if err != nil {
return triageFilter{}, err
}
resolved.Folder = folderName
resolved.FolderID = ""
// Step 3: Normal label resolution (custom labels only, since system labels handled above).
labelName, err := resolveSearchLabelFilter(runtime, mailboxID, f, dryRun)
if err != nil {
return triageFilter{}, err
}
resolved.Label = labelName
resolved.LabelID = ""
return resolved, nil
}
func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageFilter, pageSize int, pageToken string, dryRun bool) (map[string]interface{}, error) {
// folder_id is required by the API unless label_id is specified
// (folder_id and label_id cannot be set at the same time)
hasLabel := strings.TrimSpace(f.LabelID) != "" || strings.TrimSpace(f.Label) != ""
params := map[string]interface{}{
"page_size": pageSize,
}
if !hasLabel {
params["folder_id"] = "INBOX"
}
if pageToken != "" {
params["page_token"] = pageToken
}
folderIDFromFilter := strings.TrimSpace(f.FolderID)
folderFromFilter := strings.TrimSpace(f.Folder)
if folderIDFromFilter != "" {
if dryRun {
if sysID, ok := resolveFolderSystemAliasOrID(folderIDFromFilter); ok {
params["folder_id"] = sysID
} else {
params["folder_id"] = folderIDFromFilter
}
} else {
params["folder_id"] = folderIDFromFilter
}
} else if folderFromFilter != "" {
if dryRun {
if sysID, ok := resolveFolderSystemAliasOrID(folderFromFilter); ok {
params["folder_id"] = sysID
} else {
params["folder_id"] = folderFromFilter
}
} else {
params["folder_id"] = folderFromFilter
}
}
// list API uses "only_unread" (true-only flag); false has no list-path equivalent
if f.IsUnread != nil && *f.IsUnread {
params["only_unread"] = true
}
labelIDFromFilter := strings.TrimSpace(f.LabelID)
labelFromFilter := strings.TrimSpace(f.Label)
if labelIDFromFilter != "" {
if dryRun {
if sysID, ok := resolveLabelSystemID(labelIDFromFilter); ok {
params["label_id"] = sysID
} else {
params["label_id"] = labelIDFromFilter
}
} else {
params["label_id"] = labelIDFromFilter
}
} else if labelFromFilter != "" {
if dryRun {
if sysID, ok := resolveLabelSystemID(labelFromFilter); ok {
params["label_id"] = sysID
} else {
params["label_id"] = labelFromFilter
}
} else {
params["label_id"] = labelFromFilter
}
}
return params, nil
}
func buildSearchParams(runtime *common.RuntimeContext, mailboxID, query string, f triageFilter, pageSize int, pageToken string, dryRun bool) (map[string]interface{}, map[string]interface{}, error) {
params := map[string]interface{}{
"page_size": pageSize,
}
if pageToken != "" {
params["page_token"] = pageToken
}
filterBody := map[string]interface{}{}
if values := trimStringList(f.From); len(values) > 0 {
filterBody["from"] = values
}
if values := trimStringList(f.To); len(values) > 0 {
filterBody["to"] = values
}
if values := trimStringList(f.CC); len(values) > 0 {
filterBody["cc"] = values
}
if values := trimStringList(f.BCC); len(values) > 0 {
filterBody["bcc"] = values
}
if subject := strings.TrimSpace(f.Subject); subject != "" {
filterBody["subject"] = subject
}
if f.HasAttachment != nil {
filterBody["has_attachment"] = *f.HasAttachment
}
if f.IsUnread != nil {
filterBody["is_unread"] = *f.IsUnread
}
if createTime := buildSearchCreateTime(f.TimeRange); len(createTime) > 0 {
filterBody["create_time"] = createTime
}
// f.Folder and f.Label are already resolved by resolveSearchFilter before this call.
if folderName := strings.TrimSpace(f.Folder); folderName != "" {
filterBody["folder"] = []string{folderName}
}
if labelName := strings.TrimSpace(f.Label); labelName != "" {
filterBody["label"] = []string{labelName}
}
body := map[string]interface{}{}
if strings.TrimSpace(query) != "" {
body["query"] = strings.TrimSpace(query)
}
if len(filterBody) > 0 {
body["filter"] = filterBody
}
return params, body, nil
}
func triageQueryFilterFields(f triageFilter) []string {
fields := make([]string, 0, 8)
if len(f.From) > 0 {
fields = append(fields, "from")
}
if len(f.To) > 0 {
fields = append(fields, "to")
}
if len(f.CC) > 0 {
fields = append(fields, "cc")
}
if len(f.BCC) > 0 {
fields = append(fields, "bcc")
}
if strings.TrimSpace(f.Subject) != "" {
fields = append(fields, "subject")
}
if f.HasAttachment != nil {
fields = append(fields, "has_attachment")
}
if f.TimeRange != nil && (strings.TrimSpace(f.TimeRange.StartTime) != "" || strings.TrimSpace(f.TimeRange.EndTime) != "") {
fields = append(fields, "time_range")
}
sort.Strings(fields)
return fields
}
func buildSearchCreateTime(rng *triageTimeRange) map[string]interface{} {
if rng == nil {
return nil
}
createTime := map[string]interface{}{}
if start := strings.TrimSpace(rng.StartTime); start != "" {
createTime["start_time"] = start
}
if end := strings.TrimSpace(rng.EndTime); end != "" {
createTime["end_time"] = end
}
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, mailValidationParamError("--page-token", "--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, mailValidationParamError("--page-token", "--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, mailValidationParamError("--page-token", "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{}, mailValidationParamError("--page-token", "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{}, mailValidationParamError("--page-token", "invalid --page-token: must start with 'search:' or 'list:' prefix, got %q", path)
}
if raw == "" {
return triagePageToken{}, mailValidationParamError("--page-token", "invalid --page-token: token value is empty after '%s:' prefix", path)
}
return triagePageToken{Path: path, RawToken: raw}, nil
}
func normalizeTriageMax(maxCount int) int {
if maxCount <= 0 {
return 20
}
if maxCount > triageMaxLimit {
return triageMaxLimit
}
return maxCount
}
func resolveSearchFolderFilter(runtime *common.RuntimeContext, mailboxID string, f triageFilter, dryRun bool) (string, error) {
if value := strings.TrimSpace(f.FolderID); value != "" {
if dryRun {
if id, ok := resolveFolderSystemAliasOrID(value); ok {
return folderSystemIDToAlias[id], nil
}
return value, nil
}
return resolveFolderQueryNameFromID(runtime, mailboxID, value)
}
if value := strings.TrimSpace(f.Folder); value != "" {
if dryRun {
if searchOnlyFolderNames[strings.ToLower(value)] {
return strings.ToLower(value), nil
}
if id, ok := resolveFolderSystemAliasOrID(value); ok {
return folderSystemIDToAlias[id], nil
}
return value, nil
}
return resolveFolderQueryName(runtime, mailboxID, value)
}
return "", nil
}
func resolveSearchLabelFilter(runtime *common.RuntimeContext, mailboxID string, f triageFilter, dryRun bool) (string, error) {
if value := strings.TrimSpace(f.LabelID); value != "" {
if dryRun {
if id, ok := resolveLabelSystemID(value); ok {
return systemLabelSearchName[id], nil
}
return value, nil
}
return resolveLabelQueryNameFromID(runtime, mailboxID, value)
}
if value := strings.TrimSpace(f.Label); value != "" {
if dryRun {
if id, ok := resolveLabelSystemID(value); ok {
return systemLabelSearchName[id], nil
}
return value, nil
}
return resolveLabelQueryName(runtime, mailboxID, value)
}
return "", nil
}
func trimStringList(values []string) []string {
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
trimmed = append(trimmed, value)
}
return trimmed
}
func extractTriageMessageIDs(raw interface{}) []string {
rawItems, _ := raw.([]interface{})
messageIDs := make([]string, 0, len(rawItems))
for _, item := range rawItems {
msgID, _ := item.(string)
if msgID == "" {
if m, ok := item.(map[string]interface{}); ok {
msgID, _ = m["message_id"].(string)
if msgID == "" {
msgID, _ = m["id"].(string)
}
}
}
if msgID != "" {
messageIDs = append(messageIDs, msgID)
}
}
return messageIDs
}
func formatAddress(addr map[string]interface{}) string {
name, _ := addr["name"].(string)
email, _ := addr["mail_address"].(string)
if email == "" {
email, _ = addr["address"].(string)
}
if name != "" && email != "" {
return fmt.Sprintf("%s <%s>", name, email)
}
if email != "" {
return email
}
return name
}
func doJSONAPI(runtime *common.RuntimeContext, req *larkcore.ApiReq, action string) (map[string]interface{}, error) {
var lastErr error
for attempt := 0; attempt <= triageAPIRetries; attempt++ {
apiResp, err := runtime.DoAPI(req)
if err != nil {
lastErr = mailDecorateProblemMessage(client.WrapDoAPIError(err), "%s", action)
if attempt == triageAPIRetries {
return nil, lastErr
}
} else {
data, handleErr := runtime.ClassifyAPIResponse(apiResp)
if handleErr == nil {
return data, nil
}
lastErr = mailDecorateProblemMessage(handleErr, "%s", action)
if !shouldRetryTriageAPIError(lastErr) || attempt == triageAPIRetries {
return nil, lastErr
}
}
time.Sleep(time.Duration(200*(attempt+1)) * time.Millisecond)
}
return nil, lastErr
}
func shouldRetryTriageAPIError(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return p.Subtype == errs.SubtypeRateLimit || p.Category == errs.CategoryNetwork
}
func toQueryParams(params map[string]interface{}) larkcore.QueryParams {
queryParams := make(larkcore.QueryParams)
for key, value := range params {
queryParams.Set(key, fmt.Sprintf("%v", value))
}
return queryParams
}