From c12eb080179a8b65a29e049f2f31d8f8a18d9ce4 Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:48:52 +0800 Subject: [PATCH 1/8] fix: normalize mail triage filters --- shortcuts/mail/mail_triage.go | 238 +++++++++++++++++++++++++++-- shortcuts/mail/mail_triage_test.go | 146 ++++++++++++++++++ 2 files changed, 372 insertions(+), 12 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index a4048ff15e..c5555d45bf 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "sort" "strings" @@ -59,7 +60,11 @@ 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, key=value, or read-status alias is_read/is_unread). Narrow results by folder, label, sender, recipient, 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 (merged with --filter)"}, + {Name: "folder-id", Desc: "explicit folder ID filter (merged with --filter; takes priority over --folder)"}, + {Name: "is-unread", Type: "bool", Desc: "filter unread messages (merged with --filter)"}, + {Name: "is-read", Type: "bool", Desc: "filter read messages (merged with --filter; alias for is_unread=false)"}, {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 } @@ -398,7 +403,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 by read status. is_read is accepted as a compatibility input alias and normalized to is_unread. On list path only is_unread=true is supported; is_unread=false uses the search path.", "example": "true", }, "time_range": map[string]string{ @@ -416,6 +421,9 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "examples": []string{ `{"folder":"INBOX"}`, + `folder=INBOX`, + `is_unread`, + `is_read`, `{"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,17 +433,220 @@ 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, value := range fields { + 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 := mergeTriageReadStatus(&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 := mergeTriageReadStatus(&filter, !boolValue, "is_read"); err != nil { + return triageFilter{}, err + } + case "time_range": + if err := json.Unmarshal(value, &filter.TimeRange); err != nil { + return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.time_range: %s", 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 field %q. Run --print-filter-schema to see supported fields", key) + } + } + return filter, 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{IsUnread: boolPtrValue(false)}, nil + 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) + } } - dec := json.NewDecoder(strings.NewReader(filterStr)) - dec.DisallowUnknownFields() - if err := dec.Decode(&filter); err != nil { - if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" { + + 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 + } + filter.IsUnread = &boolValue + case "is_read": + boolValue, err := parseTriageBoolKV(key, value) + if err != nil { + return triageFilter{}, err + } + filter.IsUnread = boolPtrValue(!boolValue) + 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 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 mergeTriageReadStatus(filter *triageFilter, isUnread bool, source string) error { + if filter.IsUnread != nil && *filter.IsUnread != isUnread { + sourceValue := isUnread + if source == "is_read" { + sourceValue = !isUnread + } + return mailValidationParamError("--filter", "conflicting read-status filter: %s=%v conflicts with is_unread=%v", source, sourceValue, *filter.IsUnread) + } + filter.IsUnread = &isUnread + return nil +} + +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 := mergeTriageReadStatus(&filter, value, "is_unread"); err != nil { + return triageFilter{}, err + } + } + if runtime.Changed("is-read") { + value := !runtime.Bool("is-read") + if err := mergeTriageReadStatus(&filter, value, "is_read"); err != nil { + return triageFilter{}, err + } } return filter, nil } @@ -455,7 +666,7 @@ func triageFilterUnknownFieldHint(msg string) string { "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, 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) @@ -471,6 +682,9 @@ func usesTriageSearchPath(query string, filter triageFilter) bool { if strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0 { return true } + if filter.IsUnread != nil && !*filter.IsUnread { + 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. diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 50f08fae16..c606410ec6 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -224,6 +224,120 @@ func TestParseTriageFilterUnknownFieldHintUnread(t *testing.T) { } } +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: true}, + {name: "json is_unread true means unread", raw: `{"is_unread":true}`, want: true}, + {name: "alias is_unread means unread", raw: `is_unread`, want: true}, + {name: "kv is_read false means unread", raw: `is_read=false`, want: true}, + {name: "json is_read true means read", raw: `{"is_read":true}`, want: false}, + {name: "json is_unread false means read", raw: `{"is_unread":false}`, want: false}, + {name: "alias is_read means read", raw: `is_read`, want: false}, + {name: "kv is_unread false means read", raw: `is_unread=false`, want: false}, + } + 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 || *got.IsUnread != tt.want { + t.Fatalf("is_unread = %v, want %v", got.IsUnread, tt.want) + } + }) + } +} + +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: "conflicting read status", raw: `{"is_read":true,"is_unread":true}`, wantSubstr: "conflicting read-status filter"}, + } + 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) + } + if !strings.Contains(err.Error(), tt.wantSubstr) { + t.Fatalf("error %q does not contain %q", err.Error(), tt.wantSubstr) + } + }) + } +} + +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 + }{ + {name: "folder conflict", values: map[string]string{"filter": "folder=SENT", "folder": "INBOX"}}, + {name: "read status conflict", values: map[string]string{"filter": "is_unread", "is-read": "true"}}, + } + 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") + } + if !strings.Contains(err.Error(), "conflict") { + t.Fatalf("expected conflict error, got %v", err) + } + }) + } +} + 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 +423,38 @@ func TestMailTriageDryRunListPathCapsPageSizeAtAPILimit(t *testing.T) { } } +func TestMailTriageDryRunNormalizesReadStatusToSearchFilter(t *testing.T) { + runtime := runtimeForMailTriageTest(t, map[string]string{ + "filter": "is_read", + "folder": "INBOX", + }) + + apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime)) + if len(apis) != 1 { + t.Fatalf("expected search-only dry-run api, got %d", len(apis)) + } + if apis[0].URL != mailboxPath("me", "search") || apis[0].Method != "POST" { + t.Fatalf("unexpected dry-run api: %+v", apis[0]) + } + body, ok := apis[0].Body.(map[string]interface{}) + if !ok { + t.Fatalf("body type = %T", apis[0].Body) + } + filterBody, ok := body["filter"].(map[string]interface{}) + if !ok { + t.Fatalf("filter body missing: %#v", body) + } + if got, ok := filterBody["is_unread"].(bool); !ok || got { + t.Fatalf("is_unread = %#v, want false", filterBody["is_unread"]) + } + if _, ok := filterBody["is_read"]; ok { + t.Fatalf("is_read should not be present in normalized filter: %#v", filterBody) + } + if got := firstString(filterBody["folder"]); got != "inbox" { + t.Fatalf("folder = %#v, want inbox", filterBody["folder"]) + } +} + func TestBuildTriageMessagesFromSearchItems(t *testing.T) { raw := []interface{}{ map[string]interface{}{ From 908c15933a144b477729a481b841b997e3d69c3a Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:47:36 +0800 Subject: [PATCH 2/8] fix: address mail triage review feedback Change-Type: ci-fix --- shortcuts/mail/mail_triage.go | 56 ++++++++++++++++++++- shortcuts/mail/mail_triage_test.go | 81 ++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index c5555d45bf..518f6ed137 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -333,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()) } @@ -456,7 +457,8 @@ func parseTriageFilterJSON(raw string) (triageFilter, error) { } var filter triageFilter - for key, value := range fields { + for _, key := range triageFilterJSONFieldOrder(fields) { + value := fields[key] switch key { case "folder": if err := json.Unmarshal(value, &filter.Folder); err != nil { @@ -541,6 +543,9 @@ func parseTriageFilterToken(raw string) (triageFilter, error) { 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) @@ -584,6 +589,40 @@ func parseTriageFilterToken(raw string) (triageFilter, error) { 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": @@ -651,6 +690,21 @@ func buildTriageFilter(runtime *common.RuntimeContext) (triageFilter, error) { 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")))) + } + if runtime.Changed("is-read") { + hint.WriteString(" --is-read=" + shellQuote(fmt.Sprintf("%t", runtime.Bool("is-read")))) + } +} + func triageFilterUnknownFieldHint(msg string) string { const unknownFieldPrefix = `json: unknown field "` if !strings.HasPrefix(msg, unknownFieldPrefix) || !strings.HasSuffix(msg, `"`) { diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index c606410ec6..fb70854ae4 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -287,7 +287,8 @@ func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { {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: "conflicting read status", raw: `{"is_read":true,"is_unread":true}`, wantSubstr: "conflicting read-status filter"}, + {name: "comma-separated kv", raw: "folder=INBOX,is_unread=true", wantSubstr: "comma-separated key=value filters are not supported"}, + {name: "conflicting read status", raw: `{"is_read":true,"is_unread":true}`, wantSubstr: "is_read=true conflicts with is_unread=true"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -295,6 +296,7 @@ func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { 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) } @@ -302,6 +304,19 @@ func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { } } +func TestParseTriageFilterConflictMessageIsDeterministic(t *testing.T) { + for i := 0; i < 20; i++ { + _, err := parseTriageFilter(`{"is_read":true,"is_unread":true}`) + if err == nil { + t.Fatal("expected conflict error") + } + assertTriageFilterValidationError(t, err, "--filter") + if got, want := err.Error(), "is_read=true conflicts with is_unread=true"; !strings.Contains(got, want) { + t.Fatalf("error = %q, want substring %q", got, want) + } + } +} + func TestBuildTriageFilterMergesIndependentFlags(t *testing.T) { rt := runtimeForMailTriageTest(t, map[string]string{ "filter": "is_unread", @@ -319,11 +334,12 @@ func TestBuildTriageFilterMergesIndependentFlags(t *testing.T) { func TestBuildTriageFilterRejectsIndependentFlagConflicts(t *testing.T) { tests := []struct { - name string - values map[string]string + name string + values map[string]string + wantParam string }{ - {name: "folder conflict", values: map[string]string{"filter": "folder=SENT", "folder": "INBOX"}}, - {name: "read status conflict", values: map[string]string{"filter": "is_unread", "is-read": "true"}}, + {name: "folder conflict", values: map[string]string{"filter": "folder=SENT", "folder": "INBOX"}, wantParam: "--folder"}, + {name: "read status conflict", values: map[string]string{"filter": "is_unread", "is-read": "true"}, wantParam: "--filter"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -331,6 +347,7 @@ func TestBuildTriageFilterRejectsIndependentFlagConflicts(t *testing.T) { 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) } @@ -338,6 +355,27 @@ func TestBuildTriageFilterRejectsIndependentFlagConflicts(t *testing.T) { } } +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) @@ -1852,6 +1890,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." From 77ef709ecaf831589064c4f6a78e8583029d334f Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Tue, 28 Jul 2026 17:10:36 +0800 Subject: [PATCH 3/8] fix: align mail triage read filters --- shortcuts/mail/mail_triage.go | 48 +++++++---------- shortcuts/mail/mail_triage_test.go | 87 +++++++++++++++--------------- 2 files changed, 64 insertions(+), 71 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 518f6ed137..b7b1d3408e 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -60,11 +60,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, key=value, or read-status alias is_read/is_unread). 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, key=value, or unread alias is_unread/is_read=false). Narrow results by folder, label, sender, recipient, 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 (merged with --filter)"}, {Name: "folder-id", Desc: "explicit folder ID filter (merged with --filter; takes priority over --folder)"}, {Name: "is-unread", Type: "bool", Desc: "filter unread messages (merged with --filter)"}, - {Name: "is-read", Type: "bool", Desc: "filter read messages (merged with --filter; alias for is_unread=false)"}, {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"}, @@ -404,7 +403,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "is_unread": map[string]string{ "type": "bool", - "desc": "Filter by read status. is_read is accepted as a compatibility input alias and normalized to is_unread. On list path only is_unread=true is supported; is_unread=false uses the search path.", + "desc": "Filter unread messages. is_read=false is accepted as a compatibility input alias and normalized to is_unread=true. is_unread=false and alias is_read are ignored. Read-message filtering with is_read=true is not supported.", "example": "true", }, "time_range": map[string]string{ @@ -424,7 +423,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { `{"folder":"INBOX"}`, `folder=INBOX`, `is_unread`, - `is_read`, + `{"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"}}`, @@ -507,7 +506,7 @@ func parseTriageFilterJSON(raw string) (triageFilter, error) { if err := json.Unmarshal(value, &boolValue); err != nil { return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.is_unread: %s", err) } - if err := mergeTriageReadStatus(&filter, boolValue, "is_unread"); err != nil { + if err := mergeTriageUnreadFilter(&filter, boolValue, "is_unread"); err != nil { return triageFilter{}, err } case "is_read": @@ -515,7 +514,7 @@ func parseTriageFilterJSON(raw string) (triageFilter, error) { if err := json.Unmarshal(value, &boolValue); err != nil { return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.is_read: %s", err) } - if err := mergeTriageReadStatus(&filter, !boolValue, "is_read"); err != nil { + if err := mergeTriageUnreadFilter(&filter, !boolValue, "is_read"); err != nil { return triageFilter{}, err } case "time_range": @@ -538,7 +537,7 @@ func parseTriageFilterToken(raw string) (triageFilter, error) { case "is_unread": return triageFilter{IsUnread: boolPtrValue(true)}, nil case "is_read": - return triageFilter{IsUnread: boolPtrValue(false)}, nil + return triageFilter{}, nil 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) } @@ -573,13 +572,17 @@ func parseTriageFilterToken(raw string) (triageFilter, error) { if err != nil { return triageFilter{}, err } - filter.IsUnread = &boolValue + 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 } - filter.IsUnread = boolPtrValue(!boolValue) + 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) @@ -644,13 +647,12 @@ func mergeTriageStringFilter(current *string, canonical, value string) error { return nil } -func mergeTriageReadStatus(filter *triageFilter, isUnread bool, source string) error { - if filter.IsUnread != nil && *filter.IsUnread != isUnread { - sourceValue := isUnread - if source == "is_read" { - sourceValue = !isUnread +func mergeTriageUnreadFilter(filter *triageFilter, isUnread bool, source string) error { + if !isUnread { + if source == "is_unread" { + return nil } - return mailValidationParamError("--filter", "conflicting read-status filter: %s=%v conflicts with is_unread=%v", source, sourceValue, *filter.IsUnread) + return mailValidationParamError("--filter", "read-message filtering is not supported by mail +triage; use is_unread=true or is_read=false to filter unread messages") } filter.IsUnread = &isUnread return nil @@ -677,13 +679,7 @@ func buildTriageFilter(runtime *common.RuntimeContext) (triageFilter, error) { } if runtime.Changed("is-unread") { value := runtime.Bool("is-unread") - if err := mergeTriageReadStatus(&filter, value, "is_unread"); err != nil { - return triageFilter{}, err - } - } - if runtime.Changed("is-read") { - value := !runtime.Bool("is-read") - if err := mergeTriageReadStatus(&filter, value, "is_read"); err != nil { + if err := mergeTriageUnreadFilter(&filter, value, "is_unread"); err != nil { return triageFilter{}, err } } @@ -700,9 +696,6 @@ func appendTriagePaginationFilterFlags(hint *strings.Builder, runtime *common.Ru if runtime.Changed("is-unread") { hint.WriteString(" --is-unread=" + shellQuote(fmt.Sprintf("%t", runtime.Bool("is-unread")))) } - if runtime.Changed("is-read") { - hint.WriteString(" --is-read=" + shellQuote(fmt.Sprintf("%t", runtime.Bool("is-read")))) - } } func triageFilterUnknownFieldHint(msg string) string { @@ -720,7 +713,7 @@ func triageFilterUnknownFieldHint(msg string) string { "after": "time_range.start_time", "before": "time_range.end_time", } - const validFields = "folder, folder_id, label, label_id, is_unread, is_read, from, to, cc, bcc, subject, has_attachment, time_range. Run --print-filter-schema to see supported fields" + 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) @@ -736,9 +729,6 @@ func usesTriageSearchPath(query string, filter triageFilter) bool { if strings.TrimSpace(query) != "" || len(triageQueryFilterFields(filter)) > 0 { return true } - if filter.IsUnread != nil && !*filter.IsUnread { - 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. diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index fb70854ae4..152adbbfaa 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -228,16 +228,15 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { tests := []struct { name string raw string - want bool + want *bool }{ - {name: "json is_read false means unread", raw: `{"is_read":false}`, want: true}, - {name: "json is_unread true means unread", raw: `{"is_unread":true}`, want: true}, - {name: "alias is_unread means unread", raw: `is_unread`, want: true}, - {name: "kv is_read false means unread", raw: `is_read=false`, want: true}, - {name: "json is_read true means read", raw: `{"is_read":true}`, want: false}, - {name: "json is_unread false means read", raw: `{"is_unread":false}`, want: false}, - {name: "alias is_read means read", raw: `is_read`, want: false}, - {name: "kv is_unread false means read", raw: `is_unread=false`, want: false}, + {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)}, + {name: "json is_unread false is ignored", raw: `{"is_unread":false}`, want: nil}, + {name: "kv is_unread false is ignored", raw: `is_unread=false`, want: nil}, + {name: "alias is_read is ignored", raw: `is_read`, want: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -245,9 +244,30 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { if err != nil { t.Fatalf("parseTriageFilter(%q) error = %v", tt.raw, err) } - if got.IsUnread == nil || *got.IsUnread != tt.want { + 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}`, + } + 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(), "read-message filtering is not supported") { + t.Fatalf("error %q does not explain unsupported read filtering", err.Error()) + } }) } } @@ -288,7 +308,6 @@ func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { {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"}, - {name: "conflicting read status", raw: `{"is_read":true,"is_unread":true}`, wantSubstr: "is_read=true conflicts with is_unread=true"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -304,15 +323,15 @@ func TestParseTriageFilterRejectsInvalidShorthands(t *testing.T) { } } -func TestParseTriageFilterConflictMessageIsDeterministic(t *testing.T) { +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 conflict error") + t.Fatal("expected error") } assertTriageFilterValidationError(t, err, "--filter") - if got, want := err.Error(), "is_read=true conflicts with is_unread=true"; !strings.Contains(got, want) { - t.Fatalf("error = %q, want substring %q", got, want) + if !strings.Contains(err.Error(), "read-message filtering is not supported") { + t.Fatalf("error %q does not explain unsupported read filtering", err.Error()) } } } @@ -339,7 +358,6 @@ func TestBuildTriageFilterRejectsIndependentFlagConflicts(t *testing.T) { wantParam string }{ {name: "folder conflict", values: map[string]string{"filter": "folder=SENT", "folder": "INBOX"}, wantParam: "--folder"}, - {name: "read status conflict", values: map[string]string{"filter": "is_unread", "is-read": "true"}, wantParam: "--filter"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -461,35 +479,20 @@ func TestMailTriageDryRunListPathCapsPageSizeAtAPILimit(t *testing.T) { } } -func TestMailTriageDryRunNormalizesReadStatusToSearchFilter(t *testing.T) { +func TestMailTriageDryRunRejectsReadStatusFilter(t *testing.T) { runtime := runtimeForMailTriageTest(t, map[string]string{ - "filter": "is_read", + "filter": `{"is_read":true}`, "folder": "INBOX", }) - apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime)) - if len(apis) != 1 { - t.Fatalf("expected search-only dry-run api, got %d", len(apis)) - } - if apis[0].URL != mailboxPath("me", "search") || apis[0].Method != "POST" { - t.Fatalf("unexpected dry-run api: %+v", apis[0]) - } - body, ok := apis[0].Body.(map[string]interface{}) - if !ok { - t.Fatalf("body type = %T", apis[0].Body) - } - filterBody, ok := body["filter"].(map[string]interface{}) - if !ok { - t.Fatalf("filter body missing: %#v", body) - } - if got, ok := filterBody["is_unread"].(bool); !ok || got { - t.Fatalf("is_unread = %#v, want false", filterBody["is_unread"]) - } - if _, ok := filterBody["is_read"]; ok { - t.Fatalf("is_read should not be present in normalized filter: %#v", filterBody) + 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 got := firstString(filterBody["folder"]); got != "inbox" { - t.Fatalf("folder = %#v, want inbox", filterBody["folder"]) + if !strings.Contains(s, "read-message filtering is not supported") { + t.Fatalf("dry-run output %q does not explain unsupported read filtering", s) } } @@ -1235,7 +1238,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) @@ -1249,7 +1252,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" { From 6412493923806d07a013e5d84e4c1b9ca1f613ec Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Wed, 29 Jul 2026 11:17:14 +0800 Subject: [PATCH 4/8] fix: reject bare mail triage is_read filter --- shortcuts/mail/mail_triage.go | 36 +++++++++++++++++++++++++----- shortcuts/mail/mail_triage_test.go | 25 +++++++++++++++------ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index b7b1d3408e..bed6779082 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -4,6 +4,7 @@ package mail import ( + "bytes" "context" "encoding/json" "fmt" @@ -403,7 +404,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "is_unread": map[string]string{ "type": "bool", - "desc": "Filter unread messages. is_read=false is accepted as a compatibility input alias and normalized to is_unread=true. is_unread=false and alias is_read are ignored. Read-message filtering with is_read=true is not supported.", + "desc": "Filter unread messages. Use is_unread=true or is_read=false. is_unread=false is ignored. Bare is_read and is_read=true are rejected.", "example": "true", }, "time_range": map[string]string{ @@ -518,9 +519,11 @@ func parseTriageFilterJSON(raw string) (triageFilter, error) { return triageFilter{}, err } case "time_range": - if err := json.Unmarshal(value, &filter.TimeRange); err != nil { - return triageFilter{}, mailValidationParamError("--filter", "invalid --filter.time_range: %s", err) + 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) @@ -531,13 +534,30 @@ func parseTriageFilterJSON(raw string) (triageFilter, error) { 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{}, nil + 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) } @@ -652,12 +672,16 @@ func mergeTriageUnreadFilter(filter *triageFilter, isUnread bool, source string) if source == "is_unread" { return nil } - return mailValidationParamError("--filter", "read-message filtering is not supported by mail +triage; use is_unread=true or is_read=false to filter unread messages") + 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 } @@ -710,6 +734,8 @@ 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", } diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index 152adbbfaa..d39ba4eb99 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -224,6 +224,17 @@ 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 @@ -236,7 +247,6 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { {name: "kv is_read false means unread", raw: `is_read=false`, want: boolPtr(true)}, {name: "json is_unread false is ignored", raw: `{"is_unread":false}`, want: nil}, {name: "kv is_unread false is ignored", raw: `is_unread=false`, want: nil}, - {name: "alias is_read is ignored", raw: `is_read`, want: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -257,6 +267,7 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { func TestParseTriageFilterRejectsReadStatusInputs(t *testing.T) { tests := []string{ `{"is_read":true}`, + `is_read`, } for _, raw := range tests { t.Run(raw, func(t *testing.T) { @@ -265,8 +276,8 @@ func TestParseTriageFilterRejectsReadStatusInputs(t *testing.T) { t.Fatalf("expected error for %q", raw) } assertTriageFilterValidationError(t, err, "--filter") - if !strings.Contains(err.Error(), "read-message filtering is not supported") { - t.Fatalf("error %q does not explain unsupported read filtering", err.Error()) + 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()) } }) } @@ -330,8 +341,8 @@ func TestParseTriageFilterReadStatusErrorIsDeterministic(t *testing.T) { t.Fatal("expected error") } assertTriageFilterValidationError(t, err, "--filter") - if !strings.Contains(err.Error(), "read-message filtering is not supported") { - t.Fatalf("error %q does not explain unsupported read filtering", err.Error()) + 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()) } } } @@ -491,8 +502,8 @@ func TestMailTriageDryRunRejectsReadStatusFilter(t *testing.T) { if !strings.Contains(s, "filter_error") { t.Fatalf("expected filter_error for read filtering, got %s", s) } - if !strings.Contains(s, "read-message filtering is not supported") { - t.Fatalf("dry-run output %q does not explain unsupported read filtering", 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) } } From d000927aac1d709a68209900d3a629d26db80cac Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Wed, 29 Jul 2026 15:45:52 +0800 Subject: [PATCH 5/8] fix: clarify mail triage unread filter help --- shortcuts/mail/mail_triage.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index bed6779082..29bd27af32 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -61,10 +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, key=value, or unread alias is_unread/is_read=false). Narrow results by folder, label, sender, recipient, 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 (merged with --filter)"}, - {Name: "folder-id", Desc: "explicit folder ID filter (merged with --filter; takes priority over --folder)"}, - {Name: "is-unread", Type: "bool", Desc: "filter unread messages (merged with --filter)"}, + {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"}, @@ -404,7 +404,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) { }, "is_unread": map[string]string{ "type": "bool", - "desc": "Filter unread messages. Use is_unread=true or is_read=false. is_unread=false is ignored. Bare is_read and is_read=true are rejected.", + "desc": "Filter unread messages. Use is_unread=true.", "example": "true", }, "time_range": map[string]string{ From b11987690e5ddf1d0187362aa3c41c505bd0571e Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Fri, 31 Jul 2026 10:47:41 +0800 Subject: [PATCH 6/8] docs: align mail triage skill filters Document the new mail triage filter forms and standalone folder/unread flags so local skill guidance matches CLI help and schema output. Co-authored-by: TRAE CLI --- .../lark-mail/references/lark-mail-triage.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 100b8f581a..123bb46170 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 ` | — | 筛选条件,支持 JSON 对象、单个 `key=value`、裸 `is_unread`(见下方字段说明) | +| `--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...' From c641c10be6f94f20942f9f8e114225d8edb72ab5 Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Fri, 31 Jul 2026 14:10:48 +0800 Subject: [PATCH 7/8] docs: simplify mail triage filter description Co-authored-by: TRAE CLI --- skills/lark-mail/references/lark-mail-triage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-mail/references/lark-mail-triage.md b/skills/lark-mail/references/lark-mail-triage.md index 123bb46170..e2a6bcda82 100644 --- a/skills/lark-mail/references/lark-mail-triage.md +++ b/skills/lark-mail/references/lark-mail-triage.md @@ -51,7 +51,7 @@ lark-cli mail +triage --page-size 10 | 参数 | 默认 | 说明 | |------|------|------| -| `--filter ` | — | 筛选条件,支持 JSON 对象、单个 `key=value`、裸 `is_unread`(见下方字段说明) | +| `--filter ` | — | 筛选条件(见下方字段说明) | | `--folder ` | — | 文件夹名称或系统文件夹 ID 筛选;等价于设置 `filter.folder` | | `--folder-id ` | — | 明确的文件夹 ID 筛选;等价于设置 `filter.folder_id` | | `--is-unread` | — | 只看未读;等价于设置 `filter.is_unread=true` | From db9dcba732e0f14a6d1eeaa0f0bde9022ed610f1 Mon Sep 17 00:00:00 2001 From: "yangrong.happy" Date: Fri, 31 Jul 2026 15:13:34 +0800 Subject: [PATCH 8/8] fix: reject false unread triage filters Reject is_unread=false instead of silently treating it as no read-status filter, and cover the JSON and key=value forms in read-status validation tests. Co-authored-by: TRAE CLI --- shortcuts/mail/mail_triage.go | 3 --- shortcuts/mail/mail_triage_test.go | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/shortcuts/mail/mail_triage.go b/shortcuts/mail/mail_triage.go index 29bd27af32..eb8e0815fb 100644 --- a/shortcuts/mail/mail_triage.go +++ b/shortcuts/mail/mail_triage.go @@ -669,9 +669,6 @@ func mergeTriageStringFilter(current *string, canonical, value string) error { func mergeTriageUnreadFilter(filter *triageFilter, isUnread bool, source string) error { if !isUnread { - if source == "is_unread" { - return nil - } return unsupportedTriageReadFilterError() } filter.IsUnread = &isUnread diff --git a/shortcuts/mail/mail_triage_test.go b/shortcuts/mail/mail_triage_test.go index d39ba4eb99..3c189f1cb0 100644 --- a/shortcuts/mail/mail_triage_test.go +++ b/shortcuts/mail/mail_triage_test.go @@ -245,8 +245,6 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { {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)}, - {name: "json is_unread false is ignored", raw: `{"is_unread":false}`, want: nil}, - {name: "kv is_unread false is ignored", raw: `is_unread=false`, want: nil}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -267,7 +265,9 @@ func TestParseTriageFilterNormalizesReadStatusInputs(t *testing.T) { 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) {