Compare commits

...

16 Commits

Author SHA1 Message Date
zhaojunlin.0405
81b778de69 fix: document json .data wrapping in watch schema; assert single NDJSON line
Two review follow-ups on mail +watch:
- --print-output-schema showed only the bare per-event structure (.message
  paths), but the default --format json wraps each event as
  {ok,identity,data:<structure>}. Callers building parse logic from the schema
  would look for .message and miss it (it is at .data.message under the default).
  Add a _format_note documenting the .data prefix for default json.
- The NDJSON failure test only json.Marshal'd watchFailureOutputValue and never
  went through the real print function, so a revert to multi-line PrintJson
  would not be caught. Add TestWatchFailurePrintsSingleNDJSONLine, which drives
  output.PrintNdjson (the function the failure branch calls) and asserts exactly
  one decodable line for both json and data formats.
2026-07-14 16:51:23 +08:00
zhaojunlin.0405
f3558878cb refactor: move mail +triage pagination into data (im/calendar-style)
Align mail +triage with the repo-wide list convention: pagination lives inside
the data object, not meta. Output is {ok, data:{messages, total, has_more,
page_token}} with meta=nil.

Uses a typed triageOutput struct (like calendar +search-event) rather than a
map: output.toGeneric JSON-round-trips a struct, normalizing the nested
messages into []interface{} so output.ExtractItems can find it for
table/csv/ndjson rendering. (A top-level map is not round-tripped, which would
flatten the wrapper into a single row — caught by the new
TestMailTriageNdjsonEmitsOneMessagePerLine regression test.)

internal/output/envelope.go is restored to main (no Meta.HasMore/PageToken, no
Count omitempty change) — this PR no longer touches the shared envelope struct;
supersedes the CodeRabbit-A/P2 discussion. Docs, skill-eval, E2E updated to
.data.messages / .data.total / .data.has_more / .data.page_token.
2026-07-13 16:12:40 +08:00
zhaojunlin.0405
f8fb39bb1f Merge branch 'main' into feat/mail-default-json-output 2026-07-13 14:00:23 +08:00
zhaojunlin.0405
e274a749e9 fix: always serialize meta.count so empty results keep count:0
Meta.Count had json:"count,omitempty", so an empty mail +triage result
dropped .meta.count entirely, contradicting the migration contract
(.count -> .meta.count) and forcing callers to distinguish missing-vs-zero.
Every output.Meta construction already sets Count=len(data), so dropping
omitempty only makes empty list envelopes emit a correct count:0 across all
list commands. Add empty-result and serialization assertions.
2026-07-13 12:03:18 +08:00
zhaojunlin.0405
a871d1a064 fix: keep mail +watch fetch-failure output on one NDJSON line
The default json watch stream emits one enveloped JSON object per line via
PrintNdjson, but the fetch-message failure branch used PrintJson (multi-line
MarshalIndent) and bypassed the envelope, so a mid-stream failure injected a
multi-line {ok:false,...} blob that breaks line-based NDJSON parsers. Route
failures through watchFailureOutputValue: bare payload for --format data,
identity-tagged single line for the default json envelope.
2026-07-13 12:02:45 +08:00
zhaojunlin.0405
fa66195826 test: assert typed subtype/param in mail +triage data-format rejection
Align TestMailTriageRejectsRemovedDataFormat with the repo *_test.go
guideline (error-path tests assert typed errs.ProblemOf metadata —
category/subtype/param — not message substrings alone), matching the
sibling TestMailTriageEnumRejectsUnknownFormat. Addresses CodeRabbit
review on PR #1830.
2026-07-10 11:26:58 +08:00
zhaojunlin.0405
afd4555892 docs: fix stale default-format usage comments in mail triage/watch 2026-07-10 10:35:03 +08:00
zhaojunlin.0405
d826704b6f test: cover --format data rejection; emit count line for all formats 2026-07-10 09:57:13 +08:00
zhaojunlin.0405
d70c87be7d docs: rewrite mail +triage --format reference for ok/data envelope 2026-07-10 09:49:14 +08:00
zhaojunlin.0405
e24252a5fb feat: retain mail +triage --format enum and emit read hints for all formats 2026-07-10 09:46:18 +08:00
zhaojunlin.0405
d8dcd5394f test: adapt mail +triage tests to ok/data envelope output 2026-07-09 21:44:50 +08:00
zhaojunlin.0405
d8997ce19e feat: standardize mail +triage output to ok/data envelope 2026-07-09 21:41:19 +08:00
zhaojunlin.0405
47a97cc249 feat: add has_more/page_token pagination fields to output.Meta 2026-07-09 21:40:16 +08:00
zhaojunlin.0405
cc56cc5665 test: cover mail +watch default json envelope output 2026-07-09 17:20:42 +08:00
zhaojunlin.0405
c02fa92667 docs: update mail triage/watch --format default to json 2026-07-09 16:05:51 +08:00
zhaojunlin.0405
49f60d9239 feat: default mail +triage/+watch output to json 2026-07-09 16:05:51 +08:00
9 changed files with 420 additions and 137 deletions

View File

@@ -212,6 +212,10 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
// Used by --print-output-schema to let callers discover field names without reading skill docs.
func printWatchOutputSchema(runtime *common.RuntimeContext) {
schema := map[string]interface{}{
"_format_note": "Paths below are the bare per-event structure (as emitted by --format data). " +
"With the DEFAULT --format json, each event line is wrapped as " +
`{"ok":true,"identity":"user|bot","data":<structure below>} — prefix every path with .data ` +
"(e.g. .data.message.message_id; for --msg-format event, .data holds {header, event}).",
"minimal": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "<message_id>",

View File

@@ -32,24 +32,6 @@ func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
}
}
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage --json returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
}
if !strings.Contains(stdout.String(), `"messages"`) {
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
}
}
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
f1, stdout1, _, _ := mailShortcutTestFactory(t)
@@ -106,7 +88,36 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
}
if !strings.Contains(problem.Message, "table, json, data") {
if !strings.Contains(problem.Message, "json, pretty, table, ndjson, csv") {
t.Fatalf("message = %q, want allowed values list", problem.Message)
}
}
// 回归:`data` 曾是合法取值envelope 化后从 Enum 移除,应被硬拒(而非静默降级)
func TestMailTriageRejectsRemovedDataFormat(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "data", "--max", "1", "--dry-run"}, f, stdout)
if err == nil {
t.Fatal("expected validation error for removed --format data")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed errs problem carrier", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "data" for --format`) {
t.Fatalf("message = %q, want data rejection", problem.Message)
}
}

View File

@@ -94,7 +94,7 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--max", "1",
"+triage", "--format", "table", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
@@ -113,23 +113,6 @@ func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
}
}
func TestMailTriageJSONDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("json output must not emit table read hint\nstderr=%s", stderr.String())
}
}
func TestMailMessagesExecuteChunksTwentyOneIDsIntoTwoBatchGetCalls(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stub := &httpmock.Stub{

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
@@ -47,6 +48,17 @@ const (
triageAPIRetries = 2 // retry count in addition to the first attempt
)
// triageOutput is the structured output for +triage: the message list plus
// pagination live inside data (im/calendar convention), meta is nil. Passing a
// struct (not a map) lets output.toGeneric JSON-round-trip it so ExtractItems
// can find the messages array for table/csv/ndjson rendering.
type triageOutput struct {
Messages []map[string]interface{} `json:"messages"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
}
var MailTriage = common.Shortcut{
Service: "mail",
Command: "+triage",
@@ -55,7 +67,7 @@ var MailTriage = common.Shortcut{
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "format", Default: "json", Enum: []string{"json", "pretty", "table", "ndjson", "csv"}, Desc: "output format: json (default) | pretty | table | ndjson | csv"},
{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"},
@@ -138,7 +150,6 @@ var MailTriage = common.Shortcut{
}
mailbox := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailbox)
outFormat := runtime.Str("format")
query := runtime.Str("query")
if query != "" {
if err := common.RejectDangerousCharsTyped("--query", query); err != nil {
@@ -277,26 +288,26 @@ var MailTriage = common.Shortcut{
msg["mailbox_id"] = mailbox
}
switch outFormat {
case "json", "data":
outData := map[string]interface{}{
"messages": messages,
"mailbox_id": mailbox,
"count": len(messages),
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
// notice 一律走 stderr不再随 json 带内返回)
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
// 标准信封输出data = {messages, total, has_more, page_token}(与 calendar
// +search-event / im 等 list 命令一致,分页放 data每条 message 已含 mailbox_id
// meta 为 nil--format pretty 走精排表格table/csv/ndjson 由 ExtractItems 从
// data 对象提取 messages 渲染。用 struct而非 mapoutput.toGeneric 对 struct
// 会做 JSON round-trip把嵌套 messages 归一化为 []interface{}ExtractItems 能正确
// 探测到数组字段(顶层 map 不 round-trip会导致 table/csv/ndjson 拍平成一行)。
runtime.OutFormat(triageOutput{
Messages: messages,
Total: len(messages),
HasMore: hasMore,
PageToken: nextPageToken,
}, nil, func(w io.Writer) {
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
fmt.Fprintln(w, "No messages found.")
return
}
var rows []map[string]interface{}
for _, msg := range messages {
@@ -314,29 +325,31 @@ var MailTriage = common.Shortcut{
}
rows = append(rows, row)
}
output.PrintTable(runtime.IO().Out, rows)
fmt.Fprintf(runtime.IO().ErrOut, "\n%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
if mailbox != "me" {
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
output.PrintTable(w, rows)
})
// 人类导航提示走 stderr所有格式一致不污染 stdout 的数据)
fmt.Fprintf(runtime.IO().ErrOut, "%d message(s)\n", len(messages))
if hasMore && nextPageToken != "" {
var hint strings.Builder
hint.WriteString("next page: mail +triage")
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
hint.WriteString(" --mailbox " + shellQuote(mailbox))
}
if query != "" {
hint.WriteString(" --query " + shellQuote(query))
}
if filterStr := runtime.Str("filter"); filterStr != "" {
hint.WriteString(" --filter " + shellQuote(filterStr))
}
hint.WriteString(" --page-token " + shellQuote(nextPageToken))
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
quotedMailbox := shellQuote(mailbox)
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --mailbox "+quotedMailbox+" --message-id <id>; multiple messages use mail +messages --mailbox "+quotedMailbox+" --message-ids <id1>,<id2>,<id3>")
} else {
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>")
}
return nil
},

View File

@@ -1548,9 +1548,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantCount: 2,
},
{
name: "list data public mailbox",
name: "list json public mailbox",
mailbox: "shared@company.com",
format: "data",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageListStub(reg, mailbox, []string{"msg_pub_001"}, false, "")
@@ -1574,7 +1574,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
},
{
name: "empty list json keeps top-level mailbox",
name: "empty list json returns empty data array with count 0",
mailbox: "me",
format: "json",
args: []string{"--filter", `{"folder_id":"INBOX"}`},
@@ -1587,7 +1587,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
tt.register(reg, tt.mailbox)
@@ -1603,11 +1603,8 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
data := decodeMailTriageJSONOutput(t, stdout)
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
if tt.wantNotice != "" && !strings.Contains(stderr.String(), "notice: "+tt.wantNotice) {
t.Fatalf("notice mismatch: got %q, want %q in stderr", stderr.String(), tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
@@ -1622,6 +1619,69 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
}
// 空收件箱也必须显式返回 data.total=0data 为普通 maptotal 恒在),
// 调用方可稳定读取 .data.total无需区分“缺失”与“0”。
func TestMailTriageEmptyResultEmitsDataTotalZero(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", nil, false, "")
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "json", "--filter", `{"folder_id":"INBOX"}`,
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
if messages := mailTriageMessagesFromOutput(t, data); len(messages) != 0 {
t.Fatalf("expected empty messages array, got %d", len(messages))
}
dataObj := mailTriageDataObjFromOutput(t, data)
total, ok := dataObj["total"]
if !ok {
t.Fatalf("empty result must still include data.total, got data=%#v", dataObj)
}
if total != float64(0) {
t.Fatalf("empty result data.total must be 0, got %v", total)
}
}
// 回归:--format ndjson 必须逐条输出 messageExtractItems 从 data 对象提取
// messages 数组),而不是把整个 {messages,total,...} 包装体打成一行。若 messages
// 不是 []interface{}FindArrayField 探测不到数组字段,会退化成整包一行。
func TestMailTriageNdjsonEmitsOneMessagePerLine(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"m1", "m2"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("m1", "Subject One"),
mailTriageBatchMessage("m2", "Subject Two"),
})
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--format", "ndjson", "--filter", `{"folder_id":"INBOX"}`,
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 ndjson lines (one per message), got %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Fatalf("ndjson line %d is not valid JSON: %v\n%s", i, err, line)
}
if _, ok := obj["message_id"]; !ok {
t.Fatalf("ndjson line %d must be a message object with message_id, not the wrapper: %s", i, line)
}
if _, isWrapper := obj["messages"]; isWrapper {
t.Fatalf("ndjson line must be a single message, not the {messages,...} wrapper: %s", line)
}
}
}
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
@@ -1655,6 +1715,40 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageDefaultFormatIsJSON verifies that with no --format flag the
// command defaults to json and prints the paginated object to stdout, not the
// human table.
func TestMailTriageDefaultFormatIsJSON(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageListStub(reg, "me", []string{"msg_ok"}, false, "")
registerMailTriageBatchStub(reg, "me", []map[string]interface{}{
mailTriageBatchMessage("msg_ok", "Present"),
})
err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--filter", `{"folder_id":"INBOX"}`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
if data["ok"] != true {
t.Fatalf("default output must be ok/data envelope, got %#v", data)
}
messages := mailTriageMessagesFromOutput(t, data) // reads .data.messages
if len(messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(messages))
}
dataObj := mailTriageDataObjFromOutput(t, data)
if _, ok := dataObj["total"]; !ok {
t.Fatalf("data.total missing: %#v", dataObj)
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
@@ -1678,7 +1772,7 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
mailTriageBatchMessage("msg_001", "Table message"),
})
args := []string{"+triage", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
args := []string{"+triage", "--format", "pretty", "--max", "1", "--filter", `{"folder_id":"INBOX"}`}
if tt.mailbox != "me" {
args = append(args, "--mailbox", tt.mailbox)
}
@@ -1719,6 +1813,7 @@ func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--format", "pretty",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1742,24 +1837,37 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
return data
}
// mailTriageMessagesFromOutput extracts triage messages as object maps.
// mailTriageMessagesFromOutput extracts triage messages as object maps from
// the envelope's data object (.data.messages).
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
dataObj := mailTriageDataObjFromOutput(t, data)
rawMessages, ok := dataObj["messages"].([]interface{})
if !ok {
t.Fatalf("messages type mismatch: %T", data["messages"])
t.Fatalf("data.messages type mismatch: %T", dataObj["messages"])
}
messages := make([]map[string]interface{}, 0, len(rawMessages))
for i, item := range rawMessages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] type mismatch: %T", i, item)
t.Fatalf("data.messages[%d] type mismatch: %T", i, item)
}
messages = append(messages, msg)
}
return messages
}
// mailTriageDataObjFromOutput extracts the envelope's data object
// ({messages, total, has_more, page_token}); pagination lives here (im-style).
func mailTriageDataObjFromOutput(t *testing.T, data map[string]interface{}) map[string]interface{} {
t.Helper()
dataObj, _ := data["data"].(map[string]interface{})
if dataObj == nil {
t.Fatalf("data object missing in envelope: %#v", data)
}
return dataObj
}
func registerMailTriageListStub(reg *httpmock.Registry, mailbox string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
@@ -1960,7 +2068,8 @@ func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
if got := data["has_more"]; got != false {
dataObj := mailTriageDataObjFromOutput(t, data)
if got, ok := dataObj["has_more"]; ok && got == true {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are

View File

@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "json", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
@@ -332,7 +332,7 @@ var MailWatch = common.Shortcut{
output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
}
}
output.PrintJson(out, failureData)
output.PrintNdjson(out, watchFailureOutputValue(outFormat, string(runtime.As()), failureData))
return
}
}
@@ -385,12 +385,7 @@ var MailWatch = common.Shortcut{
}
}
switch outFormat {
case "json", "":
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
case "data":
output.PrintNdjson(out, outputData)
}
output.PrintNdjson(out, watchOutputValue(outFormat, string(runtime.As()), outputData))
}
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
@@ -687,6 +682,33 @@ func minimalWatchMessage(message map[string]interface{}) map[string]interface{}
return out
}
// watchOutputValue selects the per-event value that +watch prints as NDJSON:
// "data" emits the bare payload; every other format (json — the default) wraps
// it in an ok/identity/data envelope. Extracted from Execute so the default
// envelope behavior is unit-testable without a live WebSocket.
func watchOutputValue(outFormat, identity string, outputData interface{}) interface{} {
if outFormat == "data" {
return outputData
}
return output.Envelope{OK: true, Identity: identity, Data: outputData}
}
// watchFailureOutputValue frames a fetch-failure payload for the watch stream,
// mirroring watchOutputValue so the default json stream stays one JSON object
// per line: bare payload for --format data, identity-tagged single line for the
// default json envelope. failureData already carries {ok:false, error, ...}.
func watchFailureOutputValue(outFormat, identity string, failureData map[string]interface{}) interface{} {
if outFormat == "data" || identity == "" {
return failureData
}
enriched := make(map[string]interface{}, len(failureData)+1)
for k, v := range failureData {
enriched[k] = v
}
enriched["identity"] = identity
return enriched
}
func watchFetchFailureValue(messageID, fetchFormat string, err error, eventBody map[string]interface{}) map[string]interface{} {
payload := map[string]interface{}{
"ok": false,

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -885,3 +886,145 @@ func dryRunAPIsForMailWatchTest(t *testing.T, dry *common.DryRunAPI) []struct {
}
return payload.API
}
// TestWatchOutputValueDefaultIsJSONEnvelope verifies +watch's default format
// (json) wraps each event in an ok/identity/data envelope, while --format data
// emits the bare payload. Covers the default-format behavior without a live
// WebSocket (addresses the coderabbitai review ask).
func TestWatchOutputValueDefaultIsJSONEnvelope(t *testing.T) {
payload := map[string]interface{}{"message": map[string]interface{}{"message_id": "m1"}}
// default (json) → ok/identity/data envelope
b, err := json.Marshal(watchOutputValue("json", "user", payload))
if err != nil {
t.Fatalf("marshal json value: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(b, &env); err != nil {
t.Fatalf("unmarshal json value: %v", err)
}
if env["ok"] != true {
t.Fatalf("default json must have ok:true, got %s", b)
}
if env["identity"] != "user" {
t.Fatalf("default json must carry identity, got %s", b)
}
if _, ok := env["data"]; !ok {
t.Fatalf("default json must have data field, got %s", b)
}
// empty format (safety) also defaults to the json envelope
b3, err := json.Marshal(watchOutputValue("", "bot", payload))
if err != nil {
t.Fatalf("marshal empty-format value: %v", err)
}
var env3 map[string]interface{}
if err := json.Unmarshal(b3, &env3); err != nil {
t.Fatalf("unmarshal empty-format value: %v", err)
}
if env3["ok"] != true {
t.Fatalf("empty format should default to json envelope, got %s", b3)
}
// --format data → bare payload, no envelope
b2, err := json.Marshal(watchOutputValue("data", "user", payload))
if err != nil {
t.Fatalf("marshal data value: %v", err)
}
var bare map[string]interface{}
if err := json.Unmarshal(b2, &bare); err != nil {
t.Fatalf("unmarshal data value: %v", err)
}
if _, hasOK := bare["ok"]; hasOK {
t.Fatalf("--format data must be bare (no envelope), got %s", b2)
}
if _, hasMsg := bare["message"]; !hasMsg {
t.Fatalf("--format data payload should carry message, got %s", b2)
}
}
// P1: fetch 失败分支必须与成功分支同一 NDJSON 框架 —— 默认 json 输出一行
// ok:false + identity + error--format data 输出一行裸 failureData绝不能走
// PrintJson 的多行 pretty会破坏默认 json 的 NDJSON 流)。
func TestWatchFailureOutputValueDefaultIsJSONEnvelope(t *testing.T) {
failure := map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
}
// default (json) → identity-tagged single-line failure envelope, ok:false preserved
b, err := json.Marshal(watchFailureOutputValue("json", "user", failure))
if err != nil {
t.Fatalf("marshal json failure value: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(b, &env); err != nil {
t.Fatalf("unmarshal json failure value: %v", err)
}
if env["ok"] != false {
t.Fatalf("failure json must keep ok:false, got %s", b)
}
if env["identity"] != "user" {
t.Fatalf("failure json must carry identity, got %s", b)
}
if _, ok := env["error"]; !ok {
t.Fatalf("failure json must carry error, got %s", b)
}
// --format data → bare failure payload, no identity injected
b2, err := json.Marshal(watchFailureOutputValue("data", "user", failure))
if err != nil {
t.Fatalf("marshal data failure value: %v", err)
}
var bare map[string]interface{}
if err := json.Unmarshal(b2, &bare); err != nil {
t.Fatalf("unmarshal data failure value: %v", err)
}
if _, hasIdentity := bare["identity"]; hasIdentity {
t.Fatalf("--format data failure must not inject identity, got %s", b2)
}
if bare["ok"] != false {
t.Fatalf("--format data failure should carry ok:false payload, got %s", b2)
}
}
// P1: 通过真实的 output.PrintNdjson335 行调用的同一函数)验证失败分支输出恰好
// 一行合法 NDJSON —— 守住"失败行不破坏流"的契约。若生产端把 PrintNdjson 换回
// 多行 PrintJson这里断言的行数就会 >1 而失败(上面的 value 级测试抓不到这点,
// 因为它自己 json.Marshal不经过打印函数
func TestWatchFailurePrintsSingleNDJSONLine(t *testing.T) {
failure := map[string]interface{}{
"ok": false,
"error": map[string]interface{}{"type": "fetch_message_failed", "message_id": "m1"},
}
// default json一行带 identity 的 ok:false 信封
var buf bytes.Buffer
output.PrintNdjson(&buf, watchFailureOutputValue("json", "user", failure))
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 1 {
t.Fatalf("failure output must be exactly one NDJSON line, got %d:\n%s", len(lines), buf.String())
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(lines[0]), &env); err != nil {
t.Fatalf("failure line must be valid JSON: %v\n%s", err, lines[0])
}
if env["ok"] != false || env["identity"] != "user" || env["error"] == nil {
t.Fatalf("failure line must carry ok:false + identity + error, got: %s", lines[0])
}
// --format data一行裸 failureData不注入 identity
buf.Reset()
output.PrintNdjson(&buf, watchFailureOutputValue("data", "user", failure))
dataLines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(dataLines) != 1 {
t.Fatalf("--format data failure must be exactly one NDJSON line, got %d:\n%s", len(dataLines), buf.String())
}
var bare map[string]interface{}
if err := json.Unmarshal([]byte(dataLines[0]), &bare); err != nil {
t.Fatalf("data failure line must be valid JSON: %v\n%s", err, dataLines[0])
}
if _, hasIdentity := bare["identity"]; hasIdentity {
t.Fatalf("--format data failure line must not inject identity, got: %s", dataLines[0])
}
}

View File

@@ -8,7 +8,7 @@
## 用法
```bash
# 默认:收件箱邮件(默认 20 条,默认table 格式
# 默认:收件箱邮件(默认 20 条,默认 json 信封输出
lark-cli mail +triage
# 查看收件箱未读
@@ -31,12 +31,12 @@ lark-cli mail +triage --filter '{"folder":"flagged"}'
lark-cli mail +triage --filter '{"label":"important"}'
lark-cli mail +triage --filter '{"label":"重要邮件"}'
# json/data 格式可配合 jq 处理
lark-cli mail +triage --format json | jq '.messages[].subject'
# json 输出配合 jq消息数组在 .data.messages
lark-cli mail +triage --format json | jq '.data.messages[].subject'
# 分页:先取 10 条,再用 page_token 翻页
lark-cli mail +triage --max 10 --format json
# 输出中包含 page_token传入下一次请求
# 输出 data 中包含 page_token传入下一次请求
lark-cli mail +triage --page-token 'list:FfccvoqPd...' --max 10 --format json
# --page-size 是 --max 的别名
@@ -49,7 +49,7 @@ lark-cli mail +triage --page-size 10
|------|------|------|
| `--filter <json>` | — | 筛选条件(见下方字段说明) |
| `--query <text>` | — | 全文搜索关键词 |
| `--format <mode>` | `table` | `table` / `json` / `data``json``data` 均输出含分页信息的对象) |
| `--format <mode>` | `json` | `json`(默认,`{ok,data}` 信封,消息在 `data.messages`/ `pretty`(人类表格)/ `table`·`csv`·`ndjson`(通用渲染);非 Enum 值报错 |
| `--max <n>` | `20` | 最大返回条数1-400内部自动分页拉取 |
| `--page-size <n>` | — | `--max` 的别名,两者含义相同;同时指定时 `--page-size` 优先 |
| `--page-token <token>` | — | 上一次响应返回的分页令牌,传入后从该位置继续拉取。令牌带 `search:``list:` 前缀,标识来源路径,不可混用 |
@@ -78,48 +78,46 @@ lark-cli mail +triage --page-size 10
## 输出
### `--format json` / `--format data`
### `--format json`(默认)
两者输出格式相同,均为含分页信息的对象
`{ok,data}` 信封(与 im 等 list 命令一致);`data` 为对象,消息数组在 `data.messages`,分页信息作为 `data` 的兄弟字段;无 `meta`
```json
{
"messages": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
],
"mailbox_id": "me",
"count": 20,
"has_more": true,
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
"ok": true,
"data": {
"messages": [
{
"message_id": "SEU2...",
"mailbox_id": "me",
"date": "Fri, 21 Mar 2026 11:40:00 +0800",
"from": "Alice <alice@example.com>",
"subject": "Weekly update",
"labels": "INBOX,UNREAD"
}
],
"total": 20,
"has_more": true,
"page_token": "list:FfccvoqPd_loLhtcRx8cx..."
}
}
```
- `mailbox_id`当前邮箱标识,用于传递`mail +message --mailbox` 以保持公共邮箱上下文
- `has_more`:是否还有下一页
- `page_token`:传入 `--page-token` 可获取下一页;为空字符串表示已到末尾
- token 前缀 `search:` / `list:` 标识来源 API 路径,不可混用
- `data.messages[].mailbox_id`:邮箱标识,`mail +message --mailbox` 以保持公共邮箱上下文
- `data.total`:本次返回条数(空结果时为 `0`,始终返回,可稳定读取)
- `data.has_more`:是否还有下一页
- `data.page_token`:传入 `--page-token` 获取下一页;前缀 `search:` / `list:` 标识来源路径,不可混用
- **迁移**:旧的顶层 `.messages` / `.count` / `.has_more` / `.page_token` 已迁到 `.data.messages` / `.data.total` / `.data.has_more` / `.data.page_token``--format data` 已移除(用默认或 `--format json`
### `table` 格式
### `pretty` / `table` / `csv` / `ndjson`
`page_token` 信息输出在 stderr自动携带 `--query`/`--filter`/`--mailbox` 参数方便续页
`--format pretty` 输出精排人类表格;`--format table` / `csv` / `ndjson` 用通用格式化器渲染 `data.messages``ExtractItems` 自动从 data 对象提取 messages 数组)为表格 / CSV / NDJSON。导航提示计数、下一页、读全文 tip统一输出到 **stderr**(所有格式一致,不污染 stdout 数据)
```text
15 message(s)
next page: mail +triage --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --message-id <id>; multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中:
```text
next page: mail +triage --mailbox 'shared@example.com' --query '合同审批' --page-token 'search:abc123...'
tip: read full content: single message use mail +message --mailbox 'shared@example.com' --message-id <id>; multiple messages use mail +messages --mailbox 'shared@example.com' --message-ids <id1>,<id2>,<id3>
```
公共邮箱场景下,`--mailbox` 会自动出现在续页和 tip 中。
### 搜索分页注意事项

View File

@@ -10,7 +10,7 @@
## 命令
```bash
# 默认:表格输出 message 元数据
# 默认:json 信封NDJSON 流)输出 message 元数据
lark-cli mail +watch
# 仅输出 message 数据jq 友好)
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|------|------|------|
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--format <mode>` | `json` | 输出样式:`json`默认,带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |