Skip to content

Commit 2e7a11a

Browse files
authored
feat(mail): support sharing emails to IM chats (#637)
* feat(mail): add +share-to-chat shortcut to share emails as IM cards Two-step API (create share token → send card) wrapped in a single shortcut. Supports message-id/thread-id, five receive-id-type variants (chat_id, open_id, user_id, union_id, email), and dry-run mode. Change-Id: Ic7b8c01c0d25fef262f35be92555f1fd019bd679 Co-Authored-By: AI * fix(mail): regenerate SKILL.md from skill-template instead of manual edit Add missing safety rule 8 (draft link rule) to skill-template/domains/mail.md so it survives regeneration. SKILL.md is now produced by `make gen-skills` in the registry repo rather than hand-edited. Change-Id: I9cf3605deae8b6de2042e40819fedc304967e78e Co-Authored-By: AI * fix(mail): add docstrings and use real validation path in tests - Add Go doc comments to exported symbols for docstring coverage - Rewrite tests to exercise MailShareToChat.Validate via RuntimeContext instead of duplicating validation logic - Replace hand-rolled containsStr with strings.Contains - Add httpmock stubs for execute and error path tests Change-Id: Ic781494f61e9e844224185844bce7b0c48e8e200 Co-Authored-By: AI * test(mail): add dry-run E2E test for +share-to-chat Validate request shape (method, URL, mailbox path) under --dry-run with fake credentials. Covers message-id, thread-id, and custom mailbox variants. Change-Id: Iae87bf141cbe4f312d3e9b1fca4ba175052c5c35 Co-Authored-By: AI * fix(mail): include request body and params in dry-run output DryRun now mirrors Execute: the share-token POST shows message_id or thread_id, and the send POST shows receive_id_type and receive_id. E2E test updated to assert these fields. Also fix strconv.Itoa usage. Change-Id: I00f8770fd5a12b7354986c5e5077f97cfe5d6653 * style(mail): gofmt dry-run test file Change-Id: I47dc6a9a47252dcfb7853737f88dfdaef65a0ae7 * test(mail): assert exact API call count in dry-run test Change-Id: I9f4a1a183b55d03f5248eb4adddfddb08037ca95
1 parent 5d12931 commit 2e7a11a

7 files changed

Lines changed: 599 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package mail
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
"github.com/larksuite/cli/internal/output"
11+
"github.com/larksuite/cli/shortcuts/common"
12+
)
13+
14+
// validReceiveIDTypes enumerates accepted --receive-id-type values.
15+
var validReceiveIDTypes = map[string]bool{
16+
"chat_id": true,
17+
"open_id": true,
18+
"user_id": true,
19+
"union_id": true,
20+
"email": true,
21+
}
22+
23+
// MailShareToChat shares an email or thread as a card to a Lark IM chat.
24+
var MailShareToChat = common.Shortcut{
25+
Service: "mail",
26+
Command: "+share-to-chat",
27+
Description: "Share an email or thread as a card to a Lark IM chat.",
28+
Risk: "write",
29+
Scopes: []string{
30+
"mail:user_mailbox.message:readonly",
31+
"im:message",
32+
"im:message.send_as_user",
33+
},
34+
AuthTypes: []string{"user"},
35+
HasFormat: true,
36+
Flags: []common.Flag{
37+
{Name: "message-id", Desc: "Message ID to share (mutually exclusive with --thread-id)"},
38+
{Name: "thread-id", Desc: "Thread ID to share (mutually exclusive with --message-id)"},
39+
{Name: "receive-id", Desc: "Receiver ID. Type determined by --receive-id-type.", Required: true},
40+
{Name: "receive-id-type", Default: "chat_id", Desc: "Receiver ID type: chat_id (default), open_id, user_id, union_id, email"},
41+
{Name: "mailbox", Default: "me", Desc: "Mailbox email address (default: me)"},
42+
},
43+
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
44+
mailboxID := resolveMailboxID(runtime)
45+
msgID := runtime.Str("message-id")
46+
threadID := runtime.Str("thread-id")
47+
receiveID := runtime.Str("receive-id")
48+
receiveIDType := runtime.Str("receive-id-type")
49+
50+
var createBody map[string]interface{}
51+
if threadID != "" {
52+
createBody = map[string]interface{}{"thread_id": threadID}
53+
} else {
54+
createBody = map[string]interface{}{"message_id": msgID}
55+
}
56+
57+
return common.NewDryRunAPI().
58+
Desc("Share email card: create share token → send card to IM chat").
59+
POST(mailboxPath(mailboxID, "messages", "share_token")).
60+
Body(createBody).
61+
POST(mailboxPath(mailboxID, "share_tokens", "<card_id>", "send")).
62+
Params(map[string]interface{}{"receive_id_type": receiveIDType}).
63+
Body(map[string]interface{}{"receive_id": receiveID})
64+
},
65+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
66+
msgID := runtime.Str("message-id")
67+
threadID := runtime.Str("thread-id")
68+
if msgID == "" && threadID == "" {
69+
return output.ErrValidation("either --message-id or --thread-id is required")
70+
}
71+
if msgID != "" && threadID != "" {
72+
return output.ErrValidation("--message-id and --thread-id are mutually exclusive")
73+
}
74+
idType := runtime.Str("receive-id-type")
75+
if !validReceiveIDTypes[idType] {
76+
return output.ErrValidation("--receive-id-type must be one of: chat_id, open_id, user_id, union_id, email")
77+
}
78+
return nil
79+
},
80+
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
81+
msgID := runtime.Str("message-id")
82+
threadID := runtime.Str("thread-id")
83+
receiveID := runtime.Str("receive-id")
84+
receiveIDType := runtime.Str("receive-id-type")
85+
mailboxID := resolveMailboxID(runtime)
86+
87+
var createBody map[string]interface{}
88+
if threadID != "" {
89+
createBody = map[string]interface{}{"thread_id": threadID}
90+
} else {
91+
createBody = map[string]interface{}{"message_id": msgID}
92+
}
93+
createResp, err := runtime.CallAPI("POST",
94+
mailboxPath(mailboxID, "messages", "share_token"),
95+
nil, createBody)
96+
if err != nil {
97+
return fmt.Errorf("create share token: %w", err)
98+
}
99+
cardID, _ := createResp["card_id"].(string)
100+
if cardID == "" {
101+
return fmt.Errorf("create share token: response missing card_id")
102+
}
103+
104+
sendResp, err := runtime.CallAPI("POST",
105+
mailboxPath(mailboxID, "share_tokens", cardID, "send"),
106+
map[string]interface{}{"receive_id_type": receiveIDType},
107+
map[string]interface{}{"receive_id": receiveID})
108+
if err != nil {
109+
return fmt.Errorf("share token created (card_id=%s) but send failed: %w", cardID, err)
110+
}
111+
112+
runtime.Out(map[string]interface{}{
113+
"card_id": cardID,
114+
"im_message_id": sendResp["message_id"],
115+
}, nil)
116+
return nil
117+
},
118+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package mail
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"github.com/larksuite/cli/internal/httpmock"
11+
)
12+
13+
func TestShareToChatValidationErrors(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
args []string
17+
wantErr string
18+
}{
19+
{
20+
name: "missing both message-id and thread-id",
21+
args: []string{"+share-to-chat", "--receive-id", "oc_xxx"},
22+
wantErr: "either --message-id or --thread-id is required",
23+
},
24+
{
25+
name: "both message-id and thread-id",
26+
args: []string{"+share-to-chat", "--message-id", "m1", "--thread-id", "t1", "--receive-id", "oc_xxx"},
27+
wantErr: "--message-id and --thread-id are mutually exclusive",
28+
},
29+
{
30+
name: "invalid receive-id-type",
31+
args: []string{"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_xxx", "--receive-id-type", "invalid"},
32+
wantErr: "--receive-id-type must be one of",
33+
},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
f, stdout, _, _ := mailShortcutTestFactory(t)
39+
err := runMountedMailShortcut(t, MailShareToChat, tt.args, f, stdout)
40+
if err == nil {
41+
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
42+
}
43+
if !strings.Contains(err.Error(), tt.wantErr) {
44+
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestShareToChatExecuteWithMessageID(t *testing.T) {
51+
f, stdout, _, reg := mailShortcutTestFactory(t)
52+
reg.Register(&httpmock.Stub{
53+
Method: "POST",
54+
URL: "/user_mailboxes/me/messages/share_token",
55+
Body: map[string]interface{}{
56+
"code": 0,
57+
"data": map[string]interface{}{
58+
"card_id": "card_001",
59+
},
60+
},
61+
})
62+
reg.Register(&httpmock.Stub{
63+
Method: "POST",
64+
URL: "/user_mailboxes/me/share_tokens/card_001/send",
65+
Body: map[string]interface{}{
66+
"code": 0,
67+
"data": map[string]interface{}{
68+
"message_id": "om_001",
69+
},
70+
},
71+
})
72+
73+
err := runMountedMailShortcut(t, MailShareToChat, []string{
74+
"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_xxx",
75+
}, f, stdout)
76+
if err != nil {
77+
t.Fatalf("expected no error, got %v", err)
78+
}
79+
out := stdout.String()
80+
if !strings.Contains(out, "card_001") {
81+
t.Errorf("expected output to contain card_id, got %s", out)
82+
}
83+
if !strings.Contains(out, "om_001") {
84+
t.Errorf("expected output to contain im_message_id, got %s", out)
85+
}
86+
}
87+
88+
func TestShareToChatExecuteWithThreadID(t *testing.T) {
89+
f, stdout, _, reg := mailShortcutTestFactory(t)
90+
reg.Register(&httpmock.Stub{
91+
Method: "POST",
92+
URL: "/user_mailboxes/me/messages/share_token",
93+
Body: map[string]interface{}{
94+
"code": 0,
95+
"data": map[string]interface{}{
96+
"card_id": "card_002",
97+
},
98+
},
99+
})
100+
reg.Register(&httpmock.Stub{
101+
Method: "POST",
102+
URL: "/user_mailboxes/me/share_tokens/card_002/send",
103+
Body: map[string]interface{}{
104+
"code": 0,
105+
"data": map[string]interface{}{
106+
"message_id": "om_002",
107+
},
108+
},
109+
})
110+
111+
err := runMountedMailShortcut(t, MailShareToChat, []string{
112+
"+share-to-chat", "--thread-id", "t1", "--receive-id", "user@example.com", "--receive-id-type", "email",
113+
}, f, stdout)
114+
if err != nil {
115+
t.Fatalf("expected no error, got %v", err)
116+
}
117+
out := stdout.String()
118+
if !strings.Contains(out, "card_002") {
119+
t.Errorf("expected output to contain card_id, got %s", out)
120+
}
121+
}
122+
123+
func TestShareToChatStep1Failure(t *testing.T) {
124+
f, stdout, _, reg := mailShortcutTestFactory(t)
125+
reg.Register(&httpmock.Stub{
126+
Method: "POST",
127+
URL: "/user_mailboxes/me/messages/share_token",
128+
Body: map[string]interface{}{
129+
"code": 4034,
130+
"msg": "message not found",
131+
},
132+
})
133+
134+
err := runMountedMailShortcut(t, MailShareToChat, []string{
135+
"+share-to-chat", "--message-id", "bad_id", "--receive-id", "oc_xxx",
136+
}, f, stdout)
137+
if err == nil {
138+
t.Fatal("expected error for step 1 failure, got nil")
139+
}
140+
if !strings.Contains(err.Error(), "create share token") {
141+
t.Errorf("expected error to mention 'create share token', got %q", err.Error())
142+
}
143+
}
144+
145+
func TestShareToChatStep2Failure(t *testing.T) {
146+
f, stdout, _, reg := mailShortcutTestFactory(t)
147+
reg.Register(&httpmock.Stub{
148+
Method: "POST",
149+
URL: "/user_mailboxes/me/messages/share_token",
150+
Body: map[string]interface{}{
151+
"code": 0,
152+
"data": map[string]interface{}{
153+
"card_id": "card_003",
154+
},
155+
},
156+
})
157+
reg.Register(&httpmock.Stub{
158+
Method: "POST",
159+
URL: "/user_mailboxes/me/share_tokens/card_003/send",
160+
Body: map[string]interface{}{
161+
"code": 4046,
162+
"msg": "user not in chat",
163+
},
164+
})
165+
166+
err := runMountedMailShortcut(t, MailShareToChat, []string{
167+
"+share-to-chat", "--message-id", "m1", "--receive-id", "oc_not_in",
168+
}, f, stdout)
169+
if err == nil {
170+
t.Fatal("expected error for step 2 failure, got nil")
171+
}
172+
if !strings.Contains(err.Error(), "card_003") {
173+
t.Errorf("expected error to contain card_id, got %q", err.Error())
174+
}
175+
if !strings.Contains(err.Error(), "send failed") {
176+
t.Errorf("expected error to mention 'send failed', got %q", err.Error())
177+
}
178+
}
179+
180+
func TestValidReceiveIDTypes(t *testing.T) {
181+
expected := []string{"chat_id", "open_id", "user_id", "union_id", "email"}
182+
for _, typ := range expected {
183+
if !validReceiveIDTypes[typ] {
184+
t.Errorf("expected %q to be a valid receive ID type", typ)
185+
}
186+
}
187+
if validReceiveIDTypes["invalid"] {
188+
t.Error("expected 'invalid' to not be a valid receive ID type")
189+
}
190+
}

shortcuts/mail/shortcuts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
2222
MailSendReceipt,
2323
MailDeclineReceipt,
2424
MailSignature,
25+
MailShareToChat,
2526
}
2627
}

skill-template/domains/mail.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,38 @@ lark-cli mail user_mailbox.sent_messages get_recall_detail --as user \
203203

204204
**注意:** 撤回是异步操作,`recall` 返回成功仅表示请求已受理,实际结果需通过 `get_recall_detail` 查询。若响应中无 `recall_available` 字段,说明该邮件或应用不支持撤回,不要主动提及撤回。
205205

206+
### 分享邮件到 IM
207+
208+
将邮件以卡片形式分享到飞书群聊或个人会话。
209+
210+
**依赖 Scope:** `mail:user_mailbox.message:readonly``im:message``im:message.send_as_user`
211+
212+
1. 分享单封邮件到群聊(默认 `--receive-id-type chat_id`):
213+
```bash
214+
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id oc_xxx
215+
```
216+
217+
2. 分享整个会话到群聊:
218+
```bash
219+
lark-cli mail +share-to-chat --thread-id <会话ID> --receive-id oc_xxx
220+
```
221+
222+
3. 通过邮箱分享给个人:
223+
```bash
224+
lark-cli mail +share-to-chat --message-id <邮件ID> --receive-id user@example.com --receive-id-type email
225+
```
226+
227+
4. 如果不知道群聊 ID,先搜索:
228+
```bash
229+
lark-cli im +chat-search --query "群名关键词"
230+
```
231+
从结果中获取 `chat_id`,然后执行分享。
232+
233+
**注意:**
234+
- 分享需要用户在目标会话中有发消息权限
235+
- 需要同时授权 mail 和 im 两个域的 scope
236+
- 分享的卡片包含邮件摘要信息,收件人可点击查看
237+
206238
### 正文格式:优先使用 HTML
207239

208240
撰写邮件正文时,**默认使用 HTML 格式**(body 内容会被自动检测)。仅当用户明确要求纯文本时,才使用 `--plain-text` 标志强制纯文本模式。

0 commit comments

Comments
 (0)