Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions shortcuts/mail/mail_rules_reorder.go
Original file line number Diff line number Diff line change
@@ -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
}
126 changes: 126 additions & 0 deletions shortcuts/mail/mail_rules_reorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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}}}
reg.Register(list)
err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "missing"}, f, stdout)
if err == nil {
t.Fatal("expected unknown-ID error")
}
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"}}
reg.Register(list)
if err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder", "--rule-ids", "a"}, f, stdout); err == nil {
t.Fatal("expected 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)
err := runMountedMailShortcut(t, MailRulesReorder, []string{"+rules-reorder"}, f, stdout)
if err == nil {
t.Fatal("expected empty-input error")
}
reg.Verify(t)
}

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)
}
1 change: 1 addition & 0 deletions shortcuts/mail/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ func Shortcuts() []common.Shortcut {
MailTemplateCreate,
MailTemplateUpdate,
MailLintHTML,
MailRulesReorder,
}
}
Loading