Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 78 additions & 10 deletions shortcuts/mail/mail_triage.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ 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 shorthand). Examples: {"folder":"INBOX","from":["alice@example.com"]}, folder_id=DRAFT, is_unread, is_read. Run --print-filter-schema to see all fields.`},
{Name: "folder", Desc: `folder name or ID shortcut merged into --filter. Example: --folder=INBOX --filter=is_unread. Cannot be combined with --filter folder/folder_id.`},
{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"},
Expand All @@ -74,8 +75,8 @@ var MailTriage = common.Shortcut{
showLabels := runtime.Bool("labels")
maxCount := resolveTriagePageSize(runtime)
parsed, parseErr := parseTriagePageToken(runtime.Str("page-token"))
filter, err := parseTriageFilter(runtime.Str("filter"))
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter"))
filter, err := triageFilterFromRuntime(runtime)
d := common.NewDryRunAPI().Set("input_filter", runtime.Str("filter")).Set("input_folder", runtime.Str("folder"))
if parseErr != nil {
return d.Set("filter_error", parseErr.Error())
}
Expand Down Expand Up @@ -146,7 +147,7 @@ var MailTriage = common.Shortcut{
}
}
showLabels := runtime.Bool("labels")
filter, err := parseTriageFilter(runtime.Str("filter"))
filter, err := triageFilterFromRuntime(runtime)
if err != nil {
return err
}
Expand Down Expand Up @@ -328,6 +329,9 @@ var MailTriage = common.Shortcut{
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
if folder := runtime.Str("folder"); folder != "" {
hint.WriteString(" --folder " + shellQuote(folder))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
Expand All @@ -344,7 +348,7 @@ var MailTriage = common.Shortcut{

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.",
"_description": "--filter field reference for mail +triage. All fields are optional. --filter narrows results by exact conditions; --folder is a shortcut for folder filtering; --query does full-text search.",
"fields": map[string]interface{}{
"folder": map[string]string{
"type": "string",
Expand Down Expand Up @@ -398,7 +402,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_unread=true may use the list path; is_unread=false always uses the search path.",
"example": "true",
},
"time_range": map[string]string{
Expand All @@ -409,12 +413,17 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) {
},
"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.",
"from, to, cc, bcc, subject, has_attachment, time_range, and is_unread=false trigger the search path.",
"--filter also accepts shorthand: folder_id=DRAFT, folder=INBOX, is_unread/unread, is_read/read.",
"--folder=INBOX can be combined with --filter=is_unread, but conflicts with --filter folder/folder_id.",
"--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_id=DRAFT`,
`is_unread`,
`is_read`,
`{"folder":"INBOX"}`,
`{"folder":"INBOX","from":["alice@example.com"]}`,
`{"label":"FLAGGED","is_unread":true}`,
Expand All @@ -426,20 +435,76 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) {

func parseTriageFilter(filterStr string) (triageFilter, error) {
var filter triageFilter
if strings.TrimSpace(filterStr) == "" {
raw := strings.TrimSpace(filterStr)
if raw == "" {
return filter, nil
}
dec := json.NewDecoder(strings.NewReader(filterStr))
dec := json.NewDecoder(strings.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&filter); err != nil {
if hint := triageFilterUnknownFieldHint(err.Error()); hint != "" {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", hint)
}
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: %s", err)
shorthand, shorthandErr := parseTriageFilterShorthand(raw)
if shorthandErr == nil {
return shorthand, nil
}
return triageFilter{}, shorthandErr
}
return filter, nil
Comment on lines +442 to 454

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '436,506p' shortcuts/mail/mail_triage.go
rg -n -C2 'trailing|exactly one JSON|null' shortcuts/mail/mail_triage_test.go || true

Repository: larksuite/cli

Length of output: 2547


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## parseTriageFilter and filter struct definitions/usages'
ast-grep outline shortcuts/mail/mail_triage.go --match parseTriageFilter --view expanded || true
rg -n --glob '*.go' 'type triageFilter|func \*triageFilter|triageFilter\{' shortcuts/mail || true

echo '## focused tests around parseTriageFilter'
rg -n --glob '*test.go' 'parseTriageFilter|Filter|folder|unread|unknown|trailing|null' shortcuts || true

echo '## Go json.Decoder behavior probe via local source reflection (read-only)'
if command -v go >/dev/null 2>&1; then
  tmp="$(mktemp -d)"
  cat > "$tmp/main.go" <<'GO'
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

type T struct{ Folder string `json:"folder"` }

func main() {
	for _, raw := range []string{`{"folder":"INBOX"} {"is_unread":true}`, `null`, `{}`, `{"folder":"INBOX"}`} {
		var t T
		c, _ := json.NewDecoder(strings.NewReader(raw)).Decode(&t)
		fmt.Printf("raw=%q decode=%v result=%+v\n", raw, c, t)
	}
}
GO
  (cd "$tmp" && go run main.go)
else
  echo 'go not available'
fi

Repository: larksuite/cli

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## triageFilter struct and parser call sites'
sed -n '1,60p' shortcuts/mail/mail_triage.go
sed -n '425,456p' shortcuts/mail/mail_triage.go

echo '## parseTriageFilter tests around shorthand/table inputs'
sed -n '217,250p' shortcuts/mail/mail_triage_test.go
sed -n '267,285p' shortcuts/mail/mail_triage_test.go

echo '## behavioral Go probe if available'
if command -v go >/dev/null 2>&1; then
  tmp="$(mktemp -d)"
  cat > "$tmp/main.go" <<'GO'
package main
import (
	"bytes"
	"encoding/json"
	"fmt"
	"strings"
)
type T struct { Folder string `json:"folder"` }
func main() {
	s := map[string]string{
		"concatenated":     `{"folder":"INBOX"} {"is_unread":true}`,
		"concatenated_nl":  `{"folder":"INBOX"}\n {"is_unread":true}`,
		"after_json_object": `{}`,
		"null":              `null`,
		"array":             `["INBOX"]`,
		"plain_object":      `{"folder":"INBOX"}`,
		"plain_object_eof":  `{"folder":"INBOX"}`,
	}
	for name, raw := range s {
		data := []byte(raw)
		raw = strings.ReplaceAll(raw, `\n`, "\n")
		var t T
		dec := json.NewDecoder(bytes.NewReader(data))
		err := dec.Decode(&t)
		var tail interface{}
		err2 := dec.Decode(&tail)
		tok, ok := dec.Token()
		fmt.Printf("--- %s ---\n", name)
		fmt.Printf("raw=%q\n", raw)
		fmt.Printf("decode err=%v\n", err)
		if err == nil {
			fmt.Printf("result=%+v\n", t)
			fmt.Printf("second decode err=%v\n", err2)
		}
		if ok {
			fmt.Printf("next token=%T %#v\n", tok, tok)
		} else {
			fmt.Printf("!NextToken ok=false\n")
		}
	}
}
GO
  (cd "$tmp" && go run main.go)
else
  echo 'go not available'
fi

Repository: larksuite/cli

Length of output: 5803


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '## fixed Go decoder behavior probe'
tmp="$(mktemp -d)"
cat > "$tmp/main.go" <<'GO'
package main
import (
	"bytes"
	"encoding/json"
	"fmt"
	"strings"
)
type T struct { Folder string `json:"folder"` }
func main() {
	tests := map[string]string{
		"concatenated":    `{"folder":"INBOX"} {"is_unread":true}`,
		"concatenated_nl": "{\"folder\":\"INBOX\"}\n {\"is_unread\":true}",
		"null":             `null`,
		"array":            `["INBOX"]`,
		"plain_object":     `{"folder":"INBOX"}`,
		"empty_object":     `{}`,
	}
	for name, raw := range tests {
		var t T
		dec := json.NewDecoder(bytes.NewReader([]byte(raw)))
		err := dec.Decode(&t)
		var tail interface{}
		err2 := dec.Decode(&tail)
		fmt.Printf("--- %s --- raw=%q\n", name, raw)
		fmt.Printf("decode err=%v result=%+v\n", err, t)
		fmt.Printf("second decode err=%v tail=%v\n", err2, tail)
		tok, ok := dec.Token()
		if ok {
			fmt.Printf("next token=%T %#v\n", tok, tok)
		} else {
			fmt.Printf("next token ok=false\n")
		}
	}
}
GO
(cd "$tmp" && go run main.go)

Repository: larksuite/cli

Length of output: 312


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp -d)"
cat > "$tmp/main.go" <<'GO'
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
)

type T struct{ Folder string `json:"folder"` }

func main() {
	tests := map[string]string{
		"concatenated":    `{"folder":"INBOX"} {"is_unread":true}`,
		"concatenated_nl": "{\"folder\":\"INBOX\"}\n {\"is_unread\":true}",
		"null":             `null`,
		"array":            `["INBOX"]`,
		"plain_object":     `{"folder":"INBOX"}`,
		"empty_object":     `{}`,
	}
	for name, raw := range tests {
		var t T
		dec := json.NewDecoder(bytes.NewReader([]byte(raw)))
		err := dec.Decode(&t)
		var tail interface{}
		err2 := dec.Decode(&tail)
		tok, ok := dec.Token()
		next := "<EOF>"
		if ok {
			next = fmt.Sprintf("%T %#v", tok, tok)
		}
		fmt.Printf("--- %s --- raw=%q\n", name, raw)
		fmt.Printf("decode err=%v result=%+v\n", err, t)
		fmt.Printf("second decode err=%v tail=%v\n", err2, tail)
		fmt.Printf("next token=%s\n", next)
	}
}
GO
(cd "$tmp" && go run main.go)

Repository: larksuite/cli

Length of output: 230


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmp="$(mktemp -d)"
cat > "$tmp/main.go" <<'GO'
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
)

type T struct{ Folder string `json:"folder"` }

func main() {
	tests := map[string]string{
		"concatenated":    `{"folder":"INBOX"} {"is_unread":true}`,
		"concatenated_nl": "{\"folder\":\"INBOX\"}\n {\"is_unread\":true}",
		"null":             `null`,
		"array":            `["INBOX"]`,
		"plain_object":     `{"folder":"INBOX"}`,
		"empty_object":     `{}`,
	}
	for name, raw := range tests {
		var t T
		dec := json.NewDecoder(bytes.NewReader([]byte(raw)))
		err := dec.Decode(&t)
		var tail interface{}
		err2 := dec.Decode(&tail)
		var next string
		tok, ok := dec.Token()
		if ok {
			next = fmt.Sprintf("%T %#v", tok, tok)
		} else {
			next = "<EOF>"
		}
		fmt.Printf("--- %s --- raw=%q\n", name, raw)
		fmt.Printf("decode err=%v result=%+v\n", err, t)
		fmt.Printf("second decode err=%v tail=%v\n", err2, tail)
		fmt.Printf("next token=%s\n", next)
	}
}
GO
(cd "$tmp" && go run main.go)

Repository: larksuite/cli

Length of output: 230


🌐 Web query:

Go encoding/json Decoder Decode reads one JSON value and trailing characters invalid input

💡 Result:

In Go, the encoding/json Decoder.Decode method is designed to read and parse a single JSON value from an input stream [1][2]. By design, it consumes only the bytes necessary to form that valid JSON value [1]. If the input stream contains additional data after the valid JSON value, the Decoder does not treat this as an error; the trailing characters remain in the underlying buffer or the io.Reader, effectively left for subsequent operations [3][4]. This behavior can often lead to confusion when developers assume Decode will validate that a stream contains exactly one JSON value [3]. Because the decoder may buffer more data than is required for the current value, those "leftover" bytes—including trailing whitespace or subsequent JSON objects—are not automatically discarded or validated [4]. If you need to ensure that no trailing data exists or to process multiple JSON values, you can use one of the following patterns: 1. Check for EOF: After calling Decode, verify that no meaningful data remains by calling the Decoder.Token method. If there is no more data, Token will return io.EOF [3][2]. Example: d:= json.NewDecoder(r) if err:= d.Decode(&v); err!= nil { return err } if _, err:= d.Token; err!= io.EOF { return fmt.Errorf("unexpected trailing data") } 2. Inspect the Buffer: You can use the Decoder.Buffered method to inspect any data that has been read from the io.Reader but not yet consumed by a Decode or Token call [2][4]. Note that this only returns the data currently held in the internal buffer, not the entire remainder of the io.Reader [4][5]. This design allows the Decoder to support streaming multiple JSON objects from a single source, which is a primary use case for the package [3]. If the input contains invalid JSON syntax after the first object, the decoder will only report an error if it attempts to parse that specific section as part of a subsequent value [1][6].

Citations:


Reject trailing and non-object JSON filters.

json.Decoder.Decode only reads one JSON value, so --filter '{"folder":"INBOX"} {"is_unread":true}' applies only the first object, and --filter null decodes as an empty triageFilter. Require a single JSON object and EOF after decoding, returning a typed --filter validation error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_triage.go` around lines 442 - 454, The JSON parsing flow
around the decoder in the triage filter parser must require exactly one non-null
JSON object. Validate that decoding produces an object rather than null or
another JSON type, then perform a second decode to confirm EOF and reject
trailing values; return a typed --filter validation error for either condition
while preserving existing unknown-field hints and shorthand fallback for initial
JSON decode failures.

Source: Coding guidelines

}

func triageFilterFromRuntime(runtime *common.RuntimeContext) (triageFilter, error) {
filter, err := parseTriageFilter(runtime.Str("filter"))
if err != nil {
return triageFilter{}, err
}
return mergeTriageFolderFlag(filter, runtime.Str("folder"))
}

func mergeTriageFolderFlag(filter triageFilter, folder string) (triageFilter, error) {
folder = strings.TrimSpace(folder)
if folder == "" {
return filter, nil
}
if strings.TrimSpace(filter.Folder) != "" || strings.TrimSpace(filter.FolderID) != "" {
return triageFilter{}, mailValidationParamError("--folder", "invalid --folder: cannot combine with --filter folder/folder_id")
}
filter.Folder = folder
return filter, nil
}

func parseTriageFilterShorthand(raw string) (triageFilter, error) {
value := strings.TrimSpace(raw)
switch strings.ToLower(value) {
case "is_unread", "unread":
return triageFilter{IsUnread: triageBoolPtr(true)}, nil
case "is_read", "read":
return triageFilter{IsUnread: triageBoolPtr(false)}, nil
}
if strings.Contains(value, "=") {
key, val, _ := strings.Cut(value, "=")
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if val == "" {
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: shorthand field %q requires a non-empty value", key)
}
switch key {
case "folder":
return triageFilter{Folder: val}, nil
case "folder_id":
return triageFilter{FolderID: val}, nil
default:
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unsupported shorthand field %q; use JSON for complex filters", key)
}
}
return triageFilter{}, mailValidationParamError("--filter", "invalid --filter: unsupported shorthand %q", value)
}

func triageBoolPtr(v bool) *bool {
return &v
}

func triageFilterUnknownFieldHint(msg string) string {
const unknownFieldPrefix = `json: unknown field "`
if !strings.HasPrefix(msg, unknownFieldPrefix) || !strings.HasSuffix(msg, `"`) {
Expand Down Expand Up @@ -471,6 +536,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.
Expand Down
155 changes: 155 additions & 0 deletions shortcuts/mail/mail_triage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,100 @@ func TestParseTriageFilterUnknownFieldHintUnread(t *testing.T) {
}
}

func TestParseTriageFilterShorthand(t *testing.T) {
tests := []struct {
name string
input string
wantFolder string
wantFolderID string
wantUnread *bool
}{
{name: "folder id", input: "folder_id=DRAFT", wantFolderID: "DRAFT"},
{name: "folder", input: "folder=INBOX", wantFolder: "INBOX"},
{name: "is unread", input: "is_unread", wantUnread: boolPtr(true)},
{name: "unread alias", input: "unread", wantUnread: boolPtr(true)},
{name: "is read", input: "is_read", wantUnread: boolPtr(false)},
{name: "read alias", input: "read", wantUnread: boolPtr(false)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTriageFilter(tt.input)
if err != nil {
t.Fatalf("parseTriageFilter(%q): %v", tt.input, err)
}
if got.Folder != tt.wantFolder {
t.Fatalf("Folder = %q, want %q", got.Folder, tt.wantFolder)
}
if got.FolderID != tt.wantFolderID {
t.Fatalf("FolderID = %q, want %q", got.FolderID, tt.wantFolderID)
}
if tt.wantUnread == nil {
if got.IsUnread != nil {
t.Fatalf("IsUnread = %v, want nil", *got.IsUnread)
}
return
}
if got.IsUnread == nil || *got.IsUnread != *tt.wantUnread {
t.Fatalf("IsUnread = %v, want %v", got.IsUnread, *tt.wantUnread)
}
})
}
}

func TestParseTriageFilterShorthandRejectsInvalidInput(t *testing.T) {
tests := []struct {
input string
want string
}{
{input: "folder_id=", want: "requires a non-empty value"},
{input: "label=important", want: "unsupported shorthand field"},
{input: "unknown", want: "unsupported shorthand"},
}
for _, tt := range tests {
_, err := parseTriageFilter(tt.input)
if err == nil {
t.Fatalf("parseTriageFilter(%q) expected error", tt.input)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("parseTriageFilter(%q) error = %v, want substring %q", tt.input, err, tt.want)
}
}
}

func TestTriageFilterFromRuntimeMergesFolderFlag(t *testing.T) {
runtime := runtimeForMailTriageTest(t, map[string]string{
"folder": "INBOX",
"filter": "is_unread",
})
got, err := triageFilterFromRuntime(runtime)
if err != nil {
t.Fatalf("triageFilterFromRuntime: %v", err)
}
if got.Folder != "INBOX" {
t.Fatalf("Folder = %q, want INBOX", got.Folder)
}
if got.IsUnread == nil || !*got.IsUnread {
t.Fatalf("IsUnread = %v, want true", got.IsUnread)
}
}

func TestTriageFilterFromRuntimeRejectsFolderConflict(t *testing.T) {
tests := []string{"folder_id=DRAFT", `{"folder":"SENT"}`}
for _, filter := range tests {
runtime := runtimeForMailTriageTest(t, map[string]string{
"folder": "INBOX",
"filter": filter,
})
_, err := triageFilterFromRuntime(runtime)
if err == nil {
t.Fatalf("filter %q expected conflict", filter)
}
if !strings.Contains(err.Error(), "cannot combine with --filter folder/folder_id") {
t.Fatalf("unexpected error for %q: %v", filter, 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)
Expand Down Expand Up @@ -290,6 +384,52 @@ func TestMailTriageDryRunListPathUsesMessagesAndBatchGet(t *testing.T) {
}
}

func TestMailTriageDryRunFolderFlagAndUnreadShorthandUsesListPath(t *testing.T) {
runtime := runtimeForMailTriageTest(t, map[string]string{
"folder": "INBOX",
"filter": "is_unread",
})

apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
if len(apis) < 1 {
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
}
if apis[0].Method != "GET" || apis[0].URL != mailboxPath("me", "messages") {
t.Fatalf("expected list path, got %s %s", apis[0].Method, apis[0].URL)
}
if apis[0].Params["folder_id"] != "INBOX" {
t.Fatalf("folder_id = %v, want INBOX", apis[0].Params["folder_id"])
}
if apis[0].Params["only_unread"] != true {
t.Fatalf("only_unread = %v, want true", apis[0].Params["only_unread"])
}
}

func TestMailTriageDryRunReadShorthandUsesSearchPath(t *testing.T) {
runtime := runtimeForMailTriageTest(t, map[string]string{
"filter": "is_read",
})

apis := dryRunAPIsForMailTriageTest(t, MailTriage.DryRun(context.Background(), runtime))
if len(apis) < 1 {
t.Fatalf("expected at least 1 dry-run api, got %d", len(apis))
}
if apis[0].Method != "POST" || apis[0].URL != mailboxPath("me", "search") {
t.Fatalf("expected search path, got %s %s", apis[0].Method, apis[0].URL)
}
body, ok := apis[0].Body.(map[string]interface{})
if !ok {
t.Fatalf("body = %#v, want object", apis[0].Body)
}
filterBody, ok := body["filter"].(map[string]interface{})
if !ok {
t.Fatalf("filter body = %#v, want object", body["filter"])
}
if filterBody["is_unread"] != false {
t.Fatalf("is_unread = %v, want false", filterBody["is_unread"])
}
}

func TestMailTriageDryRunListPathCapsPageSizeAtAPILimit(t *testing.T) {
runtime := runtimeForMailTriageTest(t, map[string]string{
"max": "50",
Expand Down Expand Up @@ -1152,6 +1292,21 @@ func TestResolveTriagePathListTokenConflictsWithSearchFilter(t *testing.T) {
}
}

func TestResolveTriagePathReadStatusFalseUsesSearch(t *testing.T) {
useSearch, err := resolveTriagePath(triagePageToken{}, "", triageFilter{IsUnread: boolPtr(false)})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !useSearch {
t.Fatal("is_unread=false must use search path")
}

_, err = resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{IsUnread: boolPtr(false)})
if err == nil {
t.Fatal("expected list: token conflict for is_unread=false")
}
}

func TestResolveTriagePathListTokenWithListFilter(t *testing.T) {
// list: token + list-compatible filter → OK
useSearch, err := resolveTriagePath(mustParseTriagePageToken(t, "list:abc123"), "", triageFilter{Folder: "inbox"})
Expand Down
2 changes: 1 addition & 1 deletion skills/lark-mail/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)
| [`+message`](references/lark-mail-message.md) | Use only when reading full content for one email by one message ID. For multiple message IDs, use `mail +messages`; do not loop `mail +message`. |
| [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Accepts comma-separated message IDs; CLI handles more than 20 IDs in batches and merges output. |
| [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. |
| [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. |
| [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter JSON/shorthand or --folder for exact-match conditions. |
| [`+watch`](references/lark-mail-watch.md) | Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output. |
| [`+reply`](references/lark-mail-reply.md) | Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically. |
| [`+reply-all`](references/lark-mail-reply-all.md) | Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically. |
Expand Down
7 changes: 5 additions & 2 deletions skills/lark-mail/references/lark-mail-triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ lark-cli mail +triage

# 查看收件箱未读
lark-cli mail +triage --filter '{"folder":"inbox","is_unread":true}'
lark-cli mail +triage --folder=INBOX --filter=is_unread

# 全文搜索
lark-cli mail +triage --query "合同审批"
Expand All @@ -25,6 +26,7 @@ lark-cli mail +triage --query "项目评审" --filter '{"time_range":{"start_tim

# 指定文件夹
lark-cli mail +triage --filter '{"folder":"sent"}'
lark-cli mail +triage --filter folder_id=DRAFT

# 系统标签(可通过 folder 或 label 传入,搜索时自动转为 folder)
lark-cli mail +triage --filter '{"folder":"flagged"}'
Expand All @@ -47,7 +49,8 @@ lark-cli mail +triage --page-size 10

| 参数 | 默认 | 说明 |
|------|------|------|
| `--filter <json>` | — | 筛选条件(见下方字段说明) |
| `--filter <json|shorthand>` | — | 筛选条件(见下方字段说明)。支持 JSON,也支持 `folder_id=DRAFT`、`folder=INBOX`、`is_unread`/`unread`、`is_read`/`read` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the pipe in the table cell.

<json|shorthand> creates an extra Markdown column, so this row renders incorrectly. Use <json-or-shorthand> or escape the pipe.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 52-52: Table column count
Expected: 3; Actual: 4; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-mail/references/lark-mail-triage.md` at line 52, Update the
option description row near the --filter entry so the angle-bracket placeholder
does not contain an unescaped pipe; replace it with a pipe-free equivalent such
as json-or-shorthand, preserving the documented support for both formats.

Source: Linters/SAST tools

| `--folder <folder>` | — | 文件夹名称或 ID 的拆分写法,会归并为 filter folder;可与 `--filter=is_unread` 搭配,不能与 `--filter` 中的 `folder`/`folder_id` 同时使用 |
| `--query <text>` | — | 全文搜索关键词 |
| `--format <mode>` | `table` | `table` / `json` / `data`(`json` 和 `data` 均输出含分页信息的对象) |
| `--max <n>` | `20` | 最大返回条数(1-400),内部自动分页拉取 |
Expand All @@ -73,7 +76,7 @@ lark-cli mail +triage --page-size 10

> **系统标签说明**:`IMPORTANT`/`FLAGGED`/`OTHER` 可通过 `folder` 或 `label` 传入(也支持中文别名 `重要邮件`/`已加旗标`/`其他邮件`、搜索名 `priority`/`flagged`/`other`)。搜索时自动转为 folder 字段,列表时自动转为 label_id。label list 接口不返回这三个系统标签。
>
> **⚠️ 注意**:查询未读请用 `"is_unread":true`
> **简写说明**:常用筛选可直接写 `--filter folder_id=DRAFT`、`--filter=folder=INBOX`、`--filter=is_unread`、`--filter=is_read`。`is_read` 会转换为 `is_unread=false` 并走 search 路径,避免 list API 忽略已读过滤
可运行 `mail +triage --print-filter-schema` 查看完整字段说明。

## 输出
Expand Down
Loading