Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

1134 lines
38 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"encoding/json"
"fmt"
"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", 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"`},
{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 := 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())
}
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 := parseTriageFilter(runtime.Str("filter"))
if err != nil {
return err
}
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
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))
}
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 by read status. On list path only is_unread=true is supported; on search path both true/false work.",
"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","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) {
var filter triageFilter
if strings.TrimSpace(filterStr) == "" {
return filter, nil
}
dec := json.NewDecoder(strings.NewReader(filterStr))
dec.DisallowUnknownFields()
if err := dec.Decode(&filter); err != nil {
if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
}
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", err)
}
return filter, nil
}
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",
"after": "time_range.start_time",
"before": "time_range.end_time",
}
const validFields = "folder, folder_id, label, label_id, is_unread, from, to, cc, bcc, subject, has_attachment, time_range"
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
}
// 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
}
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
}