mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
Reference in New Issue
Block a user