diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 4c8c3fcf..e054a7dd 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -1854,6 +1855,33 @@ func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount . return nil } +// validateConfirmSendScope checks that the user's token includes the +// mail:user_mailbox.message:send scope when --confirm-send is set. +// This scope is not declared in the shortcut's static Scopes (to keep the +// default draft-only path accessible without the sensitive send permission), +// so we validate it dynamically here. +func validateConfirmSendScope(runtime *common.RuntimeContext) error { + if !runtime.Bool("confirm-send") { + return nil + } + 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:send"} + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("--confirm-send requires scope: %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to grant the send 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_confirm_send_scope_test.go b/shortcuts/mail/mail_confirm_send_scope_test.go new file mode 100644 index 00000000..e93fb215 --- /dev/null +++ b/shortcuts/mail/mail_confirm_send_scope_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestConfirmSendMissingScopeReply(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailReply, []string{ + "+reply", "--message-id", "msg_001", "--body", "hello", "--confirm-send", + }, f, stdout) + assertMissingSendScope(t, err) +} + +func TestConfirmSendMissingScopeReplyAll(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailReplyAll, []string{ + "+reply-all", "--message-id", "msg_001", "--body", "hello", "--confirm-send", + }, f, stdout) + assertMissingSendScope(t, err) +} + +func TestConfirmSendMissingScopeForward(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, MailForward, []string{ + "+forward", "--message-id", "msg_001", "--to", "alice@example.com", "--confirm-send", + }, f, stdout) + assertMissingSendScope(t, err) +} + +func assertMissingSendScope(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error when token lacks send scope with --confirm-send, got nil") + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitAuth { + t.Errorf("expected exit code %d (ExitAuth), got %d", output.ExitAuth, exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "missing_scope" { + t.Errorf("expected detail type missing_scope, got %+v", exitErr.Detail) + } +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 7af87421..5b767e67 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -20,7 +20,7 @@ var MailForward = common.Shortcut{ Command: "+forward", Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.", Risk: "write", - Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox: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: "message-id", Desc: "Required. Message ID to forward", Required: true}, @@ -55,6 +55,9 @@ var MailForward = common.Shortcut{ return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateConfirmSendScope(runtime); err != nil { + return err + } if runtime.Bool("confirm-send") { if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 465b6b60..638665e2 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -18,7 +18,7 @@ var MailReply = common.Shortcut{ Command: "+reply", Description: "Reply to a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Sets Re: subject, In-Reply-To, and References headers automatically.", Risk: "write", - Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox: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: "message-id", Desc: "Required. Message ID to reply to", Required: true}, @@ -52,6 +52,9 @@ var MailReply = common.Shortcut{ return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateConfirmSendScope(runtime); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index adfb81ae..91d600bf 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -18,7 +18,7 @@ var MailReplyAll = common.Shortcut{ Command: "+reply-all", Description: "Reply to all recipients and save as draft (default). Use --confirm-send to send immediately after user confirmation. Includes all original To and CC automatically.", Risk: "write", - Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox: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: "message-id", Desc: "Required. Message ID to reply to all recipients", Required: true}, @@ -53,6 +53,9 @@ var MailReplyAll = common.Shortcut{ return api }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateConfirmSendScope(runtime); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/mail/mail_shortcut_test.go b/shortcuts/mail/mail_shortcut_test.go index 2c9d389a..88fa89e0 100644 --- a/shortcuts/mail/mail_shortcut_test.go +++ b/shortcuts/mail/mail_shortcut_test.go @@ -42,7 +42,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by RefreshToken: "test-refresh-token", ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(), RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(), - Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:send mail:user_mailbox.message:modify 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:readonly", + Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify 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:readonly", GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(), } if err := auth.SetStoredToken(token); err != nil { diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index a938054d..baa51306 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -172,7 +172,7 @@ lark-cli mail +draft-edit --draft-id --patch-file /tmp/patch.json ## 注意事项 -- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:send mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限 +- 需要已登录(`lark-cli auth login --scope "mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox:readonly"`)且具备写/读邮件权限 - 邮件 ID 可从 `lark-cli mail user_mailbox.messages list` 获取 - `--bcc` 仅在发送链路中生效,通常不会在收件方看到