diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index a4048ff15e..eb8e0815fb 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -4,9 +4,11 @@ package mail import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "sort" "strings" @@ -59,7 +61,10 @@ var MailTriage = common.Shortcut{ {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: "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"}, @@ -74,7 +79,7 @@ var MailTriage = common.Shortcut{ showLabels := runtime.Bool("labels") maxCount := resolveTriagePageSize(runtime) parsed, parseErr := parseTriagePageToken(runtime.Str("page-token")) - filter, err := parseTriageFilter(runtime.Str("filter")) + filter, err := buildTriageFilter(runtime) d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")) if parseErr != nil { return d.Set("filter_error", parseErr.Error()) @@ -146,7 +151,7 @@ var MailTriage = common.Shortcut{ } } showLabels := runtime.Bool("labels") - filter, err := parseTriageFilter(runtime.Str("filter")) + filter, err := buildTriageFilter(runtime) if err != nil { return err } @@ -328,6 +333,7 @@ var MailTriage = common.Shortcut{ 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()) } @@ -398,7 +404,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "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.", + "desc": "Filter unread messages. Use is_unread=true.", "example": "true", }, "time_range": map[string]string{ @@ -416,6 +422,9 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "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"}}`, @@ -425,21 +434,291 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { } 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 - if strings.TrimSpace(filterStr) == "" { - return filter, nil + 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) + } } - dec := json.NewDecoder(strings.NewReader(filterStr)) + 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(&filter); err != nil { + 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: %s", err) + 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, `"`) { @@ -452,10 +731,12 @@ func triageFilterUnknownFieldHint(msg string) string { 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, from, to, cc, bcc, subject, has_attachment, time_range" + 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) diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 50f08fae16..3c189f1cb0 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -224,6 +224,187 @@ func TestParseTriageFilterUnknownFieldHintUnread(t *testing.T) { } } +func TestParseTriageFilterRejectsUnknownTimeRangeField(t *testing.T) { + _, err := parseTriageFilter(`{"time_range":{"start":"2026-01-01T00:00:00+08:00"}}`) + if err == nil { + t.Fatalf("expected error for unknown time_range field") + } + assertTriageFilterValidationError(t, err, "--filter") + if !strings.Contains(err.Error(), `did you mean "time_range.start_time"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { + tests := []struct { + name string + raw string + want *bool + }{ + {name: "json is_read false means unread", raw: `{"is_read":false}`, want: boolPtr(true)}, + {name: "json is_unread true means unread", raw: `{"is_unread":true}`, want: boolPtr(true)}, + {name: "alias is_unread means unread", raw: `is_unread`, want: boolPtr(true)}, + {name: "kv is_read false means unread", raw: `is_read=false`, want: boolPtr(true)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTriageFilter(tt.raw) + if err != nil { + t.Fatalf("parseTriageFilter(%q) error = %v", tt.raw, err) + } + if (got.IsUnread == nil) != (tt.want == nil) { + t.Fatalf("is_unread = %v, want %v", got.IsUnread, tt.want) + } + if got.IsUnread != nil && *got.IsUnread != *tt.want { + t.Fatalf("is_unread = %v, want %v", *got.IsUnread, *tt.want) + } + }) + } +} + +func TestParseTriageFilterRejectsReadStatusInputs(t *testing.T) { + tests := []string{ + `{"is_read":true}`, + `{"is_unread":false}`, + `is_read`, + `is_unread=false`, + } + for _, raw := range tests { + t.Run(raw, func(t *testing.T) { + _, err := parseTriageFilter(raw) + if err == nil { + t.Fatalf("expected error for %q", raw) + } + assertTriageFilterValidationError(t, err, "--filter") + if !strings.Contains(err.Error(), "only is_unread=true or is_read=false queries are supported") { + t.Fatalf("error %q does not explain supported unread filtering", err.Error()) + } + }) + } +} + +func TestParseTriageFilterJSONCompatibilityAndKV(t *testing.T) { + jsonFilter, err := parseTriageFilter(`{"folder":"INBOX","is_read":false,"subject":"report"}`) + if err != nil { + t.Fatalf("json compatibility parse failed: %v", err) + } + if jsonFilter.Folder != "INBOX" || jsonFilter.Subject != "report" || jsonFilter.IsUnread == nil || !*jsonFilter.IsUnread { + t.Fatalf("json filter mismatch: %+v", jsonFilter) + } + + kvFolder, err := parseTriageFilter(`folder=INBOX`) + if err != nil { + t.Fatalf("kv folder parse failed: %v", err) + } + if kvFolder.Folder != "INBOX" { + t.Fatalf("folder = %q, want INBOX", kvFolder.Folder) + } + + kvFolderID, err := parseTriageFilter(`folder_id=DRAFT`) + if err != nil { + t.Fatalf("kv folder_id parse failed: %v", err) + } + if kvFolderID.FolderID != "DRAFT" { + t.Fatalf("folder_id = %q, want DRAFT", kvFolderID.FolderID) + } +} + +func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { + tests := []struct { + name string + raw string + wantSubstr string + }{ + {name: "unknown kv", raw: "unknown=value", wantSubstr: "--print-filter-schema"}, + {name: "invalid bool", raw: "is_unread=maybe", wantSubstr: "must be true or false"}, + {name: "invalid alias", raw: "not-json", wantSubstr: "JSON, key=value, is_read, or is_unread"}, + {name: "comma-separated kv", raw: "folder=INBOX,is_unread=true", wantSubstr: "comma-separated key=value filters are not supported"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseTriageFilter(tt.raw) + if err == nil { + t.Fatalf("expected error for %q", tt.raw) + } + assertTriageFilterValidationError(t, err, "--filter") + if !strings.Contains(err.Error(), tt.wantSubstr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantSubstr) + } + }) + } +} + +func TestParseTriageFilterReadStatusErrorIsDeterministic(t *testing.T) { + for i := 0; i < 20; i++ { + _, err := parseTriageFilter(`{"is_read":true,"is_unread":true}`) + if err == nil { + t.Fatal("expected error") + } + assertTriageFilterValidationError(t, err, "--filter") + if !strings.Contains(err.Error(), "only is_unread=true or is_read=false queries are supported") { + t.Fatalf("error %q does not explain supported unread filtering", err.Error()) + } + } +} + +func TestBuildTriageFilterMergesIndependentFlags(t *testing.T) { + rt := runtimeForMailTriageTest(t, map[string]string{ + "filter": "is_unread", + "folder": "INBOX", + "folder-id": "DRAFT", + }) + got, err := buildTriageFilter(rt) + if err != nil { + t.Fatalf("buildTriageFilter failed: %v", err) + } + if got.Folder != "INBOX" || got.FolderID != "DRAFT" || got.IsUnread == nil || !*got.IsUnread { + t.Fatalf("merged filter mismatch: %+v", got) + } +} + +func TestBuildTriageFilterRejectsIndependentFlagConflicts(t *testing.T) { + tests := []struct { + name string + values map[string]string + wantParam string + }{ + {name: "folder conflict", values: map[string]string{"filter": "folder=SENT", "folder": "INBOX"}, wantParam: "--folder"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildTriageFilter(runtimeForMailTriageTest(t, tt.values)) + if err == nil { + t.Fatal("expected conflict error") + } + assertTriageFilterValidationError(t, err, tt.wantParam) + if !strings.Contains(err.Error(), "conflict") { + t.Fatalf("expected conflict error, got %v", err) + } + }) + } +} + +func assertTriageFilterValidationError(t *testing.T, err error, wantParam string) { + t.Helper() + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if validationErr.Param != wantParam { + t.Fatalf("param = %q, want %s", validationErr.Param, wantParam) + } +} + func TestBuildSearchParamsDoesNotSetUserMailboxIDInBody(t *testing.T) { runtime := runtimeForMailTriageTest(t, map[string]string{"query": "hello"}) params, body, err := buildSearchParams(runtime, "", runtime.Str("query"), triageFilter{}, 15, "", true) @@ -309,6 +490,23 @@ func TestMailTriageDryRunListPathCapsPageSizeAtAPILimit(t *testing.T) { } } +func TestMailTriageDryRunRejectsReadStatusFilter(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": `{"is_read":true}`, + "folder": "INBOX", + }) + + dry := MailTriage.DryRun(context.Background(), runtime) + b, _ := json.Marshal(dry) + s := string(b) + if !strings.Contains(s, "filter_error") { + t.Fatalf("expected filter_error for read filtering, got %s", s) + } + if !strings.Contains(s, "only is_unread=true or is_read=false queries are supported") { + t.Fatalf("dry-run output %q does not explain supported unread filtering", s) + } +} + func TestBuildTriageMessagesFromSearchItems(t *testing.T) { raw := []interface{}{ map[string]interface{}{ @@ -1051,7 +1249,7 @@ func TestBuildSearchParamsAllFilterFields(t *testing.T) { BCC: []string{"bcc@d.com"}, Subject: "report", HasAttachment: boolPtr(true), - IsUnread: boolPtr(false), + IsUnread: boolPtr(true), } resolved, _ := resolveSearchFilter(rt, "me", f, true) _, body, err := buildSearchParams(rt, "me", "keyword", resolved, 10, "tok", true) @@ -1065,7 +1263,7 @@ func TestBuildSearchParamsAllFilterFields(t *testing.T) { if fb["has_attachment"] != true { t.Fatalf("has_attachment mismatch: %v", fb["has_attachment"]) } - if fb["is_unread"] != false { + if fb["is_unread"] != true { t.Fatalf("is_unread mismatch: %v", fb["is_unread"]) } if body["query"] != "keyword" { @@ -1706,6 +1904,39 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) { } } +func TestMailTriageNextPageHintPreservesIndependentFilterFlags(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + defer reg.Verify(t) + + registerMailTriageListStub(reg, "me", []string{"msg_001"}, true, "next_page_token") + registerMailTriageBatchStub(reg, "me", []map[string]interface{}{ + mailTriageBatchMessage("msg_001", "Table message"), + }) + + if err := runMountedMailShortcut(t, MailTriage, []string{ + "+triage", + "--max", "1", + "--filter", "is_unread", + "--folder-id", "DRAFT", + "--is-unread=true", + }, f, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + errOut := stderr.String() + for _, want := range []string{ + "next page: mail +triage", + "--filter 'is_unread'", + "--folder-id 'DRAFT'", + "--is-unread='true'", + "--page-token 'list:next_page_token'", + } { + if !strings.Contains(errOut, want) { + t.Fatalf("stderr should contain %q, got:\n%s", want, errOut) + } + } +} + // TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices. func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) { const notice = "The query is too long and has been truncated to the first 50 characters for search." diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 100b8f581a..e2a6bcda82 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -13,6 +13,8 @@ lark-cli mail +triage # 查看收件箱未读 lark-cli mail +triage --filter '{"folder":"inbox","is_unread":true}' +lark-cli mail +triage --folder INBOX --is-unread +lark-cli mail +triage --filter is_unread # 全文搜索 lark-cli mail +triage --query "合同审批" @@ -25,6 +27,8 @@ lark-cli mail +triage --query "项目评审" --filter '{"time_range":{"start_tim # 指定文件夹 lark-cli mail +triage --filter '{"folder":"sent"}' +lark-cli mail +triage --filter folder=sent +lark-cli mail +triage --folder sent # 系统标签(可通过 folder 或 label 传入,搜索时自动转为 folder) lark-cli mail +triage --filter '{"folder":"flagged"}' @@ -47,7 +51,10 @@ lark-cli mail +triage --page-size 10 | 参数 | 默认 | 说明 | |------|------|------| -| `--filter ` | — | 筛选条件(见下方字段说明) | +| `--filter ` | — | 筛选条件(见下方字段说明) | +| `--folder ` | — | 文件夹名称或系统文件夹 ID 筛选;等价于设置 `filter.folder` | +| `--folder-id ` | — | 明确的文件夹 ID 筛选;等价于设置 `filter.folder_id` | +| `--is-unread` | — | 只看未读;等价于设置 `filter.is_unread=true` | | `--query ` | — | 全文搜索关键词 | | `--format ` | `table` | `table` / `json` / `data`(`json` 和 `data` 均输出含分页信息的对象) | | `--max ` | `20` | 最大返回条数(1-400),内部自动分页拉取 | @@ -58,6 +65,14 @@ lark-cli mail +triage --page-size 10 ### `--filter` 支持的字段 +`--filter` 有三种写法: + +- JSON 对象:`--filter '{"folder":"INBOX","is_unread":true}'`,用于组合多个字段或传数组/对象字段 +- 单个 `key=value`:`--filter folder=INBOX`、`--filter is_unread=true` +- 裸未读快捷写法:`--filter is_unread` + +多个筛选条件请使用 JSON 对象,`folder=INBOX,is_unread=true` 这种逗号拼接的 key=value 不支持。 + | 字段 | 类型 | 说明 | |------|------|------| | `folder` | string | 文件夹名称筛选。系统文件夹固定值:`inbox`/`sent`/`draft`/`trash`/`spam`/`archive`/`priority`/`flagged`/`other`/`scheduled`,也支持自定义文件夹名称。子文件夹需用 `parent_name/child_name` 格式,可通过 folder list 接口查看 | @@ -73,7 +88,7 @@ lark-cli mail +triage --page-size 10 > **系统标签说明**:`IMPORTANT`/`FLAGGED`/`OTHER` 可通过 `folder` 或 `label` 传入(也支持中文别名 `重要邮件`/`已加旗标`/`其他邮件`、搜索名 `priority`/`flagged`/`other`)。搜索时自动转为 folder 字段,列表时自动转为 label_id。label list 接口不返回这三个系统标签。 > -> **⚠️ 注意**:查询未读请用 `"is_unread":true`。 +> **⚠️ 注意**:查询未读可用 `--is-unread`、`--filter is_unread`、`--filter is_unread=true` 或 JSON 写法 `"is_unread":true`。 可运行 `mail +triage --print-filter-schema` 查看完整字段说明。 ## 输出 @@ -108,7 +123,7 @@ lark-cli mail +triage --page-size 10 ### `table` 格式 -`page_token` 信息输出在 stderr,自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页: +`page_token` 信息输出在 stderr,自动携带 `--query`/`--filter`/`--folder`/`--folder-id`/`--is-unread`/`--mailbox` 参数方便续页: ```text 15 message(s) next page: mail +triage --query '合同审批' --page-token 'search:abc123...'