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
4 changes: 2 additions & 2 deletions shortcuts/im/im_feed_group_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ var ImFeedGroupList = common.Shortcut{
return executeFeedGroupListGroupsAllPages(runtime)
}

data, err := runtime.DoAPIJSON("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 AI Review | [P3 可维护性] IM feed group 错误契约迁移缺少回归覆盖

这里把 feed group 调用从 DoAPIJSON 切到 DoAPIJSONTyped,成功数据结构不变,但错误分类/输出契约会从 legacy API error 路径变为 typed error 路径;同类改动还出现在 im_feed_group_list_item.goim_feed_group_query_item.go。如果脚本或 AI 依赖旧的错误 envelope/message,非零 API code 场景会发生可见行为变化,但当前新增测试主要覆盖 mail 文案和成功分批,没有锁住 feed group 的错误输出。

修复建议: 补一组 feed group 非零 API code / HTTP error 的单测,断言返回的 code、message、log_id/subtype 等关键字段;或者在 PR 描述中明确这是有意的 IM 错误契约迁移。

如有疑问或认为判断不准确,欢迎直接回复讨论。

if err != nil {
return err
}
Expand Down Expand Up @@ -158,7 +158,7 @@ func executeFeedGroupListGroupsAllPages(rt *common.RuntimeContext) error {
params["end_time"] = []string{end}
}

data, err := rt.DoAPIJSON("GET", feedGroupListPath, params, nil)
data, err := rt.DoAPIJSONTyped("GET", feedGroupListPath, params, nil)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions shortcuts/im/im_feed_group_list_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ var ImFeedGroupListItem = common.Shortcut{
return executeFeedGroupListAllPages(runtime)
}

data, err := runtime.DoAPIJSON("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
if err != nil {
return err
}
Expand Down Expand Up @@ -163,7 +163,7 @@ func executeFeedGroupListAllPages(rt *common.RuntimeContext) error {
params["end_time"] = []string{end}
}

data, err := rt.DoAPIJSON("GET", feedGroupListItemPath(rt), params, nil)
data, err := rt.DoAPIJSONTyped("GET", feedGroupListItemPath(rt), params, nil)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/im/im_feed_group_query_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ var ImFeedGroupQueryItem = common.Shortcut{
return err
}

data, err := runtime.DoAPIJSON("POST", feedGroupQueryItemPath(runtime), nil, body)
data, err := runtime.DoAPIJSONTyped("POST", feedGroupQueryItemPath(runtime), nil, body)
if err != nil {
return err
}
Expand Down
10 changes: 5 additions & 5 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 for one email by
// 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 a single email by message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata.",
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. For multiple IDs, use --message-ids with mail +messages; do not loop mail +message.", 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 @@ -32,7 +32,7 @@ var MailMessage = common.Shortcut{
mailboxID := resolveMailboxID(runtime)
messageID := runtime.Str("message-id")
return common.NewDryRunAPI().
Desc("Fetch full email content and attachments metadata, including inline images").
Desc("Fetch full content for one email only, including attachments metadata; for multiple IDs use mail +messages.").
GET(mailboxPath(mailboxID, "messages", messageID))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand Down
127 changes: 127 additions & 0 deletions shortcuts/mail/mail_message_help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

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

"github.com/spf13/cobra"

"github.com/larksuite/cli/shortcuts/common"
)

func TestMailMessageHelpMentionsSingleMessageOnly(t *testing.T) {
help := strings.ToLower(mountedShortcutHelp(t, MailMessage))
for _, want := range []string{
"single email",
"mail +messages",
"do not loop mail +message",
"single email message id",
} {
if !strings.Contains(help, want) {
t.Errorf("mail +message help should mention %q; got:\n%s", want, help)
}
}
}

func TestMailMessagesHelpMentionsBatchLimitAndAutoChunk(t *testing.T) {
help := strings.ToLower(mountedShortcutHelp(t, MailMessages))
for _, want := range []string{
"multiple emails",
"at most 20 ids per batch_get request",
"merges output",
"current backend raw request validation rejects more than 50 ids",
} {
if !strings.Contains(help, want) {
t.Errorf("mail +messages help should mention %q; got:\n%s", want, help)
}
}
}

func TestMailMessageDryRunMentionsSingleMessageOnly(t *testing.T) {
runtime := runtimeForShortcutDryRun(t, MailMessage, map[string]string{
"message-id": "msg_1",
})
got := strings.ToLower(marshalDryRun(t, MailMessage.DryRun(context.Background(), runtime)))
for _, want := range []string{
"one email only",
"for multiple ids use mail +messages",
} {
if !strings.Contains(got, want) {
t.Errorf("mail +message dry-run should mention %q; got:\n%s", want, got)
}
}
}

func TestMailMessagesDryRunMentionsChunkAndBackendLimit(t *testing.T) {
runtime := runtimeForShortcutDryRun(t, MailMessages, map[string]string{
"message-ids": "msg_1,msg_2",
})
got := strings.ToLower(marshalDryRun(t, MailMessages.DryRun(context.Background(), runtime)))
for _, want := range []string{
"auto-chunks at most 20 ids per request",
"merges output",
"backend raw request validation rejects more than 50 ids",
} {
if !strings.Contains(got, want) {
t.Errorf("mail +messages dry-run should mention %q; got:\n%s", want, got)
}
}
}

func mountedShortcutHelp(t *testing.T, shortcut common.Shortcut) string {
t.Helper()
f, _, _, _ := mailShortcutTestFactory(t)
parent := &cobra.Command{Use: "mail"}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{shortcut.Command})
if err != nil {
t.Fatalf("find mounted shortcut %s: %v", shortcut.Command, err)
}
if cmd == parent {
t.Fatalf("shortcut %s was not mounted", shortcut.Command)
}
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Help(); err != nil {
t.Fatalf("help for %s: %v", shortcut.Command, err)
}
return cmd.Short + "\n" + out.String()
}

func runtimeForShortcutDryRun(t *testing.T, shortcut common.Shortcut, values map[string]string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, fl := range shortcut.Flags {
switch fl.Type {
case "bool":
cmd.Flags().Bool(fl.Name, fl.Default == "true", "")
default:
cmd.Flags().String(fl.Name, fl.Default, "")
}
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("parse flags failed: %v", err)
}
for k, v := range values {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set flag --%s failed: %v", k, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}

func marshalDryRun(t *testing.T, dry *common.DryRunAPI) string {
t.Helper()
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
return string(raw)
}
6 changes: 3 additions & 3 deletions shortcuts/mail/mail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ type mailMessagesOutput struct {
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. This shortcut sends at most 20 IDs per batch_get request and merges output; current backend raw request validation rejects more than 50 IDs.",
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. This shortcut sends at most 20 IDs per batch_get request and merges output; current backend raw request validation rejects more than 50 IDs. 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 AI Review | [P2 正确性] dry-run 展示的 batch_get 请求与真实分批执行不一致

这里会把用户传入的全部 message_ids 放进一个 POST /messages/batch_get body,但 Execute 实际会按 20 个 ID 拆批。故障场景是 AI 对 41 或 60 个 ID 先跑 --dry-run:输出看起来像一次 raw 请求携带 41/60 个 ID,其中 60 个 ID 又与下方文案“raw request rejects more than 50 IDs”冲突,容易让调用方误判 +messages 不能处理 50+ 输入或复制出不可执行的 raw API 样例。

修复建议: 让 dry-run 也表达真实执行计划:对 >20 IDs 输出分批计划/多段 body,或只展示首批并明确 “dry-run body is illustrative; execution splits N IDs into M requests”,避免生成 raw-invalid 的单次请求。

如有疑问或认为判断不准确,欢迎直接回复讨论。

}
return common.NewDryRunAPI().
Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)").
Desc("Fetch multiple emails via messages.batch_get; execution auto-chunks at most 20 IDs per request and merges output. Current backend raw request validation rejects more than 50 IDs per raw request.").
POST(mailboxPath(mailboxID, "messages", "batch_get")).
Body(body)
},
Expand Down
16 changes: 12 additions & 4 deletions shortcuts/mail/mail_messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"github.com/larksuite/cli/internal/httpmock"
)

func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
func TestMailMessagesExecuteChunksByTwentyAndMergesOutput(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 21)
defer reg.Verify(t)

ids := make([]string, 41)
for i := range ids {
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
}
Expand All @@ -30,8 +32,14 @@ func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[20:]),
Body: batchGetMessagesResponse(ids[20:]),
BodyFilter: requestMessageIDsEqual(ids[20:40]),
Body: batchGetMessagesResponse(ids[20:40]),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[40:]),
Body: batchGetMessagesResponse(ids[40:]),
})

err := runMountedMailShortcut(t, MailMessages, []string{
Expand Down
4 changes: 2 additions & 2 deletions shortcuts/mail/mail_triage.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,9 @@ 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")
fmt.Fprintln(runtime.IO().ErrOut, "tip: read full content: single message -> mail +message --mailbox "+shellQuote(mailbox)+" --message-id <id>; multiple messages -> mail +messages --mailbox "+shellQuote(mailbox)+" --message-ids <id1,id2,...>")
} 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 -> mail +message --message-id <id>; multiple messages -> mail +messages --message-ids <id1,id2,...>")
}
}
return nil
Expand Down
10 changes: 8 additions & 2 deletions shortcuts/mail/mail_triage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1647,8 +1647,14 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
if got := strings.Contains(errOut, "--mailbox "+quotedMailbox); got != tt.wantMailboxHint {
t.Fatalf("mailbox hint presence mismatch: got %v, want %v\nstderr:\n%s", got, tt.wantMailboxHint, errOut)
}
if !strings.Contains(errOut, "mail +message") {
t.Fatalf("stderr should contain mail +message tip, got:\n%s", errOut)
for _, want := range []string{"single message -> mail +message", "multiple messages -> mail +messages"} {
if !strings.Contains(errOut, want) {
t.Fatalf("stderr should contain %q tip, got:\n%s", want, errOut)
}
}
oldTip := "tip: use mail +message --message-id <id>" + " to read full content"
if strings.Contains(errOut, oldTip) {
t.Fatalf("stderr should not contain old mail +message-only tip, got:\n%s", errOut)
}
})
}
Expand Down
9 changes: 7 additions & 2 deletions skill-template/domains/mail.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
- ❌ 创建一个新对象代替查询不到的目标(找不到"工作"文件夹时,不得自行创建后再移动)
- ❌ 用占位符(`example.com`、`alice@example.com`、`<id>` 字面量)凑数

所有"删除 X / 归档 X / 打标签 X / 取消定时发送 X"等操作,X 必须来自 `+triage` / `+message` / `drafts list` 等真实查询的返回结果。
所有"删除 X / 归档 X / 打标签 X / 取消定时发送 X"等操作,X 必须来自 `+triage` / `+message` / `+messages` / `drafts list` 等真实查询的返回结果。

### 2. 写操作前显式确认

Expand Down Expand Up @@ -81,7 +81,7 @@

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_id` 用 `+message` 读单封邮件;多个 `message_id` 用 `+messages` 一次读取;`+thread` 读整个会话
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
Expand Down Expand Up @@ -377,6 +377,8 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'

`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。

从 `+triage` 结果读取详情时:只选中一封邮件用 `+message --message-id <id>`;选中多封邮件用 `+messages --message-ids <id1,id2,...>`,不要循环调用 `+message`。`+messages` 执行时每个后端 `batch_get` 请求最多发送 20 个 ID,超过 20 自动拆批并合并输出;当前后端 raw 请求校验上限是 50 个 ID。

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

```bash
Expand All @@ -385,6 +387,9 @@ lark-cli mail +message --message-id <id> --html=false

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

# ✅ 多封邮件:一次调用 +messages,不要循环 +message
lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
```

### 邮件模板(`+template-create` / `+template-update` / `--template-id`)
Expand Down
14 changes: 9 additions & 5 deletions skills/lark-mail/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ metadata:
- ❌ 创建一个新对象代替查询不到的目标(找不到"工作"文件夹时,不得自行创建后再移动)
- ❌ 用占位符(`example.com`、`alice@example.com`、`<id>` 字面量)凑数

所有"删除 X / 归档 X / 打标签 X / 取消定时发送 X"等操作,X 必须来自 `+triage` / `+message` / `drafts list` 等真实查询的返回结果。
所有"删除 X / 归档 X / 打标签 X / 取消定时发送 X"等操作,X 必须来自 `+triage` / `+message` / `+messages` / `drafts list` 等真实查询的返回结果。

### 2. 写操作前显式确认

Expand Down 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_id` 用 `+message` 读单封邮件;多个 `message_id` 用 `+messages` 一次读取;`+thread` 读整个会话
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
Expand Down Expand Up @@ -349,6 +349,8 @@ lark-cli mail +reply --message-id <id> --body '收到,谢谢'

`+message`、`+messages`、`+thread` 默认返回 HTML 正文(`--html=true`)。仅需确认操作结果(如验证标记已读、移动文件夹是否成功)时,用 `--html=false` 跳过 HTML 正文,只返回纯文本,显著减少 token 消耗。

从 `+triage` 结果读取详情时:只选中一封邮件用 `+message --message-id <id>`;选中多封邮件用 `+messages --message-ids <id1,id2,...>`,不要循环调用 `+message`。`+messages` 执行时每个后端 `batch_get` 请求最多发送 20 个 ID,超过 20 自动拆批并合并输出;当前后端 raw 请求校验上限是 50 个 ID。

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

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

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

# ✅ 多封邮件:一次调用 +messages,不要循环 +message
lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
```

### 邮件模板(`+template-create` / `+template-update` / `--template-id`)
Expand Down Expand Up @@ -466,8 +471,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 a single email by message ID. For multiple message IDs, use mail +messages; do not loop mail +message. Returns normalized body content plus attachments metadata. |
| [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. This shortcut sends at most 20 IDs per batch_get request and merges output; current backend raw request validation rejects more than 50 IDs. |
| [`+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 +662,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` |

Loading
Loading