Skip to content

Commit f68a411

Browse files
fix(mail): on-demand scope checks and watch event filtering (#198)
* fix(mail): on-demand scope checks, event filtering, and watch lifecycle - Remove mail:user_mailbox.folder:read from watch's static Scopes; add validateFolderReadScope and validateLabelReadScope that check permissions on-demand when listMailboxFolders/listMailboxLabels is called (same pattern as validateConfirmSendScope). - Resolve --mailbox me to real email address via profile API for event filtering, preventing other users' mail events from being processed. Block startup if resolution fails, with proper error type distinction. - Add unsubscribe cleanup (guarded by sync.Once) on all exit paths: SIGINT/SIGTERM, profile resolution failure, and WebSocket failure. - Remove bot from AuthTypes since bot tokens cannot subscribe. - Include profile lookup in dry-run output and update tests. - Update fetchMailboxPrimaryEmail to return error for diagnostics. - Update documentation for on-demand scope requirements. * fix(mail): preserve original error in enhanceProfileError fallback Return the original error directly for non-permission failures instead of wrapping with fmt.Errorf, so structured exit codes (ExitNetwork, ExitAPI) are preserved for scripting.
1 parent eda2b9c commit f68a411

4 files changed

Lines changed: 146 additions & 44 deletions

File tree

shortcuts/mail/helpers.go

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,24 +218,24 @@ func mailboxPath(mailboxID string, segments ...string) string {
218218
}
219219

220220
// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from
221-
// user_mailboxes.profile. Returns empty string on failure (non-fatal).
222-
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string {
221+
// user_mailboxes.profile. Returns the email address or an error.
222+
func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) {
223223
if mailboxID == "" {
224224
mailboxID = "me"
225225
}
226226
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil)
227227
if err != nil {
228-
return ""
228+
return "", err
229229
}
230230
if email := extractPrimaryEmail(data); email != "" {
231-
return email
231+
return email, nil
232232
}
233233
if nested, ok := data["data"].(map[string]interface{}); ok {
234234
if email := extractPrimaryEmail(nested); email != "" {
235-
return email
235+
return email, nil
236236
}
237237
}
238-
return ""
238+
return "", fmt.Errorf("profile API returned no primary_email_address")
239239
}
240240

241241
func extractPrimaryEmail(data map[string]interface{}) string {
@@ -252,7 +252,8 @@ func extractPrimaryEmail(data map[string]interface{}) string {
252252

253253
// fetchCurrentUserEmail retrieves the current mailbox primary email.
254254
func fetchCurrentUserEmail(runtime *common.RuntimeContext) string {
255-
return fetchMailboxPrimaryEmail(runtime, "me")
255+
email, _ := fetchMailboxPrimaryEmail(runtime, "me")
256+
return email
256257
}
257258

258259
// fetchSelfEmailSet returns a set containing the primary email of the given
@@ -264,7 +265,7 @@ func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[str
264265
mailboxID = "me"
265266
}
266267
set := make(map[string]bool)
267-
if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
268+
if email, _ := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" {
268269
set[strings.ToLower(email)] = true
269270
}
270271
return set
@@ -680,6 +681,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) {
680681
}
681682

682683
func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) {
684+
if err := validateFolderReadScope(runtime); err != nil {
685+
return nil, err
686+
}
683687
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil)
684688
if err != nil {
685689
return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID))
@@ -701,6 +705,9 @@ func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]fol
701705
}
702706

703707
func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) {
708+
if err := validateLabelReadScope(runtime); err != nil {
709+
return nil, err
710+
}
704711
data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil)
705712
if err != nil {
706713
return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID))
@@ -1882,6 +1889,52 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
18821889
return nil
18831890
}
18841891

1892+
// validateFolderReadScope checks that the user's token includes the
1893+
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
1894+
// before hitting the folders API. System folders are resolved locally and
1895+
// never reach this check.
1896+
func validateFolderReadScope(runtime *common.RuntimeContext) error {
1897+
appID := runtime.Config.AppID
1898+
userOpenId := runtime.UserOpenId()
1899+
if appID == "" || userOpenId == "" {
1900+
return nil
1901+
}
1902+
stored := auth.GetStoredToken(appID, userOpenId)
1903+
if stored == nil {
1904+
return nil
1905+
}
1906+
required := []string{"mail:user_mailbox.folder:read"}
1907+
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
1908+
return output.ErrWithHint(output.ExitAuth, "missing_scope",
1909+
fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")),
1910+
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " ")))
1911+
}
1912+
return nil
1913+
}
1914+
1915+
// validateLabelReadScope checks that the user's token includes the
1916+
// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels
1917+
// before hitting the labels API. System labels are resolved locally and
1918+
// never reach this check.
1919+
func validateLabelReadScope(runtime *common.RuntimeContext) error {
1920+
appID := runtime.Config.AppID
1921+
userOpenId := runtime.UserOpenId()
1922+
if appID == "" || userOpenId == "" {
1923+
return nil
1924+
}
1925+
stored := auth.GetStoredToken(appID, userOpenId)
1926+
if stored == nil {
1927+
return nil
1928+
}
1929+
required := []string{"mail:user_mailbox.message:modify"}
1930+
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
1931+
return output.ErrWithHint(output.ExitAuth, "missing_scope",
1932+
fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")),
1933+
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " ")))
1934+
}
1935+
return nil
1936+
}
1937+
18851938
func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error {
18861939
if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" {
18871940
return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required")

shortcuts/mail/mail_watch.go

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"bytes"
88
"context"
99
"encoding/json"
10+
"errors"
1011
"fmt"
1112
"io"
1213
"net/http"
@@ -16,6 +17,7 @@ import (
1617
"regexp"
1718
"sort"
1819
"strings"
20+
"sync"
1921
"syscall"
2022

2123
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -79,8 +81,8 @@ var MailWatch = common.Shortcut{
7981
Command: "+watch",
8082
Description: "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.",
8183
Risk: "read",
82-
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.folder:read", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
83-
AuthTypes: []string{"user", "bot"},
84+
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
85+
AuthTypes: []string{"user"},
8486
Flags: []common.Flag{
8587
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
8688
{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)"},
@@ -138,6 +140,11 @@ var MailWatch = common.Shortcut{
138140
Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)).
139141
Body(map[string]interface{}{"event_type": 1})
140142

143+
if mailbox == "me" {
144+
d.GET(mailboxPath("me", "profile")).
145+
Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)")
146+
}
147+
141148
if len(resolvedLabelIDs) > 0 {
142149
d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ","))
143150
}
@@ -244,11 +251,24 @@ var MailWatch = common.Shortcut{
244251
}
245252
info("Mailbox subscribed.")
246253

247-
// mailboxFilter: only apply event-level filtering when an explicit email address is given
248-
// "me" is a server-side alias and cannot be matched against event.mail_address
249-
mailboxFilter := ""
250-
if mailbox != "me" {
251-
mailboxFilter = mailbox
254+
var unsubOnce sync.Once
255+
var unsubErr error
256+
unsubscribe := func() error {
257+
unsubOnce.Do(func() {
258+
_, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1})
259+
})
260+
return unsubErr
261+
}
262+
263+
// Resolve "me" to the actual email address so we can filter events.
264+
mailboxFilter := mailbox
265+
if mailbox == "me" {
266+
resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me")
267+
if profileErr != nil {
268+
unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr
269+
return enhanceProfileError(profileErr)
270+
}
271+
mailboxFilter = resolved
252272
}
253273

254274
eventCount := 0
@@ -257,10 +277,10 @@ var MailWatch = common.Shortcut{
257277
// Extract event body
258278
eventBody := extractMailEventBody(data)
259279

260-
// Filter by --mailbox (only when an explicit email address was provided)
280+
// Filter by --mailbox
261281
if mailboxFilter != "" {
262282
mailAddr, _ := eventBody["mail_address"].(string)
263-
if mailAddr != mailboxFilter {
283+
if !strings.EqualFold(mailAddr, mailboxFilter) {
264284
return
265285
}
266286
}
@@ -414,12 +434,19 @@ var MailWatch = common.Shortcut{
414434
}()
415435
<-sigCh
416436
info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount))
437+
info("Unsubscribing mailbox events...")
438+
if unsubErr := unsubscribe(); unsubErr != nil {
439+
fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr)
440+
} else {
441+
info("Mailbox unsubscribed.")
442+
}
417443
signal.Stop(sigCh)
418444
os.Exit(0)
419445
}()
420446

421447
info("Connected. Waiting for mail events... (Ctrl+C to stop)")
422448
if err := cli.Start(ctx); err != nil {
449+
unsubscribe() //nolint:errcheck // best-effort cleanup
423450
return output.ErrNetwork("WebSocket connection failed: %v", err)
424451
}
425452
return nil
@@ -692,6 +719,25 @@ func wrapWatchSubscribeError(err error) error {
692719
return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint)
693720
}
694721

722+
// enhanceProfileError wraps a profile API error with actionable hints.
723+
// Permission errors get a scope-specific hint; other errors (network, 5xx)
724+
// are reported as-is so diagnostics aren't misleading.
725+
func enhanceProfileError(err error) error {
726+
var exitErr *output.ExitError
727+
if errors.As(err, &exitErr) && exitErr.Detail != nil {
728+
errType := exitErr.Detail.Type
729+
lower := strings.ToLower(exitErr.Detail.Message)
730+
if errType == "permission" || errType == "missing_scope" ||
731+
strings.Contains(lower, "permission") || strings.Contains(lower, "scope") {
732+
return output.ErrWithHint(output.ExitAuth, "missing_scope",
733+
"unable to resolve mailbox address: "+exitErr.Detail.Message,
734+
"run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access")
735+
}
736+
}
737+
// Preserve original error (and its exit code) for non-permission failures.
738+
return err
739+
}
740+
695741
// decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and
696742
// body_plain_text decoded from base64url, so that files saved via --output-dir contain
697743
// human-readable content instead of raw base64 strings.

shortcuts/mail/mail_watch_test.go

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -87,19 +87,22 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) {
8787
runtime := runtimeForMailWatchTest(t, map[string]string{})
8888

8989
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
90-
if len(apis) != 2 {
91-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
90+
if len(apis) != 3 {
91+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
9292
}
9393
if apis[0].Method != "POST" {
9494
t.Fatalf("unexpected method: %s", apis[0].Method)
9595
}
9696
if apis[0].URL != mailboxPath("me", "event", "subscribe") {
9797
t.Fatalf("unexpected url: %s", apis[0].URL)
9898
}
99-
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
100-
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
99+
if apis[1].Method != "GET" || apis[1].URL != mailboxPath("me", "profile") {
100+
t.Fatalf("unexpected profile api: %s %s", apis[1].Method, apis[1].URL)
101101
}
102-
if got := apis[1].Params["format"]; got != "metadata" {
102+
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
103+
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
104+
}
105+
if got := apis[2].Params["format"]; got != "metadata" {
103106
t.Fatalf("unexpected fetch format: %#v", got)
104107
}
105108
}
@@ -110,16 +113,16 @@ func TestMailWatchDryRunMetadataFormatFetchesMessage(t *testing.T) {
110113
})
111114

112115
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
113-
if len(apis) != 2 {
114-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
116+
if len(apis) != 3 {
117+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
115118
}
116-
if apis[1].Method != "GET" {
117-
t.Fatalf("unexpected fetch method: %s", apis[1].Method)
119+
if apis[2].Method != "GET" {
120+
t.Fatalf("unexpected fetch method: %s", apis[2].Method)
118121
}
119-
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
120-
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
122+
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
123+
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
121124
}
122-
if got := apis[1].Params["format"]; got != "metadata" {
125+
if got := apis[2].Params["format"]; got != "metadata" {
123126
t.Fatalf("unexpected fetch format: %#v", got)
124127
}
125128
}
@@ -130,10 +133,10 @@ func TestMailWatchDryRunMinimalFormatFetchesMessage(t *testing.T) {
130133
})
131134

132135
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
133-
if len(apis) != 2 {
134-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
136+
if len(apis) != 3 {
137+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
135138
}
136-
if got := apis[1].Params["format"]; got != "metadata" {
139+
if got := apis[2].Params["format"]; got != "metadata" {
137140
t.Fatalf("unexpected fetch format: %#v", got)
138141
}
139142
}
@@ -173,10 +176,10 @@ func TestMailWatchDryRunPlainTextFullFormatFetchesMessage(t *testing.T) {
173176
})
174177

175178
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
176-
if len(apis) != 2 {
177-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
179+
if len(apis) != 3 {
180+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
178181
}
179-
if got := apis[1].Params["format"]; got != "plain_text_full" {
182+
if got := apis[2].Params["format"]; got != "plain_text_full" {
180183
t.Fatalf("unexpected fetch format: %#v", got)
181184
}
182185
}
@@ -187,10 +190,10 @@ func TestMailWatchDryRunFullFormatUsesFull(t *testing.T) {
187190
})
188191

189192
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
190-
if len(apis) != 2 {
191-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
193+
if len(apis) != 3 {
194+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
192195
}
193-
if got := apis[1].Params["format"]; got != "full" {
196+
if got := apis[2].Params["format"]; got != "full" {
194197
t.Fatalf("unexpected fetch format: %#v", got)
195198
}
196199
}
@@ -202,13 +205,13 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) {
202205
})
203206

204207
apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime))
205-
if len(apis) != 2 {
206-
t.Fatalf("expected 2 dry-run apis, got %d", len(apis))
208+
if len(apis) != 3 {
209+
t.Fatalf("expected 3 dry-run apis, got %d", len(apis))
207210
}
208-
if apis[1].URL != mailboxPath("me", "messages", "{message_id}") {
209-
t.Fatalf("unexpected fetch url: %s", apis[1].URL)
211+
if apis[2].URL != mailboxPath("me", "messages", "{message_id}") {
212+
t.Fatalf("unexpected fetch url: %s", apis[2].URL)
210213
}
211-
if got := apis[1].Params["format"]; got != "metadata" {
214+
if got := apis[2].Params["format"]; got != "metadata" {
212215
t.Fatalf("unexpected fetch format: %#v", got)
213216
}
214217
}

skills/lark-mail/references/lark-mail-watch.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
实时监听新邮件事件(`mail.user_mailbox.event.message_received_v1`)。
77

8-
**权限要求:** 应用需要 `mail:event``mail:user_mailbox.message:readonly``mail:user_mailbox.folder:read` 权限,以及字段权限 `mail:user_mailbox.message.address:read``mail:user_mailbox.message.subject:read``mail:user_mailbox.message.body:read`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`
8+
**权限要求:** 应用需要 `mail:event``mail:user_mailbox.message:readonly` 权限,以及字段权限 `mail:user_mailbox.message.address:read``mail:user_mailbox.message.subject:read``mail:user_mailbox.message.body:read`,且机器人需订阅事件 `mail.user_mailbox.event.message_received_v1`。按需权限(缺失时会提示申请):使用 `--folders` / `--folder-ids` 筛选自定义文件夹时需要 `mail:user_mailbox.folder:read`;使用 `--labels` / `--label-ids` 筛选自定义标签时需要 `mail:user_mailbox.message:modify`
99

1010
## 命令
1111

0 commit comments

Comments
 (0)