From 4202ae705f5354252639784add3e13b509e1dc09 Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:50:25 +0800 Subject: [PATCH 1/2] feat(mail): add rule reorder completion Co-authored-by: TRAE CLI --- shortcuts/mail/mail_rules_reorder.go | 147 ++++++++++++++++++++++ shortcuts/mail/mail_rules_reorder_test.go | 140 +++++++++++++++++++++ shortcuts/mail/shortcuts.go | 1 + 3 files changed, 288 insertions(+) create mode 100644 shortcuts/mail/mail_rules_reorder.go create mode 100644 shortcuts/mail/mail_rules_reorder_test.go diff --git a/shortcuts/mail/mail_rules_reorder.go b/shortcuts/mail/mail_rules_reorder.go new file mode 100644 index 0000000000..4a14f6b697 --- /dev/null +++ b/shortcuts/mail/mail_rules_reorder.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const mailRulesPageSize = 100 + +// MailRulesReorder moves the supplied rules to the front in the requested +// order and preserves every other rule in its current relative order. +var MailRulesReorder = common.Shortcut{ + Service: "mail", + Command: "+rules-reorder", + Description: "Reorder incoming-mail rules. Specified rule IDs are placed first; all remaining rules keep their current order.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.rule:read", "mail:user_mailbox.rule:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Desc: "Mailbox address that owns the rules (default: me)."}, + {Name: "rule-ids", Type: "string_array", Required: true, Desc: "Rule IDs to prioritize; comma-separated or repeat the flag."}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := normalizeRuleReorderIDs(runtime.StrArray("rule-ids")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailbox := resolveMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Read all mail rules, then submit the complete normalized order."). + GET(mailboxPath(mailbox, "rules")). + POST(mailboxPath(mailbox, "rules", "reorder")). + Body(map[string]interface{}{"rule_ids": runtime.StrArray("rule-ids")}) + }, + Execute: executeMailRulesReorder, +} + +func executeMailRulesReorder(ctx context.Context, runtime *common.RuntimeContext) error { + requested, err := normalizeRuleReorderIDs(runtime.StrArray("rule-ids")) + if err != nil { + return err + } + + mailbox := resolveMailboxID(runtime) + current, err := listAllMailRuleIDs(runtime, mailbox) + if err != nil { + return err + } + final, err := completeMailRuleOrder(requested, current) + if err != nil { + return err + } + if _, err := runtime.CallAPITyped("POST", mailboxPath(mailbox, "rules", "reorder"), nil, map[string]interface{}{"rule_ids": final}); err != nil { + return err + } + runtime.Out(map[string]interface{}{"rule_ids": final}, nil) + return nil +} + +func normalizeRuleReorderIDs(values []string) ([]string, error) { + ids := make([]string, 0, len(values)) + for _, value := range values { + for _, id := range strings.Split(value, ",") { + id = strings.TrimSpace(id) + if id == "" { + return nil, mailValidationParamError("--rule-ids", "rule IDs must not be empty") + } + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, mailValidationParamError("--rule-ids", "at least one rule ID is required") + } + return ids, nil +} + +func listAllMailRuleIDs(runtime *common.RuntimeContext, mailbox string) ([]string, error) { + var ids []string + pageToken := "" + for { + params := map[string]interface{}{"page_size": mailRulesPageSize} + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPITyped("GET", mailboxPath(mailbox, "rules"), params, nil) + if err != nil { + return nil, err + } + ids = append(ids, extractMailRuleIDs(data["items"])...) + hasMore, _ := data["has_more"].(bool) + pageToken, _ = data["page_token"].(string) + if !hasMore || pageToken == "" { + return ids, nil + } + } +} + +func extractMailRuleIDs(value interface{}) []string { + items, _ := value.([]interface{}) + ids := make([]string, 0, len(items)) + for _, item := range items { + rule, _ := item.(map[string]interface{}) + id, _ := rule["rule_id"].(string) + if id == "" { + id, _ = rule["id"].(string) + } + if id != "" { + ids = append(ids, id) + } + } + return ids +} + +func completeMailRuleOrder(requested, current []string) ([]string, error) { + available := make(map[string]struct{}, len(current)) + for _, id := range current { + available[id] = struct{}{} + } + seen := make(map[string]struct{}, len(requested)) + final := make([]string, 0, len(current)) + for _, id := range requested { + if _, ok := available[id]; !ok { + return nil, mailValidationParamError("--rule-ids", "rule not found: %s", id) + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + final = append(final, id) + } + for _, id := range current { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + final = append(final, id) + } + } + if len(final) != len(available) { + return nil, mailInvalidResponseError("rules list contains duplicate or missing rule IDs") + } + return final, nil +} diff --git a/shortcuts/mail/mail_rules_reorder_test.go b/shortcuts/mail/mail_rules_reorder_test.go new file mode 100644 index 0000000000..1338e93b14 --- /dev/null +++ b/shortcuts/mail/mail_rules_reorder_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestCompleteMailRuleOrder(t *testing.T) { + cases := []struct { + name string + requested []string + current []string + want []string + }{ + {"complete input stays ordered", []string{"c", "a", "b"}, []string{"a", "b", "c"}, []string{"c", "a", "b"}}, + {"partial input appends missing", []string{"b"}, []string{"a", "b", "c"}, []string{"b", "a", "c"}}, + {"unordered subset keeps explicit order", []string{"c", "a"}, []string{"a", "b", "c", "d"}, []string{"c", "a", "b", "d"}}, + {"duplicates keep first occurrence", []string{"b", "b", "a"}, []string{"a", "b", "c"}, []string{"b", "a", "c"}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got, err := completeMailRuleOrder(tt.requested, tt.current) + if err != nil { + t.Fatal(err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestCompleteMailRuleOrderRejectsUnknownID(t *testing.T) { + _, err := completeMailRuleOrder([]string{"missing"}, []string{"a"}) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--rule-ids" { + t.Fatalf("expected rule-ids validation error, got %v", err) + } +} + +func TestMailRulesReorderExecutesCompleteOrder(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "a"}, map[string]interface{}{"rule_id": "b"}, map[string]interface{}{"rule_id": "c"}}, "has_more": false}}} + reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, BodyFilter: func(body []byte) bool { + var got map[string][]string + return json.Unmarshal(body, &got) == nil && len(got["rule_ids"]) == 3 && got["rule_ids"][0] == "c" && got["rule_ids"][1] == "a" && got["rule_ids"][2] == "b" + }} + reg.Register(list) + reg.Register(reorder) + if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "c,a,c"}, f, stdout); err != nil { + t.Fatal(err) + } + reg.Verify(t) + data := decodeShortcutEnvelopeData(t, stdout) + if got := data["rule_ids"].([]interface{}); len(got) != 3 || got[0] != "c" { + t.Fatalf("output rule_ids = %#v", data["rule_ids"]) + } +} + +func TestMailRulesReorderRejectsUnknownWithoutWrite(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "a"}}, "has_more": false}}} + reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Optional: true} + reg.Register(list) + reg.Register(reorder) + err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "missing"}, f, stdout) + if err == nil { + t.Fatal("expected unknown-ID error") + } + if len(reorder.CapturedBodies) != 0 { + t.Fatalf("reorder was called for unknown ID") + } + reg.Verify(t) +} + +func TestMailRulesReorderListFailureDoesNotWrite(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Status: 500, Body: map[string]interface{}{"code": 99991663, "msg": "list failed"}} + reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Optional: true} + reg.Register(list) + reg.Register(reorder) + if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "a"}, f, stdout); err == nil { + t.Fatal("expected list failure") + } + if len(reorder.CapturedBodies) != 0 { + t.Fatal("reorder was called after list failure") + } + reg.Verify(t) +} + +func TestMailRulesReorderReturnsWriteFailure(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "a"}}, "has_more": false}}} + reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Status: 500, Body: map[string]interface{}{"code": 99991664, "msg": "rules changed"}} + reg.Register(list) + reg.Register(reorder) + if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "a"}, f, stdout); err == nil { + t.Fatal("expected reorder failure") + } + reg.Verify(t) +} + +func TestMailRulesReorderRejectsEmptyInputWithoutRead(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Optional: true} + reg.Register(list) + err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder"}, f, stdout) + if err == nil { + t.Fatal("expected empty-input error") + } + if len(list.CapturedBodies) != 0 { + t.Fatal("list was called for empty input") + } +} + +func TestMailRulesReorderPaginatesBeforeWriting(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + first := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules?page_size=100", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "a"}}, "has_more": true, "page_token": "next"}}} + second := &httpmock.Stub{Method: "GET", URL: "page_token=next", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "b"}}, "has_more": false}}} + reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, BodyFilter: func(body []byte) bool { return string(body) == `{"rule_ids":["b","a"]}` }} + reg.Register(first) + reg.Register(second) + reg.Register(reorder) + if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "b"}, f, stdout); err != nil { + t.Fatal(err) + } + reg.Verify(t) +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index c7a9c99c16..85e3db1403 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -29,5 +29,6 @@ func Shortcuts() []common.Shortcut { MailTemplateCreate, MailTemplateUpdate, MailLintHTML, + MailRulesReorder, } } From 40390326a15e95538dd304d5959c4cce7182914a Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:10:51 +0800 Subject: [PATCH 2/2] test(mail): support reorder checks on fork baseline Co-authored-by: TRAE CLI --- shortcuts/mail/mail_rules_reorder_test.go | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/shortcuts/mail/mail_rules_reorder_test.go b/shortcuts/mail/mail_rules_reorder_test.go index 1338e93b14..57f1b6eb93 100644 --- a/shortcuts/mail/mail_rules_reorder_test.go +++ b/shortcuts/mail/mail_rules_reorder_test.go @@ -72,31 +72,21 @@ func TestMailRulesReorderExecutesCompleteOrder(t *testing.T) { func TestMailRulesReorderRejectsUnknownWithoutWrite(t *testing.T) { f, stdout, _, reg := mailShortcutTestFactory(t) list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"rule_id": "a"}}, "has_more": false}}} - reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Optional: true} reg.Register(list) - reg.Register(reorder) err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "missing"}, f, stdout) if err == nil { t.Fatal("expected unknown-ID error") } - if len(reorder.CapturedBodies) != 0 { - t.Fatalf("reorder was called for unknown ID") - } reg.Verify(t) } func TestMailRulesReorderListFailureDoesNotWrite(t *testing.T) { f, stdout, _, reg := mailShortcutTestFactory(t) list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Status: 500, Body: map[string]interface{}{"code": 99991663, "msg": "list failed"}} - reorder := &httpmock.Stub{Method: "POST", URL: "/user_mailboxes/me/rules/reorder", Optional: true} reg.Register(list) - reg.Register(reorder) if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "a"}, f, stdout); err == nil { t.Fatal("expected list failure") } - if len(reorder.CapturedBodies) != 0 { - t.Fatal("reorder was called after list failure") - } reg.Verify(t) } @@ -114,15 +104,11 @@ func TestMailRulesReorderReturnsWriteFailure(t *testing.T) { func TestMailRulesReorderRejectsEmptyInputWithoutRead(t *testing.T) { f, stdout, _, reg := mailShortcutTestFactory(t) - list := &httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/rules", Optional: true} - reg.Register(list) err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder"}, f, stdout) if err == nil { t.Fatal("expected empty-input error") } - if len(list.CapturedBodies) != 0 { - t.Fatal("list was called for empty input") - } + reg.Verify(t) } func TestMailRulesReorderPaginatesBeforeWriting(t *testing.T) {