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.
This commit is contained in:
wangzhengkui
2026-04-02 10:56:49 +08:00
committed by GitHub
parent eda2b9cd85
commit f68a41163e
4 changed files with 146 additions and 44 deletions

View File

@@ -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")

View File

@@ -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,25 @@ 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")
}
}
// Preserve original error (and its exit code) for non-permission failures.
return 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.

View File

@@ -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)
}
}

View File

@@ -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`
## 命令