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
8 changes: 4 additions & 4 deletions shortcuts/mail/mail_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)

// MailMessage is the `+message` shortcut: fetch full content of a single
// email by message ID (normalized body + attachments / inline metadata).
// MailMessage is the `+message` shortcut: fetch full content of one email
// by one message ID (normalized body + attachments / inline metadata).
var MailMessage = common.Shortcut{
Service: "mail",
Command: "+message",
Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.",
Description: "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. Returns normalized body content plus attachments metadata, including inline images.",
Risk: "read",
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-id", Desc: "Required. Email message ID", Required: true},
{Name: "message-id", Desc: "Required. Single email message ID only. For multiple IDs, use mail +messages --message-ids.", Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Expand Down
10 changes: 5 additions & 5 deletions shortcuts/mail/mail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@ type mailMessagesOutput struct {
}

// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// multiple message IDs, chunking requests into batches of 20 while preserving
// request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.",
Description: "Use when reading full content for multiple emails by message ID. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output while preserving request order.",
Risk: "read",
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. You may pass more than 20 IDs; the CLI handles them in batches of 20 and merges output. Example: "<id1>,<id2>,<id3>"`, Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Expand All @@ -52,7 +52,7 @@ var MailMessages = common.Shortcut{
body["message_ids"] = messageIDs
}
return common.NewDryRunAPI().
Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
Desc("Fetch multiple emails; execution chunks every 20 IDs and merges output").
POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body)
},
Expand Down
220 changes: 220 additions & 0 deletions shortcuts/mail/mail_read_help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)

func TestMailMessageHelpClarifiesSingleMessageOnly(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)

err := runMountedMailShortcutWithCobraOutput(t, MailMessage, []string{"+message", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}

help := stdout.String()
for _, want := range []string{
"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",
"Single email message ID only",
"mail +messages --message-ids",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
}

func TestMailMessagesHelpClarifiesBatchGetChunkingAndLimits(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)

err := runMountedMailShortcutWithCobraOutput(t, MailMessages, []string{"+messages", "-h"}, f, stdout)
if err != nil {
t.Fatalf("help returned error: %v", err)
}

help := stdout.String()
for _, want := range []string{
"multiple emails by message ID",
"handles them in batches of 20 and merges output",
"Comma-separated email message IDs",
"You may pass more than 20 IDs",
} {
if !strings.Contains(help, want) {
t.Fatalf("help missing %q\n%s", want, help)
}
}
for _, disallowed := range []string{"messages.batch_get", "OAPI Meta", "gateway config", "50 IDs", "50 个"} {
if strings.Contains(help, disallowed) {
t.Fatalf("help must not expose internal wording %q\n%s", disallowed, help)
}
}
}

func TestMailMessagesDryRunMentionsBatchGetChunkingAndMerge(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
messageIDs := []string{
validMessageIDForTest("dry-run-1"),
validMessageIDForTest("dry-run-2"),
}

err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(messageIDs, ","), "--dry-run", "--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run returned error: %v", err)
}

out := stdout.String()
for _, want := range []string{
"chunks every 20 IDs",
"merges output",
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run missing %q\n%s", want, out)
}
}
}

func TestMailTriageTableHintRoutesSingleAndMultipleReads(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)

err := runMountedMailShortcut(t, MailTriage, []string{
"+triage", "--max", "1",
}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
reg.Verify(t)

errOut := stderr.String()
for _, want := range []string{
"tip: read full content:",
"single message use mail +message --message-id <id>",
"multiple messages use mail +messages --message-ids <id1>,<id2>,<id3>",
} {
if !strings.Contains(errOut, want) {
t.Fatalf("stderr missing %q\n%s", want, errOut)
}
}
}

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{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Reusable: true,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"messages": []interface{}{}},
},
}
reg.Register(stub)

ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(fmt.Sprintf("batch-%02d", i+1))
}
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("messages returned error: %v", err)
}

if got := len(stub.CapturedBodies); got != 2 {
t.Fatalf("expected 2 batch_get calls, got %d", got)
}
assertBatchGetMessageIDCount(t, stub.CapturedBodies[0], 20)
assertBatchGetMessageIDCount(t, stub.CapturedBodies[1], 1)
}

func registerTriageReadHintStubs(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/messages",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{"msg_1"},
"has_more": false,
"page_token": "",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": []interface{}{
map[string]interface{}{
"message_id": "msg_1",
"subject": "Quarterly update",
"date": "Thu, 04 Jun 2026 10:00:00 +0800",
"from": map[string]interface{}{"name": "Alice", "mail_address": "alice@example.com"},
},
},
},
},
})
}

func assertBatchGetMessageIDCount(t *testing.T, body []byte, want int) {
t.Helper()
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal batch_get body: %v\n%s", err, string(body))
}
if got := len(payload.MessageIDs); got != want {
t.Fatalf("message_ids count mismatch: got %d want %d body=%s", got, want, string(body))
}
}

func runMountedMailShortcutWithCobraOutput(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
parent.SetOut(stdout)
parent.SetErr(stdout)
shortcut.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
stdout.Reset()
return parent.Execute()
}
5 changes: 3 additions & 2 deletions shortcuts/mail/mail_triage.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,10 @@ var MailTriage = common.Shortcut{
fmt.Fprintln(runtime.IO().ErrOut, hint.String())
}
if mailbox != "me" {
fmt.Fprintln(runtime.IO().ErrOut, "tip: use mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id> to read full content")
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: use mail +message --message-id <id> to read full content")
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
Expand Down
12 changes: 7 additions & 5 deletions skills/lark-mail/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ metadata:

1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
3. **阅读** — `+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
Expand Down Expand Up @@ -347,7 +347,7 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'

### 读取邮件:按需控制返回内容

`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。
`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。`+message` 只适合单个 `message_id`;多个已知 `message_id` 请一次性传给 `+messages --message-ids <id1>,<id2>,<id3>`。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。

输出默认为结构化 JSON,可直接读取,无需额外编码转换。

Expand All @@ -357,6 +357,9 @@ lark-cli mail +message --message-id <id> --html=false

# ✅ 需要阅读完整内容:保持默认
lark-cli mail +message --message-id <id>

# ✅ 已有多个 message_id:批量读取,避免循环调用 +message
lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
```

### 邮件模板(`+template-create` / `+template-update` / `--template-id`)
Expand Down Expand Up @@ -466,8 +469,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)

| Shortcut | 说明 |
|----------|------|
| [`+message`](references/lark-mail-message.md) | Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images. |
| [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume. |
| [`+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. |
| [`+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. |
Expand Down Expand Up @@ -657,4 +660,3 @@ lark-cli mail <resource> <method> [flags] # 调用 API
| `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` |
| `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` |
| `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` |

5 changes: 4 additions & 1 deletion skills/lark-mail/references/lark-mail-message.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

读取指定邮件的完整内容,包括邮件头、正文(纯文本 + 可选 HTML)以及统一的 `attachments` 列表(涵盖普通附件和内嵌图片)。

`mail +message` 只适合读取一封邮件、一个 `message_id`。如果手上已有多个 `message_id`,请使用 `mail +messages --message-ids <id1>,<id2>,<id3>`;不要循环调用 `mail +message`。

CLI 分两阶段构建最终 JSON:
- 安全的邮件元数据字段直接透传
- 正文、附件和辅助字段由 shortcut 派生
Expand Down Expand Up @@ -34,7 +36,7 @@ lark-cli mail +message --message-id <message-id> --dry-run

| 参数 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `--message-id <id>` | 是 | — | 邮件 ID |
| `--message-id <id>` | 是 | — | 单个邮件 ID;多个 ID 使用 `mail +messages --message-ids` |
| `--mailbox <email>` | 否 | 当前用户 | 邮箱地址(`user_mailbox_id`) |
| `--html` | 否 | true | 是否返回 HTML 正文(`false` 仅返回纯文本,减少带宽) |
| `--format <mode>` | 否 | json | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
Expand Down Expand Up @@ -155,6 +157,7 @@ lark-cli mail +message --message-id <message-id> --dry-run
## 注意事项

- **JSON 输出可直接使用** — 默认输出合法 UTF-8 JSON,可直接读取,无需额外编码转换。
- **单封读取专用** — `mail +message` 只接收一个 `message_id`。多个 ID 使用 `mail +messages --message-ids <id1>,<id2>,<id3>`,避免逐封循环调用。
- JSON 输出中 `body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变,`jq -r` 可还原)。
- `mail +message` 默认不再获取附件/图片下载 URL。这样可以保持邮件详情读取更轻量,调用方可按需单独请求 URL。
- 查看原始 HTML:
Expand Down
12 changes: 6 additions & 6 deletions skills/lark-mail/references/lark-mail-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

通过传入逗号分隔的 `message_id` 列表,一次性读取多封邮件的完整内容。

超过 20 个 ID 可以直接传入 CLI;CLI 会按 20 个 ID 自动拆批并合并输出,不需要手动拆批,也不要逐封循环调用 `+message`。

本 shortcut 是 `mail +message` 的批量版本。每个返回的 `messages[]` 项使用与 `+message` 相同的归一化结构:安全元数据字段直接透传,正文和辅助字段由 shortcut 派生。

优先使用本 shortcut 而非原生 `mail user_mailbox.messages batch_get` API,因为:
优先使用本 shortcut,因为:
- 正文字段已 base64url 解码
- 每条邮件的输出结构已归一化
- 不可用的 message ID 会被显式列出

本 skill 对应 shortcut `lark-cli mail +messages`,内部步骤:
1. `POST /open-apis/mail/v1/user_mailboxes/{mailbox}/messages/batch_get` — 批量获取邮件
2. 对每条返回的邮件使用与 `+message` 相同的规则归一化输出
本 skill 对应 shortcut `lark-cli mail +messages`;每条返回的邮件使用与 `+message` 相同的规则归一化输出。

## 命令

Expand All @@ -38,7 +38,7 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run

| 参数 | 必填 | 默认值 | 说明 |
|------|------|--------|------|
| `--message-ids <id1,id2,...>` | 是 | — | 逗号分隔的邮件 ID 列表 |
| `--message-ids <id1>,<id2>,<id3>` | 是 | — | 逗号分隔的邮件 ID 列表;超过 20 个 ID 时 CLI 自动按 20 拆批并合并输出 |
| `--mailbox <email>` | 否 | 当前用户 | 邮箱地址(`user_mailbox_id`) |
| `--html` | 否 | true | 是否返回 HTML 正文(`false` 仅返回纯文本,减少带宽) |
| `--format <mode>` | 否 | json | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
Expand Down Expand Up @@ -74,7 +74,7 @@ lark-cli mail +messages --message-ids <id1>,<id2> --dry-run

- **JSON 输出可直接使用**,可直接读取,无需额外编码转换。
- 只需读取一封邮件时请使用 `+message`。
- `--message-ids` 无硬性上限;shortcut 内部会自动将大列表拆分为多次批量 API 调用
- CLI 每 20 个 ID 拆成一次调用并合并输出,不需要为大列表手动拆请求
- JSON 输出中 `messages[].body_html` 里的 `<` / `>` 可能显示为 `\u003c` / `\u003e`(JSON 安全转义,内容不变,`jq -r` 可还原)。
- `mail +messages` 仅返回附件元数据。如后续步骤需要下载 URL,请针对特定的 `message_id` 和 `attachment_ids` 调用原生附件 URL API。
- 与 `+message` 一样,普通附件和内嵌图片都出现在 `messages[].attachments[]` 中,使用同一个 `user_mailbox.message.attachments download_url` API。
Expand Down
Loading
Loading