From 16ec2e3c4c4b434ab329eaa151b8dd818b4c1d82 Mon Sep 17 00:00:00 2001 From: oOvalm <126466766+oOvalm@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:22:58 +0800 Subject: [PATCH 1/2] feat: add mail allow block shortcuts --- shortcuts/mail/mail_allow_block.go | 441 ++++++++++++++++++ shortcuts/mail/mail_allow_block_test.go | 379 +++++++++++++++ shortcuts/mail/shortcuts.go | 3 + skill-template/domains/mail.md | 1 + skills/lark-mail/SKILL.md | 19 +- .../lark-mail-allow-block-delete.md | 27 ++ .../references/lark-mail-allow-block-list.md | 30 ++ .../references/lark-mail-allow-block-set.md | 29 ++ 8 files changed, 928 insertions(+), 1 deletion(-) create mode 100644 shortcuts/mail/mail_allow_block.go create mode 100644 shortcuts/mail/mail_allow_block_test.go create mode 100644 skills/lark-mail/references/lark-mail-allow-block-delete.md create mode 100644 skills/lark-mail/references/lark-mail-allow-block-list.md create mode 100644 skills/lark-mail/references/lark-mail-allow-block-set.md diff --git a/shortcuts/mail/mail_allow_block.go b/shortcuts/mail/mail_allow_block.go new file mode 100644 index 000000000..8c7ef34a3 --- /dev/null +++ b/shortcuts/mail/mail_allow_block.go @@ -0,0 +1,441 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + allowBlockTypeAllow = "allow" + allowBlockTypeBlock = "block" + allowBlockTypeAll = "all" + + allowBlockResourceAllow = "allow_senders" + allowBlockResourceBlock = "blocked_senders" + + defaultAllowBlockPageSize = "50" + maxAllowBlockAddresses = 100 + maxAllowBlockQueryLength = 255 +) + +var allowBlockReadScopes = []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message:modify"} +var allowBlockWriteScopes = []string{"mail:user_mailbox.message:modify"} + +type allowBlockListOutput struct { + MailboxID string `json:"mailbox_id"` + Type string `json:"type"` + Items []allowBlockListOutputItem `json:"items"` + HasMore bool `json:"has_more,omitempty"` + NextPageToken string `json:"next_page_token,omitempty"` + Allow map[string]interface{} `json:"allow,omitempty"` + Block map[string]interface{} `json:"block,omitempty"` +} + +type allowBlockListOutputItem struct { + Type string `json:"type"` + Item map[string]interface{} `json:"item"` +} + +type allowBlockBatchOutput struct { + MailboxID string `json:"mailbox_id"` + Type string `json:"type"` + Requested int `json:"requested"` + SuccessCount int `json:"success_count"` + FailedItems []map[string]interface{} `json:"failed_items,omitempty"` + Response map[string]interface{} `json:"response"` +} + +// MailAllowBlockList lists or searches the current user's mail allow/block +// sender list. --type all fans out to both resources and merges the output. +var MailAllowBlockList = common.Shortcut{ + Service: "mail", + Command: "+allow-block-list", + Description: "List or search the current user's mail allow/block sender lists. Use --type allow, block, or all; --type all calls both resources and merges the result.", + Risk: "read", + Scopes: allowBlockReadScopes, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "type", Default: allowBlockTypeAll, Enum: []string{allowBlockTypeAllow, allowBlockTypeBlock, allowBlockTypeAll}, Desc: "Which list to read: allow, block, or all."}, + {Name: "query", Desc: "Optional sender address/domain keyword. Empty means list mode."}, + {Name: "page-size", Type: "int", Default: defaultAllowBlockPageSize, Desc: "Page size, 1-100."}, + {Name: "page-token", Desc: "Cursor from a previous response."}, + }, + Validate: validateAllowBlockList, + DryRun: dryRunAllowBlockList, + Execute: executeAllowBlockList, +} + +// MailAllowBlockSet adds sender addresses/domains to the allow or block list. +var MailAllowBlockSet = common.Shortcut{ + Service: "mail", + Command: "+allow-block-set", + Description: "Add addresses or domains to the current user's mail allow/block sender list.", + Risk: "write", + Scopes: allowBlockWriteScopes, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "type", Required: true, Enum: []string{allowBlockTypeAllow, allowBlockTypeBlock}, Desc: "Target list: allow or block."}, + {Name: "address", Type: "string_slice", Required: true, Desc: "Sender addresses or domains to add; repeat the flag or pass comma-separated values (max 100)."}, + {Name: "scene", Default: "sender", Enum: []string{"sender", "web_image"}, Desc: "Write scene: sender or web_image."}, + }, + Validate: validateAllowBlockSet, + DryRun: dryRunAllowBlockSet, + Execute: executeAllowBlockSet, +} + +// MailAllowBlockDelete removes sender addresses/domains from the allow or +// block list. +var MailAllowBlockDelete = common.Shortcut{ + Service: "mail", + Command: "+allow-block-delete", + Description: "Remove addresses or domains from the current user's mail allow/block sender list.", + Risk: "write", + Scopes: allowBlockWriteScopes, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "type", Required: true, Enum: []string{allowBlockTypeAllow, allowBlockTypeBlock}, Desc: "Target list: allow or block."}, + {Name: "address", Type: "string_slice", Required: true, Desc: "Sender addresses or domains to remove; repeat the flag or pass comma-separated values (max 100)."}, + }, + Validate: validateAllowBlockDelete, + DryRun: dryRunAllowBlockDelete, + Execute: executeAllowBlockDelete, +} + +func validateAllowBlockList(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateBotMailboxNotMe(runtime); err != nil { + return err + } + if err := validateAllowBlockType(runtime.Str("type"), true); err != nil { + return err + } + if query := runtime.Str("query"); len(query) > maxAllowBlockQueryLength { + return mailValidationParamError("--query", "--query must be at most %d characters", maxAllowBlockQueryLength) + } + pageSize := runtime.Int("page-size") + if pageSize < 1 || pageSize > 100 { + return mailValidationParamError("--page-size", "--page-size must be between 1 and 100") + } + return nil +} + +func validateAllowBlockSet(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateBotMailboxNotMe(runtime); err != nil { + return err + } + if err := validateAllowBlockType(runtime.Str("type"), false); err != nil { + return err + } + if err := validateAllowBlockAddresses(runtime.StrSlice("address")); err != nil { + return err + } + scene := runtime.Str("scene") + if scene != "sender" && scene != "web_image" { + return mailValidationParamError("--scene", "--scene must be sender or web_image") + } + return nil +} + +func validateAllowBlockDelete(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateBotMailboxNotMe(runtime); err != nil { + return err + } + if err := validateAllowBlockType(runtime.Str("type"), false); err != nil { + return err + } + return validateAllowBlockAddresses(runtime.StrSlice("address")) +} + +func validateAllowBlockType(typ string, allowAll bool) error { + switch typ { + case allowBlockTypeAllow, allowBlockTypeBlock: + return nil + case allowBlockTypeAll: + if allowAll { + return nil + } + return mailValidationParamError("--type", "--type all is only supported by +allow-block-list; use allow or block") + default: + if allowAll { + return mailValidationParamError("--type", "--type must be allow, block, or all") + } + return mailValidationParamError("--type", "--type must be allow or block") + } +} + +func validateAllowBlockAddresses(raw []string) error { + addresses := normalizeAllowBlockAddresses(raw) + if len(addresses) == 0 { + return mailValidationParamError("--address", "--address is required; provide at least one address or domain") + } + if len(addresses) > maxAllowBlockAddresses { + return mailValidationParamError("--address", "--address accepts at most %d values", maxAllowBlockAddresses) + } + return nil +} + +func normalizeAllowBlockAddresses(raw []string) []string { + out := make([]string, 0, len(raw)) + seen := map[string]struct{}{} + for _, value := range raw { + addr := strings.TrimSpace(value) + if addr == "" { + continue + } + key := strings.ToLower(addr) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, addr) + } + return out +} + +func dryRunAllowBlockList(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + resource := allowBlockResource(runtime.Str("type")) + desc := "List or search mail allow/block senders" + if runtime.Str("type") == allowBlockTypeAll { + resource = allowBlockResourceAllow + desc = "List or search mail allow/block senders; execution also calls blocked_senders when --type all" + } + query := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if runtime.Str("query") != "" { + query["keyword"] = runtime.Str("query") + } + if runtime.Str("page-token") != "" { + query["page_token"] = runtime.Str("page-token") + } + return common.NewDryRunAPI(). + Desc(desc). + GET(mailboxPath(mailboxID, resource)). + Params(query) +} + +func dryRunAllowBlockSet(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Add mail allow/block senders"). + POST(mailboxPath(mailboxID, allowBlockResource(runtime.Str("type")), "batch_create")). + Body(map[string]interface{}{"items": buildAllowBlockItems(runtime)}) +} + +func dryRunAllowBlockDelete(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Remove mail allow/block senders"). + POST(mailboxPath(mailboxID, allowBlockResource(runtime.Str("type")), "batch_remove")). + Body(map[string]interface{}{"senders": normalizeAllowBlockAddresses(runtime.StrSlice("address"))}) +} + +func executeAllowBlockList(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + typ := runtime.Str("type") + query := allowBlockListQuery(runtime) + if typ != allowBlockTypeAll { + data, err := callAllowBlockList(runtime, mailboxID, typ, query) + if err != nil { + return decorateAllowBlockAPIError(err) + } + out := buildAllowBlockListOutput(mailboxID, typ, data) + runtime.OutFormat(out, &output.Meta{Count: len(out.Items)}, nil) + return nil + } + + allowData, err := callAllowBlockList(runtime, mailboxID, allowBlockTypeAllow, query) + if err != nil { + return decorateAllowBlockAPIError(err) + } + blockData, err := callAllowBlockList(runtime, mailboxID, allowBlockTypeBlock, query) + if err != nil { + return decorateAllowBlockAPIError(err) + } + out := mergeAllowBlockListOutput(mailboxID, allowData, blockData) + runtime.OutFormat(out, &output.Meta{Count: len(out.Items)}, nil) + return nil +} + +func executeAllowBlockSet(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + typ := runtime.Str("type") + addresses := normalizeAllowBlockAddresses(runtime.StrSlice("address")) + data, err := runtime.CallAPITyped("POST", + mailboxPath(mailboxID, allowBlockResource(typ), "batch_create"), + nil, map[string]interface{}{"items": buildAllowBlockItems(runtime)}) + if err != nil { + return decorateAllowBlockAPIError(err) + } + out := buildAllowBlockBatchOutput(mailboxID, typ, len(addresses), data) + emitAllowBlockFailedItemsWarning(runtime, out.FailedItems) + runtime.OutFormat(out, &output.Meta{Count: out.SuccessCount}, nil) + return nil +} + +func executeAllowBlockDelete(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + typ := runtime.Str("type") + addresses := normalizeAllowBlockAddresses(runtime.StrSlice("address")) + data, err := runtime.CallAPITyped("POST", + mailboxPath(mailboxID, allowBlockResource(typ), "batch_remove"), + nil, map[string]interface{}{"senders": addresses}) + if err != nil { + return decorateAllowBlockAPIError(err) + } + out := buildAllowBlockBatchOutput(mailboxID, typ, len(addresses), data) + runtime.OutFormat(out, &output.Meta{Count: out.SuccessCount}, nil) + return nil +} + +func allowBlockResource(typ string) string { + if typ == allowBlockTypeAllow { + return allowBlockResourceAllow + } + return allowBlockResourceBlock +} + +func allowBlockListQuery(runtime *common.RuntimeContext) map[string]interface{} { + query := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if keyword := strings.TrimSpace(runtime.Str("query")); keyword != "" { + query["keyword"] = keyword + } + if token := strings.TrimSpace(runtime.Str("page-token")); token != "" { + query["page_token"] = token + } + return query +} + +func callAllowBlockList(runtime *common.RuntimeContext, mailboxID, typ string, query map[string]interface{}) (map[string]interface{}, error) { + return runtime.CallAPITyped("GET", mailboxPath(mailboxID, allowBlockResource(typ)), query, nil) +} + +func buildAllowBlockItems(runtime *common.RuntimeContext) []map[string]interface{} { + addresses := normalizeAllowBlockAddresses(runtime.StrSlice("address")) + items := make([]map[string]interface{}, 0, len(addresses)) + scene := runtime.Str("scene") + for _, address := range addresses { + items = append(items, map[string]interface{}{ + "sender": address, + "sender_type": allowBlockSenderType(address), + "scene": scene, + }) + } + return items +} + +func allowBlockSenderType(sender string) int { + if strings.Contains(sender, "@") { + return 1 + } + return 2 +} + +func buildAllowBlockListOutput(mailboxID, typ string, data map[string]interface{}) allowBlockListOutput { + items := extractAllowBlockItems(typ, data) + return allowBlockListOutput{ + MailboxID: mailboxID, + Type: typ, + Items: items, + HasMore: boolVal(data["has_more"]), + NextPageToken: strVal(data["next_page_token"]), + } +} + +func mergeAllowBlockListOutput(mailboxID string, allowData, blockData map[string]interface{}) allowBlockListOutput { + items := append(extractAllowBlockItems(allowBlockTypeAllow, allowData), extractAllowBlockItems(allowBlockTypeBlock, blockData)...) + return allowBlockListOutput{ + MailboxID: mailboxID, + Type: allowBlockTypeAll, + Items: items, + HasMore: boolVal(allowData["has_more"]) || boolVal(blockData["has_more"]), + Allow: map[string]interface{}{ + "has_more": boolVal(allowData["has_more"]), + "next_page_token": strVal(allowData["next_page_token"]), + }, + Block: map[string]interface{}{ + "has_more": boolVal(blockData["has_more"]), + "next_page_token": strVal(blockData["next_page_token"]), + }, + } +} + +func extractAllowBlockItems(typ string, data map[string]interface{}) []allowBlockListOutputItem { + raw, _ := data["items"].([]interface{}) + items := make([]allowBlockListOutputItem, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, allowBlockListOutputItem{Type: typ, Item: m}) + } + } + return items +} + +func buildAllowBlockBatchOutput(mailboxID, typ string, requested int, data map[string]interface{}) allowBlockBatchOutput { + failedItems := extractAllowBlockFailedItems(data) + successCount := intVal(data["success_count"]) + if successCount == 0 { + successCount = intVal(data["added_count"]) + } + if successCount == 0 { + successCount = intVal(data["deleted_count"]) + } + if successCount == 0 && len(failedItems) == 0 { + successCount = requested + } + return allowBlockBatchOutput{ + MailboxID: mailboxID, + Type: typ, + Requested: requested, + SuccessCount: successCount, + FailedItems: failedItems, + Response: data, + } +} + +func extractAllowBlockFailedItems(data map[string]interface{}) []map[string]interface{} { + raw, _ := data["failed_items"].([]interface{}) + out := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +func emitAllowBlockFailedItemsWarning(runtime *common.RuntimeContext, failedItems []map[string]interface{}) { + if len(failedItems) == 0 { + return + } + fmt.Fprintf(runtime.IO().ErrOut, "warning: %d allow/block item(s) were not applied; inspect failed_items in stdout\n", len(failedItems)) +} + +func decorateAllowBlockAPIError(err error) error { + err = mailDecorateProblemMessage(err, "mail allow/block API failed") + if strings.Contains(strings.ToLower(err.Error()), "cache") || strings.Contains(err.Error(), "456") { + return mailAppendProblemHint(err, "search cache may still be building; retry later or list without --query") + } + if strings.Contains(strings.ToLower(err.Error()), "self address") { + return mailAppendProblemHint(err, "do not add your own email address to the allow/block list") + } + if strings.Contains(strings.ToLower(err.Error()), "self domain") { + return mailAppendProblemHint(err, "do not add your tenant internal domain to the allow/block list") + } + return err +} diff --git a/shortcuts/mail/mail_allow_block_test.go b/shortcuts/mail/mail_allow_block_test.go new file mode 100644 index 000000000..644f5e3b9 --- /dev/null +++ b/shortcuts/mail/mail_allow_block_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestMailAllowBlock_Metadata(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + command string + risk string + scopes []string + flags []string + }{ + { + name: "list", + shortcut: MailAllowBlockList, + command: "+allow-block-list", + risk: "read", + scopes: allowBlockReadScopes, + flags: []string{"mailbox", "type", "query", "page-size", "page-token"}, + }, + { + name: "set", + shortcut: MailAllowBlockSet, + command: "+allow-block-set", + risk: "write", + scopes: allowBlockWriteScopes, + flags: []string{"mailbox", "type", "address", "scene"}, + }, + { + name: "delete", + shortcut: MailAllowBlockDelete, + command: "+allow-block-delete", + risk: "write", + scopes: allowBlockWriteScopes, + flags: []string{"mailbox", "type", "address"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.shortcut.Service != "mail" { + t.Errorf("Service = %q, want mail", tt.shortcut.Service) + } + if tt.shortcut.Command != tt.command { + t.Errorf("Command = %q, want %q", tt.shortcut.Command, tt.command) + } + if tt.shortcut.Risk != tt.risk { + t.Errorf("Risk = %q, want %q", tt.shortcut.Risk, tt.risk) + } + if strings.Join(tt.shortcut.Scopes, " ") != strings.Join(tt.scopes, " ") { + t.Errorf("Scopes = %#v, want %#v", tt.shortcut.Scopes, tt.scopes) + } + if strings.Join(tt.shortcut.AuthTypes, " ") != "user bot" { + t.Errorf("AuthTypes = %#v, want [user bot]", tt.shortcut.AuthTypes) + } + gotFlags := map[string]common.Flag{} + for _, fl := range tt.shortcut.Flags { + gotFlags[fl.Name] = fl + } + for _, name := range tt.flags { + if _, ok := gotFlags[name]; !ok { + t.Errorf("missing flag %s", name) + } + } + }) + } +} + +func TestMailAllowBlockList_MapsAllowRequest(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stub := &httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/allow_senders", + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("keyword") != "example.com" { + t.Fatalf("keyword query = %q, want example.com", q.Get("keyword")) + } + if q.Get("page_size") != "20" { + t.Fatalf("page_size query = %q, want 20", q.Get("page_size")) + } + if q.Get("page_token") != "cursor-0" { + t.Fatalf("page_token query = %q, want cursor-0", q.Get("page_token")) + } + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []map[string]interface{}{ + {"sender": "a@example.com", "scene": "sender"}, + }, + "has_more": true, + "next_page_token": "cursor-1", + }, + }, + } + reg.Register(stub) + + err := runMountedMailShortcut(t, MailAllowBlockList, []string{ + "+allow-block-list", + "--type", "allow", + "--query", "example.com", + "--page-size", "20", + "--page-token", "cursor-0", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + if got := stub.CapturedHeaders.Get("Authorization"); got == "" { + t.Fatal("expected Authorization header to be set") + } + + data := decodeShortcutEnvelopeData(t, stdout) + if data["type"] != allowBlockTypeAllow { + t.Fatalf("type = %v, want allow", data["type"]) + } + if data["next_page_token"] != "cursor-1" { + t.Fatalf("next_page_token = %v, want cursor-1", data["next_page_token"]) + } + items := data["items"].([]interface{}) + if len(items) != 1 { + t.Fatalf("items len = %d, want 1", len(items)) + } + item := items[0].(map[string]interface{}) + if item["type"] != allowBlockTypeAllow { + t.Fatalf("item type = %v, want allow", item["type"]) + } +} + +func TestMailAllowBlockList_AllCallsBothResources(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/allow_senders", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []map[string]interface{}{{"sender": "ally@example.com"}}, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/blocked_senders", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []map[string]interface{}{{"sender": "spam.example.com"}}, + }, + }, + }) + + err := runMountedMailShortcut(t, MailAllowBlockList, []string{ + "+allow-block-list", + "--type", "all", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + data := decodeShortcutEnvelopeData(t, stdout) + items := data["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("items len = %d, want 2", len(items)) + } + if items[0].(map[string]interface{})["type"] != allowBlockTypeAllow { + t.Fatalf("first item type = %v, want allow", items[0]) + } + if items[1].(map[string]interface{})["type"] != allowBlockTypeBlock { + t.Fatalf("second item type = %v, want block", items[1]) + } +} + +func TestMailAllowBlockSet_MapsBatchCreateAndWarnsFailedItems(t *testing.T) { + f, stdout, stderr, reg := mailShortcutTestFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/blocked_senders/batch_create", + BodyFilter: func(body []byte) bool { + var got map[string]interface{} + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("request JSON: %v", err) + } + items := got["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("items len = %d, want 2", len(items)) + } + first := items[0].(map[string]interface{}) + second := items[1].(map[string]interface{}) + return first["sender"] == "spam@example.com" && + first["sender_type"].(float64) == 1 && + first["scene"] == "web_image" && + second["sender_type"].(float64) == 2 + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "success_count": 1, + "failed_items": []map[string]interface{}{ + {"sender": "bad", "reason": "invalid address"}, + }, + }, + }, + } + reg.Register(stub) + + err := runMountedMailShortcut(t, MailAllowBlockSet, []string{ + "+allow-block-set", + "--type", "block", + "--address", "spam@example.com,bad", + "--scene", "web_image", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + if !strings.Contains(stderr.String(), "warning: 1 allow/block item") { + t.Fatalf("stderr missing failed_items warning: %s", stderr.String()) + } + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 1 { + t.Fatalf("success_count = %v, want 1", data["success_count"]) + } + if len(data["failed_items"].([]interface{})) != 1 { + t.Fatalf("failed_items = %#v, want one item", data["failed_items"]) + } +} + +func TestMailAllowBlockDelete_MapsBatchRemove(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/alice@example.com/allow_senders/batch_remove", + BodyFilter: func(body []byte) bool { + var got map[string]interface{} + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("request JSON: %v", err) + } + senders := got["senders"].([]interface{}) + return len(senders) == 2 && senders[0] == "a@example.com" && senders[1] == "example.org" + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"deleted_count": 2}, + }, + } + reg.Register(stub) + + err := runMountedMailShortcut(t, MailAllowBlockDelete, []string{ + "+allow-block-delete", + "--mailbox", "alice@example.com", + "--type", "allow", + "--address", "a@example.com", + "--address", "example.org", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 2 { + t.Fatalf("success_count = %v, want 2", data["success_count"]) + } +} + +func TestMailAllowBlockValidation(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + args []string + param string + cobraErr string + }{ + { + name: "bot me rejected", + shortcut: MailAllowBlockList, + args: []string{"+allow-block-list", "--as", "bot", "--mailbox", "me"}, + param: "--mailbox", + }, + { + name: "set rejects all", + shortcut: MailAllowBlockSet, + args: []string{"+allow-block-set", "--type", "all", "--address", "a@example.com"}, + param: "--type", + }, + { + name: "delete requires address", + shortcut: MailAllowBlockDelete, + args: []string{"+allow-block-delete", "--type", "allow"}, + cobraErr: `required flag(s) "address" not set`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, _ := mailShortcutTestFactory(t) + err := runMountedMailShortcut(t, tt.shortcut, tt.args, f, stdout) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if tt.cobraErr != "" { + if !strings.Contains(err.Error(), tt.cobraErr) { + t.Fatalf("error = %v, want %q", err, tt.cobraErr) + } + return + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if validationErr.Param != tt.param { + t.Fatalf("Param = %q, want %q", validationErr.Param, tt.param) + } + }) + } +} + +func TestMailAllowBlockAPIHints(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + want string + }{ + { + name: "self address", + body: map[string]interface{}{"code": 400, "msg": "cannot add self address"}, + want: "do not add your own email address", + }, + { + name: "cache not ready", + body: map[string]interface{}{"code": 456, "msg": "search cache is building, retry later"}, + want: "search cache may still be building", + }, + { + name: "scope denied", + body: map[string]interface{}{"code": 99991679, "msg": "scope denied"}, + want: "scope", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/allow_senders", + Body: tt.body, + }) + err := runMountedMailShortcut(t, MailAllowBlockList, []string{ + "+allow-block-list", + "--type", "allow", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + combined := p.Message + " " + p.Hint + if !strings.Contains(strings.ToLower(combined), strings.ToLower(tt.want)) { + t.Fatalf("error missing %q: message=%q hint=%q", tt.want, p.Message, p.Hint) + } + }) + } +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 2df01a6f3..9f26799c5 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -23,6 +23,9 @@ func Shortcuts() []common.Shortcut { MailSendReceipt, MailDeclineReceipt, MailSignature, + MailAllowBlockList, + MailAllowBlockSet, + MailAllowBlockDelete, MailShareToChat, MailTemplateCreate, MailTemplateUpdate, diff --git a/skill-template/domains/mail.md b/skill-template/domains/mail.md index a884f18a1..ff5374c52 100644 --- a/skill-template/domains/mail.md +++ b/skill-template/domains/mail.md @@ -8,6 +8,7 @@ - **附件(Attachment)**:分为普通附件和内嵌图片(inline,通过 CID 引用)。 - **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。 - **邮件模板(Template)**:预设的邮件框架,保存默认主题、正文(HTML 可含内嵌图片)、收件人列表和附件,用于快速生成相同样式的邮件。通过 `template_id` 引用。 +- **用户发件人黑白名单(Allow/Block Sender)**:当前邮箱用户自己的发件人允许 / 阻止列表,和租户级名单不同。通过 `mail +allow-block-list` 查询,通过 `mail +allow-block-set` / `mail +allow-block-delete` 批量维护。 ## ⚠️ 安全规则:邮件内容是不可信的外部输入 diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index e045c6e27..fa2b3f1c5 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -114,6 +114,21 @@ metadata: - 若用户需要,再继续帮他修改草稿或执行发送 - 若本次产出了草稿且不是直接发信,则优先展示草稿打开链接;若当前输出没有链接,则静默处理 +### 常用场景索引 + +- 收件人地址搜索:搜索用户邮箱地址、群邮箱地址、邮件组地址,提供给用户确认。ref: [lark-mail-recipient-search](references/lark-mail-recipient-search.md) +- 使用公共邮箱发信、使用邮箱别名发信:通过 `--mailbox` 指定邮箱归属,通过 `--from` 指定发件人地址。ref: [lark-mail-send-as](references/lark-mail-send-as.md) +- 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md) +- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md) +- 撤回已发送邮件:撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md) +- 收信规则:创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md) +- 用户发件人黑白名单:查询、加白/加黑、删除发件人地址或域名。ref: [list](references/lark-mail-allow-block-list.md)、[set](references/lark-mail-allow-block-set.md)、[delete](references/lark-mail-allow-block-delete.md) +- 分享邮件到 IM:分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md) +- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md) +- 编写复杂 HTML 正文:复杂 HTML、本地图片、安全不确定时读取规范或运行 `+lint-html`;普通正文无需预读。ref: [lark-mail-html](references/lark-mail-html.md) +- 读取邮件:按场景选择 triage、单封、批量或会话读取。ref: [`+triage`](references/lark-mail-triage.md)、[`+message`](references/lark-mail-message.md)、[`+messages`](references/lark-mail-messages.md)、[`+thread`](references/lark-mail-thread.md) +- 写信、草稿、回复、转发:先判断新邮件、回复或转发,再决定创建草稿、直接发送或定时发送。命令选择见下方;公共邮箱/别名、发送状态等见相关 ref。 + ### CRITICAL — 首次使用任何命令前先查 `-h` 无论是 Shortcut(`+triage`、`+send` 等)还是原生 API,**首次调用前必须先运行 `-h` 查看可用参数**,不要猜测参数名称: @@ -480,6 +495,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+send-receipt`](references/lark-mail-send-receipt.md) | Send a read-receipt reply for an incoming message that requested one (i.e. carries the READ_RECEIPT_REQUEST label). Body is auto-generated (subject / recipient / send time / read time) to match the Lark client's receipt format — callers cannot customize it, matching the industry norm that read-receipt bodies are system-generated templates, not free-form replies. Intended for agent use after the user confirms. | | [`+decline-receipt`](references/lark-mail-decline-receipt.md) | Dismiss the read-receipt request banner on an incoming mail by clearing its READ_RECEIPT_REQUEST label, without sending a receipt. Use when the user wants to silence the prompt but refuse to confirm they have read it. Idempotent — safe to re-run. | | [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. | +| [`+allow-block-list`](references/lark-mail-allow-block-list.md) | List or search the current user's personal sender allow/block lists. | +| [`+allow-block-set`](references/lark-mail-allow-block-set.md) | Add sender addresses or domains to the current user's personal allow or block list. | +| [`+allow-block-delete`](references/lark-mail-allow-block-delete.md) | Remove sender addresses or domains from the current user's personal allow or block list. | | [`+share-to-chat`](references/lark-mail-share-to-chat.md) | Share an email or thread as a card to a Lark IM chat. | | [`+template-create`](references/lark-mail-template-create.md) | Create a personal mail template. Scans HTML local paths (reusing draft inline-image detection), uploads inline images and non-inline attachments to Drive, rewrites HTML to cid: references, and POSTs a Template payload to mail.user_mailbox.templates.create. | | [`+template-update`](references/lark-mail-template-update.md) | Update an existing mail template. Supports --inspect (read-only projection), --print-patch-template (prints a JSON skeleton for --patch-file), and flat flags (--set-subject / --set-name / etc). Internally it GETs the template, applies the patch, rewrites local paths to cid: refs, and PUTs a full-replace update (no optimistic locking: last-write-wins). | @@ -657,4 +675,3 @@ lark-cli mail [flags] # 调用 API | `user_mailbox.threads.list` | `mail:user_mailbox.message:readonly` | | `user_mailbox.threads.modify` | `mail:user_mailbox.message:modify` | | `user_mailbox.threads.trash` | `mail:user_mailbox.message:modify` | - diff --git a/skills/lark-mail/references/lark-mail-allow-block-delete.md b/skills/lark-mail/references/lark-mail-allow-block-delete.md new file mode 100644 index 000000000..a62068ec8 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-allow-block-delete.md @@ -0,0 +1,27 @@ +# Mail allow/block delete + +Use `mail +allow-block-delete` to remove sender addresses or domains from the current user's personal allow or block list. + +## Command + +```bash +lark-cli mail +allow-block-delete --as user --type allow --address partner@example.com +lark-cli mail +allow-block-delete --as user --type block --address spam.example.com --address bad.example.org +``` + +## Flags + +| Flag | Required | Notes | +|---|---:|---| +| `--mailbox` | no | Mailbox address. Defaults to `me`. With `--as bot`, pass an explicit mailbox address. | +| `--type` | yes | `allow` or `block`. `all` is not supported for writes. | +| `--address` | yes | Repeatable or comma-separated; accepts up to 100 addresses/domains. | + +## Output + +The result includes `requested`, `success_count`, and the raw API `response`. + +## Recovery + +- Permission errors: re-authorize with the scope shown in the typed error hint. +- `--as bot --mailbox me`: pass an explicit mailbox address. diff --git a/skills/lark-mail/references/lark-mail-allow-block-list.md b/skills/lark-mail/references/lark-mail-allow-block-list.md new file mode 100644 index 000000000..38d64446e --- /dev/null +++ b/skills/lark-mail/references/lark-mail-allow-block-list.md @@ -0,0 +1,30 @@ +# Mail allow/block list + +Use `mail +allow-block-list` to list or search the current user's personal sender allow/block lists. + +## Command + +```bash +lark-cli mail +allow-block-list --as user --type all +lark-cli mail +allow-block-list --as user --type block --query spam.example.com +``` + +## Flags + +| Flag | Required | Notes | +|---|---:|---| +| `--mailbox` | no | Mailbox address. Defaults to `me`. With `--as bot`, pass an explicit mailbox address. | +| `--type` | no | `allow`, `block`, or `all`. Defaults to `all`; `all` calls both resources and merges the result. | +| `--query` | no | Optional address/domain keyword. Omit for list mode. | +| `--page-size` | no | 1-100, default 50. | +| `--page-token` | no | Cursor from a previous response. | + +## Output + +The result contains `items[]`, each tagged with `type` (`allow` or `block`), plus pagination fields. When `--type all` is used, `allow` and `block` pagination metadata are returned separately. + +## Recovery + +- `456` / cache building: retry later, or remove `--query` and page through the list. +- Permission errors: re-authorize with the scope shown in the typed error hint. +- `--as bot --mailbox me`: pass an explicit mailbox address. diff --git a/skills/lark-mail/references/lark-mail-allow-block-set.md b/skills/lark-mail/references/lark-mail-allow-block-set.md new file mode 100644 index 000000000..22b0500f5 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-allow-block-set.md @@ -0,0 +1,29 @@ +# Mail allow/block set + +Use `mail +allow-block-set` to add sender addresses or domains to the current user's personal allow or block list. + +## Command + +```bash +lark-cli mail +allow-block-set --as user --type allow --address partner@example.com +lark-cli mail +allow-block-set --as user --type block --address spam.example.com --address bad.example.org +``` + +## Flags + +| Flag | Required | Notes | +|---|---:|---| +| `--mailbox` | no | Mailbox address. Defaults to `me`. With `--as bot`, pass an explicit mailbox address. | +| `--type` | yes | `allow` or `block`. `all` is not supported for writes. | +| `--address` | yes | Repeatable or comma-separated; accepts up to 100 addresses/domains. | +| `--scene` | no | `sender` or `web_image`; defaults to `sender`. | + +## Output + +The result includes `requested`, `success_count`, `failed_items`, and the raw API `response`. If `failed_items` is non-empty, the command still succeeds and prints a warning to stderr so callers can inspect the partial server-side filtering. + +## Recovery + +- Self address/domain errors: do not add your own mailbox address or internal tenant domain. +- Permission errors: re-authorize with the scope shown in the typed error hint. +- `--as bot --mailbox me`: pass an explicit mailbox address. From 8c9ae8b19c49dff31419b7f246b6dc9656e0b5c7 Mon Sep 17 00:00:00 2001 From: oOvalm <126466766+oOvalm@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:39:01 +0800 Subject: [PATCH 2/2] fix: handle allow block batch edge cases Change-Type: ci-fix --- shortcuts/mail/mail_allow_block.go | 21 +++++-- shortcuts/mail/mail_allow_block_test.go | 80 ++++++++++++++++++++----- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/shortcuts/mail/mail_allow_block.go b/shortcuts/mail/mail_allow_block.go index 8c7ef34a3..16f7c8221 100644 --- a/shortcuts/mail/mail_allow_block.go +++ b/shortcuts/mail/mail_allow_block.go @@ -297,6 +297,7 @@ func executeAllowBlockDelete(ctx context.Context, runtime *common.RuntimeContext return decorateAllowBlockAPIError(err) } out := buildAllowBlockBatchOutput(mailboxID, typ, len(addresses), data) + emitAllowBlockFailedItemsWarning(runtime, out.FailedItems) runtime.OutFormat(out, &output.Meta{Count: out.SuccessCount}, nil) return nil } @@ -388,14 +389,14 @@ func extractAllowBlockItems(typ string, data map[string]interface{}) []allowBloc func buildAllowBlockBatchOutput(mailboxID, typ string, requested int, data map[string]interface{}) allowBlockBatchOutput { failedItems := extractAllowBlockFailedItems(data) - successCount := intVal(data["success_count"]) - if successCount == 0 { - successCount = intVal(data["added_count"]) + successCount, ok := intValIfPresent(data, "success_count") + if !ok { + successCount, ok = intValIfPresent(data, "added_count") } - if successCount == 0 { - successCount = intVal(data["deleted_count"]) + if !ok { + successCount, ok = intValIfPresent(data, "deleted_count") } - if successCount == 0 && len(failedItems) == 0 { + if !ok && len(failedItems) == 0 { successCount = requested } return allowBlockBatchOutput{ @@ -408,6 +409,14 @@ func buildAllowBlockBatchOutput(mailboxID, typ string, requested int, data map[s } } +func intValIfPresent(data map[string]interface{}, key string) (int, bool) { + v, ok := data[key] + if !ok { + return 0, false + } + return intVal(v), true +} + func extractAllowBlockFailedItems(data map[string]interface{}) []map[string]interface{} { raw, _ := data["failed_items"].([]interface{}) out := make([]map[string]interface{}, 0, len(raw)) diff --git a/shortcuts/mail/mail_allow_block_test.go b/shortcuts/mail/mail_allow_block_test.go index 644f5e3b9..4ac3e77eb 100644 --- a/shortcuts/mail/mail_allow_block_test.go +++ b/shortcuts/mail/mail_allow_block_test.go @@ -242,7 +242,7 @@ func TestMailAllowBlockSet_MapsBatchCreateAndWarnsFailedItems(t *testing.T) { } func TestMailAllowBlockDelete_MapsBatchRemove(t *testing.T) { - f, stdout, _, reg := mailShortcutTestFactory(t) + f, stdout, stderr, reg := mailShortcutTestFactory(t) stub := &httpmock.Stub{ Method: "POST", URL: "/user_mailboxes/alice@example.com/allow_senders/batch_remove", @@ -256,7 +256,12 @@ func TestMailAllowBlockDelete_MapsBatchRemove(t *testing.T) { }, Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"deleted_count": 2}, + "data": map[string]interface{}{ + "deleted_count": 1, + "failed_items": []map[string]interface{}{ + {"sender": "example.org", "reason": "not_found"}, + }, + }, }, } reg.Register(stub) @@ -272,9 +277,41 @@ func TestMailAllowBlockDelete_MapsBatchRemove(t *testing.T) { t.Fatalf("unexpected error: %v", err) } reg.Verify(t) + if !strings.Contains(stderr.String(), "warning: 1 allow/block item") { + t.Fatalf("stderr missing failed_items warning: %s", stderr.String()) + } data := decodeShortcutEnvelopeData(t, stdout) - if data["success_count"].(float64) != 2 { - t.Fatalf("success_count = %v, want 2", data["success_count"]) + if data["success_count"].(float64) != 1 { + t.Fatalf("success_count = %v, want 1", data["success_count"]) + } + if len(data["failed_items"].([]interface{})) != 1 { + t.Fatalf("failed_items = %#v, want one item", data["failed_items"]) + } +} + +func TestMailAllowBlockDelete_ExplicitZeroDeletedCount(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/blocked_senders/batch_remove", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"deleted_count": 0}, + }, + }) + + err := runMountedMailShortcut(t, MailAllowBlockDelete, []string{ + "+allow-block-delete", + "--type", "block", + "--address", "missing@example.com", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + data := decodeShortcutEnvelopeData(t, stdout) + if data["success_count"].(float64) != 0 { + t.Fatalf("success_count = %v, want explicit zero", data["success_count"]) } } @@ -331,24 +368,32 @@ func TestMailAllowBlockValidation(t *testing.T) { func TestMailAllowBlockAPIHints(t *testing.T) { tests := []struct { - name string - body map[string]interface{} - want string + name string + body map[string]interface{} + want string + wantCategory errs.Category + wantSubtype errs.Subtype }{ { - name: "self address", - body: map[string]interface{}{"code": 400, "msg": "cannot add self address"}, - want: "do not add your own email address", + name: "self address", + body: map[string]interface{}{"code": 400, "msg": "cannot add self address"}, + want: "do not add your own email address", + wantCategory: errs.CategoryAPI, + wantSubtype: errs.SubtypeUnknown, }, { - name: "cache not ready", - body: map[string]interface{}{"code": 456, "msg": "search cache is building, retry later"}, - want: "search cache may still be building", + name: "cache not ready", + body: map[string]interface{}{"code": 456, "msg": "search cache is building, retry later"}, + want: "search cache may still be building", + wantCategory: errs.CategoryAPI, + wantSubtype: errs.SubtypeUnknown, }, { - name: "scope denied", - body: map[string]interface{}{"code": 99991679, "msg": "scope denied"}, - want: "scope", + name: "scope denied", + body: map[string]interface{}{"code": 99991679, "msg": "scope denied"}, + want: "scope", + wantCategory: errs.CategoryAuthorization, + wantSubtype: errs.SubtypeMissingScope, }, } for _, tt := range tests { @@ -370,6 +415,9 @@ func TestMailAllowBlockAPIHints(t *testing.T) { if !ok { t.Fatalf("expected typed error, got %T: %v", err, err) } + if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype { + t.Fatalf("typed error contract = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype) + } combined := p.Message + " " + p.Hint if !strings.Contains(strings.ToLower(combined), strings.ToLower(tt.want)) { t.Fatalf("error missing %q: message=%q hint=%q", tt.want, p.Message, p.Hint)