fix: remove sensitive send scope from reply and forward shortcuts (#92)

* fix: remove sensitive send scope from reply and forward shortcuts

Remove mail:user_mailbox.message:send from the required scopes of
+reply, +reply-all, and +forward shortcuts. This scope is sensitive
and may not be granted, while these shortcuts default to saving
drafts and do not strictly require it.

* fix: validate send scope dynamically when --confirm-send is set

Add validateConfirmSendScope() to check mail:user_mailbox.message:send
in the Validate phase when --confirm-send is used, preventing the
"draft created but send failed" scenario. Add regression tests for
+reply, +reply-all, and +forward.
This commit is contained in:
feng zhi hao
2026-03-30 18:19:11 +08:00
committed by GitHub
parent a13bee8fda
commit ecf3209c52
7 changed files with 94 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -172,7 +172,7 @@ lark-cli mail +draft-edit --draft-id <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` 仅在发送链路中生效,通常不会在收件方看到