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
244 changes: 244 additions & 0 deletions shortcuts/mail/mail_rule_reorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

import (
"context"
"fmt"
"io"
"strings"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

// MailRuleReorder is the `+rule-reorder` shortcut: reorders mail inbox rules
// with automatic gap-filling. The backend ReorderUserMailboxRule API requires
// all rule IDs to be provided in the desired order; this shortcut lets users
// specify only the rules they want to reposition and auto-fills the remaining
// IDs by preserving their current relative order in the gaps between anchors.
var MailRuleReorder = common.Shortcut{
Service: "mail",
Command: "+rule-reorder",
Description: "Reorder mail inbox rules; auto-fills missing rule IDs to match backend requirement",
Risk: "write",
Scopes: []string{"mail:user_mailbox.rule:read", "mail:user_mailbox.rule:write"},
AuthTypes: []string{"user", "tenant"},
HasFormat: true,
Flags: []common.Flag{
{Name: "rule-ids", Desc: "Comma-separated rule IDs in desired priority order (partial list allowed)"},
{Name: "mailbox", Desc: "User mailbox address (default: me)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(runtime); err != nil {
return err

Check warning on line 35 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L35

Added line #L35 was not covered by tests
}
ids := runtime.Str("rule-ids")
if ids == "" {
return output.ErrValidation("--rule-ids is required")
}
parts := strings.Split(ids, ",")
seen := make(map[string]bool)
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
return output.ErrValidation("--rule-ids contains empty ID")
}
if seen[p] {
return output.ErrValidation("duplicate rule ID: %s", p)
}
seen[p] = true
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
return common.NewDryRunAPI().
GET(mailboxPath(mailboxID, "rules")).
Desc("List current rules to determine existing order").
POST(mailboxPath(mailboxID, "rules", "reorder")).
Body(map[string]interface{}{"rule_ids": []string{"<auto-filled>"}}).
Desc("Reorder rules with auto-filled complete ID list")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
mailboxID := resolveMailboxID(runtime)
userIDs := parseRuleIDs(runtime.Str("rule-ids"))

// Step 1: List current rules
listData, err := runtime.DoAPIJSON("GET",
mailboxPath(mailboxID, "rules"),
nil, nil)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "list rules failed: %s", err)
}
currentIDs := extractRuleIDs(listData)

// Step 2: Auto-fill missing IDs
reordered, err := reorderRuleIDs(userIDs, currentIDs)
if err != nil {
return err
}

// Step 3: Call reorder API
_, err = runtime.DoAPIJSON("POST",
mailboxPath(mailboxID, "rules", "reorder"),
nil, map[string]interface{}{"rule_ids": reordered})
if err != nil {
return output.Errorf(output.ExitAPI, "api_error", "reorder rules failed: %s", err)

Check warning on line 88 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L88

Added line #L88 was not covered by tests
}

runtime.OutFormat(map[string]interface{}{
"rule_ids": reordered,
}, &output.Meta{Count: len(reordered)}, func(w io.Writer) {
fmt.Fprintf(w, "Reordered %d rules successfully.\n", len(reordered))
})

Check warning on line 95 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L94-L95

Added lines #L94 - L95 were not covered by tests
return nil
},
}

// parseRuleIDs splits a comma-separated string of rule IDs into a slice,
// trimming whitespace from each ID.
func parseRuleIDs(s string) []string {
parts := strings.Split(s, ",")
ids := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
ids = append(ids, p)
}
}
return ids
}

// extractRuleIDs extracts rule IDs from the ListUserMailboxRule response.
// The response has the shape {"items": [{"id": "..."}, ...]} where items are
// ordered by execution priority (sequence).
func extractRuleIDs(data map[string]interface{}) []string {
if data == nil {
return nil
}
items, ok := data["items"].([]interface{})
if !ok {
return nil

Check warning on line 123 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L123

Added line #L123 was not covered by tests
}
ids := make([]string, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue

Check warning on line 129 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L129

Added line #L129 was not covered by tests
}
id, _ := m["id"].(string)
if id != "" {
ids = append(ids, id)
}
}
return ids
}
Comment on lines +117 to +137
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail closed on malformed rule items.

extractRuleIDs silently skips non-object entries and empty/missing id fields. That lets Execute build and POST an incomplete rule_ids list even though this API requires the full ordered set. Please return an error here and abort before the reorder call instead of dropping bad items.

Suggested direction
-func extractRuleIDs(data map[string]interface{}) []string {
+func extractRuleIDs(data map[string]interface{}) ([]string, error) {
 	if data == nil {
-		return nil
+		return nil, output.Errorf(output.ExitAPI, "api_error", "list rules response missing data")
 	}
 	items, ok := data["items"].([]interface{})
 	if !ok {
-		return nil
+		return nil, output.Errorf(output.ExitAPI, "api_error", "list rules response missing items")
 	}
 	ids := make([]string, 0, len(items))
-	for _, item := range items {
+	for i, item := range items {
 		m, ok := item.(map[string]interface{})
 		if !ok {
-			continue
+			return nil, output.Errorf(output.ExitAPI, "api_error", "rule item %d is malformed", i)
 		}
-		id, _ := m["id"].(string)
-		if id != "" {
-			ids = append(ids, id)
+		id, ok := m["id"].(string)
+		if !ok || id == "" {
+			return nil, output.Errorf(output.ExitAPI, "api_error", "rule item %d missing id", i)
 		}
+		ids = append(ids, id)
 	}
-	return ids
+	return ids, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func extractRuleIDs(data map[string]interface{}) []string {
if data == nil {
return nil
}
items, ok := data["items"].([]interface{})
if !ok {
return nil
}
ids := make([]string, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
id, _ := m["id"].(string)
if id != "" {
ids = append(ids, id)
}
}
return ids
}
func extractRuleIDs(data map[string]interface{}) ([]string, error) {
if data == nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "list rules response missing data")
}
items, ok := data["items"].([]interface{})
if !ok {
return nil, output.Errorf(output.ExitAPI, "api_error", "list rules response missing items")
}
ids := make([]string, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, output.Errorf(output.ExitAPI, "api_error", "rule item %d is malformed", i)
}
id, ok := m["id"].(string)
if !ok || id == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "rule item %d missing id", i)
}
ids = append(ids, id)
}
return ids, nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/mail/mail_rule_reorder.go` around lines 117 - 137, The
extractRuleIDs function must fail closed instead of silently skipping malformed
items: change extractRuleIDs to validate every item and return an error when an
entry is not a map[string]interface{} or when an "id" is missing/empty (e.g.
change signature to return ([]string, error)), and update the caller Execute to
check the error and abort before constructing/posting rule_ids; ensure the error
message identifies the offending item index or content so the reorder POST is
never sent with an incomplete rule_ids list.


// reorderRuleIDs implements the gap-filling algorithm.
// userIDs: user-specified rule IDs (partial or full), in desired priority order
// currentIDs: all rule IDs in current priority order (from ListUserMailboxRule)
// Returns: complete rule ID list with user-specified IDs in desired positions
//
// Algorithm: assign each remaining ID to a gap between anchors based on which
// two anchors surround it in the current order. Within each gap, remaining IDs
// preserve their current relative order.
func reorderRuleIDs(userIDs, currentIDs []string) ([]string, error) {
currentSet := make(map[string]bool, len(currentIDs))
for _, id := range currentIDs {
currentSet[id] = true
}
// Validate all user IDs exist
for _, id := range userIDs {
if !currentSet[id] {
return nil, output.ErrValidation("rule ID %s not found in current rules", id)
}
}
// Full list provided
if len(userIDs) == len(currentIDs) {
return userIDs, nil
}

// Build position map and user set
currentPos := make(map[string]int, len(currentIDs))
for i, id := range currentIDs {
currentPos[id] = i
}
userSet := make(map[string]bool, len(userIDs))
for _, id := range userIDs {
userSet[id] = true
}

// Collect remaining IDs in current order
var remainingIDs []string
for _, id := range currentIDs {
if !userSet[id] {
remainingIDs = append(remainingIDs, id)
}
}

// Build anchor position list (sorted by current position)
type anchorPos struct {
id string
pos int
}
var anchors []anchorPos
for _, id := range userIDs {
anchors = append(anchors, anchorPos{id: id, pos: currentPos[id]})
}

// Assign each remaining ID to a gap index:
// gap 0 = before first anchor in result
// gap i = between anchor i-1 and anchor i in result (1 <= i <= len(anchors)-1)
// gap len(anchors) = after last anchor in result
//
// Gap assignment: for each remaining ID r, scan consecutive anchor pairs
// in result order. r belongs to the first pair (in result order) whose
// current-position range encloses r. If no pair encloses r, r goes into
// gap 0 (before all anchors) or gap N (after all anchors).
gapCount := len(anchors) + 1
gaps := make([][]string, gapCount)

for _, r := range remainingIDs {
rPos := currentPos[r]

gapIdx := -1
// Scan consecutive anchor pairs in result order
for i := 0; i < len(anchors)-1; i++ {
posA := anchors[i].pos
posB := anchors[i+1].pos
// r is between this pair if its position lies strictly between
// the two anchors' positions (regardless of which is larger)
minPos, maxPos := posA, posB
if minPos > maxPos {
minPos, maxPos = maxPos, minPos
}
if rPos > minPos && rPos < maxPos {
gapIdx = i + 1 // gap between anchor i and anchor i+1
break
}
}
if gapIdx == -1 {
// Not enclosed by any consecutive pair — must be before first
// or after last anchor in current order
if rPos < anchors[0].pos || (len(anchors) == 1 && rPos != anchors[0].pos) {
gapIdx = 0 // before first anchor
} else {
gapIdx = len(anchors) // after last anchor

Check warning on line 228 in shortcuts/mail/mail_rule_reorder.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_rule_reorder.go#L228

Added line #L228 was not covered by tests
}
}

gaps[gapIdx] = append(gaps[gapIdx], r)
}

// Assemble result: gap0 + anchor0 + gap1 + anchor1 + ... + gapN
var result []string
for i := 0; i < len(anchors); i++ {
result = append(result, gaps[i]...)
result = append(result, anchors[i].id)
}
result = append(result, gaps[len(anchors)]...)

return result, nil
}
Loading
Loading