From 7f3bcfbb72a490546921041c9a8d523b1336c5bb Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Wed, 1 Apr 2026 21:54:24 +0800 Subject: [PATCH 1/2] 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. --- shortcuts/mail/helpers.go | 69 ++++++++++++++++--- shortcuts/mail/mail_watch.go | 63 ++++++++++++++--- shortcuts/mail/mail_watch_test.go | 55 ++++++++------- .../lark-mail/references/lark-mail-watch.md | 2 +- 4 files changed, 145 insertions(+), 44 deletions(-) diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index e054a7dd9..193f4f46b 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -218,24 +218,24 @@ func mailboxPath(mailboxID string, segments ...string) string { } // fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from -// user_mailboxes.profile. Returns empty string on failure (non-fatal). -func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string { +// user_mailboxes.profile. Returns the email address or an error. +func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) (string, error) { if mailboxID == "" { mailboxID = "me" } data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil) if err != nil { - return "" + return "", err } if email := extractPrimaryEmail(data); email != "" { - return email + return email, nil } if nested, ok := data["data"].(map[string]interface{}); ok { if email := extractPrimaryEmail(nested); email != "" { - return email + return email, nil } } - return "" + return "", fmt.Errorf("profile API returned no primary_email_address") } func extractPrimaryEmail(data map[string]interface{}) string { @@ -252,7 +252,8 @@ func extractPrimaryEmail(data map[string]interface{}) string { // fetchCurrentUserEmail retrieves the current mailbox primary email. func fetchCurrentUserEmail(runtime *common.RuntimeContext) string { - return fetchMailboxPrimaryEmail(runtime, "me") + email, _ := fetchMailboxPrimaryEmail(runtime, "me") + return email } // fetchSelfEmailSet returns a set containing the primary email of the given @@ -264,7 +265,7 @@ func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[str mailboxID = "me" } set := make(map[string]bool) - if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" { + if email, _ := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" { set[strings.ToLower(email)] = true } return set @@ -680,6 +681,9 @@ func addUniqueID(dst *[]string, seen map[string]bool, id string) { } func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) { + if err := validateFolderReadScope(runtime); err != nil { + return nil, err + } data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil) if err != nil { 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 } func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) { + if err := validateLabelReadScope(runtime); err != nil { + return nil, err + } data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil) if err != nil { 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 { return nil } +// validateFolderReadScope checks that the user's token includes the +// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders +// before hitting the folders API. System folders are resolved locally and +// never reach this check. +func validateFolderReadScope(runtime *common.RuntimeContext) error { + appID := runtime.Config.AppID + userOpenId := runtime.UserOpenId() + if appID == "" || userOpenId == "" { + return nil + } + stored := auth.GetStoredToken(appID, userOpenId) + if stored == nil { + return nil + } + required := []string{"mail:user_mailbox.folder:read"} + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("folder resolution requires scope: %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant folder read permission", strings.Join(missing, " "))) + } + return nil +} + +// validateLabelReadScope checks that the user's token includes the +// mail:user_mailbox.message:modify scope. Called on-demand by listMailboxLabels +// before hitting the labels API. System labels are resolved locally and +// never reach this check. +func validateLabelReadScope(runtime *common.RuntimeContext) error { + appID := runtime.Config.AppID + userOpenId := runtime.UserOpenId() + if appID == "" || userOpenId == "" { + return nil + } + stored := auth.GetStoredToken(appID, userOpenId) + if stored == nil { + return nil + } + required := []string{"mail:user_mailbox.message:modify"} + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("label resolution requires scope: %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant label access permission", strings.Join(missing, " "))) + } + return nil +} + func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error { if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" { return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required") diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index 21386b7ec..0893e9f75 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,6 +17,7 @@ import ( "regexp" "sort" "strings" + "sync" "syscall" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -79,8 +81,8 @@ var MailWatch = common.Shortcut{ Command: "+watch", 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.", Risk: "read", - 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"}, - AuthTypes: []string{"user", "bot"}, + 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"}, + AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "format", Default: "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)"}, @@ -138,6 +140,11 @@ var MailWatch = common.Shortcut{ Desc(fmt.Sprintf("Subscribe mailbox events (effective_folder_ids=%s, effective_label_ids=%s)", effectiveFolderDisplay, effectiveLabelDisplay)). Body(map[string]interface{}{"event_type": 1}) + if mailbox == "me" { + d.GET(mailboxPath("me", "profile")). + Desc("Resolve mailbox address for event filtering (requires scope mail:user_mailbox:readonly)") + } + if len(resolvedLabelIDs) > 0 { d.Set("filter_label_ids", strings.Join(resolvedLabelIDs, ",")) } @@ -244,11 +251,24 @@ var MailWatch = common.Shortcut{ } info("Mailbox subscribed.") - // mailboxFilter: only apply event-level filtering when an explicit email address is given - // "me" is a server-side alias and cannot be matched against event.mail_address - mailboxFilter := "" - if mailbox != "me" { - mailboxFilter = mailbox + var unsubOnce sync.Once + var unsubErr error + unsubscribe := func() error { + unsubOnce.Do(func() { + _, unsubErr = runtime.CallAPI("POST", mailboxPath(mailbox, "event", "unsubscribe"), nil, map[string]interface{}{"event_type": 1}) + }) + return unsubErr + } + + // Resolve "me" to the actual email address so we can filter events. + mailboxFilter := mailbox + if mailbox == "me" { + resolved, profileErr := fetchMailboxPrimaryEmail(runtime, "me") + if profileErr != nil { + unsubscribe() //nolint:errcheck // best-effort cleanup; primary error is profileErr + return enhanceProfileError(profileErr) + } + mailboxFilter = resolved } eventCount := 0 @@ -257,10 +277,10 @@ var MailWatch = common.Shortcut{ // Extract event body eventBody := extractMailEventBody(data) - // Filter by --mailbox (only when an explicit email address was provided) + // Filter by --mailbox if mailboxFilter != "" { mailAddr, _ := eventBody["mail_address"].(string) - if mailAddr != mailboxFilter { + if !strings.EqualFold(mailAddr, mailboxFilter) { return } } @@ -414,12 +434,19 @@ var MailWatch = common.Shortcut{ }() <-sigCh info(fmt.Sprintf("\nShutting down... (received %d events)", eventCount)) + info("Unsubscribing mailbox events...") + if unsubErr := unsubscribe(); unsubErr != nil { + fmt.Fprintf(errOut, "Warning: unsubscribe failed: %v\n", unsubErr) + } else { + info("Mailbox unsubscribed.") + } signal.Stop(sigCh) os.Exit(0) }() info("Connected. Waiting for mail events... (Ctrl+C to stop)") if err := cli.Start(ctx); err != nil { + unsubscribe() //nolint:errcheck // best-effort cleanup return output.ErrNetwork("WebSocket connection failed: %v", err) } return nil @@ -692,6 +719,24 @@ func wrapWatchSubscribeError(err error) error { return output.ErrWithHint(output.ExitAPI, "api_error", fmt.Sprintf("subscribe mailbox events failed: %v", err), hint) } +// enhanceProfileError wraps a profile API error with actionable hints. +// Permission errors get a scope-specific hint; other errors (network, 5xx) +// are reported as-is so diagnostics aren't misleading. +func enhanceProfileError(err error) error { + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + errType := exitErr.Detail.Type + lower := strings.ToLower(exitErr.Detail.Message) + if errType == "permission" || errType == "missing_scope" || + strings.Contains(lower, "permission") || strings.Contains(lower, "scope") { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + "unable to resolve mailbox address: "+exitErr.Detail.Message, + "run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access") + } + } + return fmt.Errorf("unable to resolve mailbox address for event filtering: %w", err) +} + // decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and // body_plain_text decoded from base64url, so that files saved via --output-dir contain // human-readable content instead of raw base64 strings. diff --git a/shortcuts/mail/mail_watch_test.go b/shortcuts/mail/mail_watch_test.go index 9717de3bf..02476fbdf 100644 --- a/shortcuts/mail/mail_watch_test.go +++ b/shortcuts/mail/mail_watch_test.go @@ -87,8 +87,8 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) { runtime := runtimeForMailWatchTest(t, map[string]string{}) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } if apis[0].Method != "POST" { t.Fatalf("unexpected method: %s", apis[0].Method) @@ -96,10 +96,13 @@ func TestMailWatchDryRunDefaultMetadataFetchesMessage(t *testing.T) { if apis[0].URL != mailboxPath("me", "event", "subscribe") { t.Fatalf("unexpected url: %s", apis[0].URL) } - if apis[1].URL != mailboxPath("me", "messages", "{message_id}") { - t.Fatalf("unexpected fetch url: %s", apis[1].URL) + if apis[1].Method != "GET" || apis[1].URL != mailboxPath("me", "profile") { + t.Fatalf("unexpected profile api: %s %s", apis[1].Method, apis[1].URL) } - if got := apis[1].Params["format"]; got != "metadata" { + if apis[2].URL != mailboxPath("me", "messages", "{message_id}") { + t.Fatalf("unexpected fetch url: %s", apis[2].URL) + } + if got := apis[2].Params["format"]; got != "metadata" { t.Fatalf("unexpected fetch format: %#v", got) } } @@ -110,16 +113,16 @@ func TestMailWatchDryRunMetadataFormatFetchesMessage(t *testing.T) { }) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } - if apis[1].Method != "GET" { - t.Fatalf("unexpected fetch method: %s", apis[1].Method) + if apis[2].Method != "GET" { + t.Fatalf("unexpected fetch method: %s", apis[2].Method) } - if apis[1].URL != mailboxPath("me", "messages", "{message_id}") { - t.Fatalf("unexpected fetch url: %s", apis[1].URL) + if apis[2].URL != mailboxPath("me", "messages", "{message_id}") { + t.Fatalf("unexpected fetch url: %s", apis[2].URL) } - if got := apis[1].Params["format"]; got != "metadata" { + if got := apis[2].Params["format"]; got != "metadata" { t.Fatalf("unexpected fetch format: %#v", got) } } @@ -130,10 +133,10 @@ func TestMailWatchDryRunMinimalFormatFetchesMessage(t *testing.T) { }) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } - if got := apis[1].Params["format"]; got != "metadata" { + if got := apis[2].Params["format"]; got != "metadata" { t.Fatalf("unexpected fetch format: %#v", got) } } @@ -173,10 +176,10 @@ func TestMailWatchDryRunPlainTextFullFormatFetchesMessage(t *testing.T) { }) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } - if got := apis[1].Params["format"]; got != "plain_text_full" { + if got := apis[2].Params["format"]; got != "plain_text_full" { t.Fatalf("unexpected fetch format: %#v", got) } } @@ -187,10 +190,10 @@ func TestMailWatchDryRunFullFormatUsesFull(t *testing.T) { }) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } - if got := apis[1].Params["format"]; got != "full" { + if got := apis[2].Params["format"]; got != "full" { t.Fatalf("unexpected fetch format: %#v", got) } } @@ -202,13 +205,13 @@ func TestMailWatchDryRunEventFormatWithLabelFilterFetchesMessage(t *testing.T) { }) apis := dryRunAPIsForMailWatchTest(t, MailWatch.DryRun(context.Background(), runtime)) - if len(apis) != 2 { - t.Fatalf("expected 2 dry-run apis, got %d", len(apis)) + if len(apis) != 3 { + t.Fatalf("expected 3 dry-run apis, got %d", len(apis)) } - if apis[1].URL != mailboxPath("me", "messages", "{message_id}") { - t.Fatalf("unexpected fetch url: %s", apis[1].URL) + if apis[2].URL != mailboxPath("me", "messages", "{message_id}") { + t.Fatalf("unexpected fetch url: %s", apis[2].URL) } - if got := apis[1].Params["format"]; got != "metadata" { + if got := apis[2].Params["format"]; got != "metadata" { t.Fatalf("unexpected fetch format: %#v", got) } } diff --git a/skills/lark-mail/references/lark-mail-watch.md b/skills/lark-mail/references/lark-mail-watch.md index 0f50f6c67..43c5fd8d8 100644 --- a/skills/lark-mail/references/lark-mail-watch.md +++ b/skills/lark-mail/references/lark-mail-watch.md @@ -5,7 +5,7 @@ 实时监听新邮件事件(`mail.user_mailbox.event.message_received_v1`)。 -**权限要求:** 应用需要 `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`。 +**权限要求:** 应用需要 `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`。 ## 命令 From c8152b392c7cbe8acab76bfef3564d4094681672 Mon Sep 17 00:00:00 2001 From: wangzhengkui Date: Wed, 1 Apr 2026 22:06:17 +0800 Subject: [PATCH 2/2] 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. --- shortcuts/mail/mail_watch.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shortcuts/mail/mail_watch.go b/shortcuts/mail/mail_watch.go index 0893e9f75..c56994270 100644 --- a/shortcuts/mail/mail_watch.go +++ b/shortcuts/mail/mail_watch.go @@ -734,7 +734,8 @@ func enhanceProfileError(err error) error { "run `lark-cli auth login --scope \"mail:user_mailbox:readonly\"` to grant mailbox profile access") } } - return fmt.Errorf("unable to resolve mailbox address for event filtering: %w", err) + // Preserve original error (and its exit code) for non-permission failures. + return err } // decodeBodyFieldsForFile returns a shallow copy of outputData with body_html and