mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
2a1613484a
* 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>
1405 lines
47 KiB
Go
1405 lines
47 KiB
Go
// 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
|
||
}
|