From 678ba3edf2f0ddccea94d18349774c6fb1a75fd3 Mon Sep 17 00:00:00 2001 From: jqmseu <15628786+jqmseu@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:22:13 +0800 Subject: [PATCH 1/2] feat: auto-complete mail rule reorder ids --- shortcuts/mail/mail_rule_reorder.go | 241 ++++++++++++++++++ shortcuts/mail/mail_rule_reorder_test.go | 206 +++++++++++++++ shortcuts/mail/shortcuts.go | 1 + skills/lark-mail/SKILL.md | 3 +- .../references/lark-mail-rule-reorder.md | 45 ++++ tests/cli_e2e/mail/coverage.md | 2 +- 6 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 shortcuts/mail/mail_rule_reorder.go create mode 100644 shortcuts/mail/mail_rule_reorder_test.go create mode 100644 skills/lark-mail/references/lark-mail-rule-reorder.md diff --git a/shortcuts/mail/mail_rule_reorder.go b/shortcuts/mail/mail_rule_reorder.go new file mode 100644 index 000000000..e04cc00ca --- /dev/null +++ b/shortcuts/mail/mail_rule_reorder.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +type mailboxRule struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + IsEnable bool `json:"is_enable,omitempty"` +} + +type ruleReorderPreview struct { + Mailbox string `json:"mailbox"` + SpecifiedIDs []string `json:"specified_rule_ids"` + Before []mailboxRule `json:"before"` + After []mailboxRule `json:"after"` + CompletedIDs []string `json:"completed_rule_ids"` + DryRun bool `json:"dry_run"` +} + +var MailRuleReorder = common.Shortcut{ + Service: "mail", + Command: "+rule-reorder", + Description: "Reorder inbox rules. Accepts a partial --rule-ids list, fetches the full current order, and appends omitted rules automatically before calling reorder.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.rule:read", "mail:user_mailbox.rule:write"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "rule-ids", Desc: "Required. Comma or whitespace separated rule IDs. Partial input is allowed; omitted rules keep their relative order and are appended automatically.", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateBotMailboxNotMe(runtime); err != nil { + return err + } + _, err := parseRuleIDsInput(runtime.Str("rule-ids")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + ruleIDsInput := runtime.Str("rule-ids") + api := common.NewDryRunAPI(). + Desc("Fetch current mailbox rules, complete omitted rule IDs locally, then reorder with the full list"). + GET(mailboxPath(mailboxID, "rules")). + POST(mailboxPath(mailboxID, "rules", "reorder")). + Body(map[string]interface{}{"rule_ids": []string{""}}) + if ids, err := parseRuleIDsInput(ruleIDsInput); err == nil { + api = api.Set("specified_rule_ids", ids) + } else { + api = api.Set("rule_ids_error", err.Error()) + } + return api + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := resolveMailboxID(runtime) + ruleIDs, err := parseRuleIDsInput(runtime.Str("rule-ids")) + if err != nil { + return err + } + + rules, err := listMailboxRules(runtime, mailboxID) + if err != nil { + return mailDecorateProblemMessage(err, "failed to list mailbox rules") + } + if len(rules) == 0 { + return mailValidationError("no mailbox rules found to reorder") + } + if len(rules) == 1 { + preview := buildRuleReorderPreview(mailboxID, ruleIDs, rules, rules, runtime.Bool("dry-run")) + runtime.Out(preview, nil) + return nil + } + + completedIDs, reorderedRules, err := buildCompletedRuleOrder(ruleIDs, rules) + if err != nil { + return err + } + + preview := buildRuleReorderPreview(mailboxID, ruleIDs, rules, reorderedRules, runtime.Bool("dry-run")) + if runtime.Bool("dry-run") { + runtime.Out(preview, nil) + return nil + } + + _, err = doJSONAPI(runtime, &larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: mailboxPath(mailboxID, "rules", "reorder"), + Body: map[string]interface{}{ + "rule_ids": completedIDs, + }, + }, "failed to reorder mailbox rules") + if err != nil { + return err + } + + runtime.Out(preview, nil) + return nil + }, +} + +func parseRuleIDsInput(raw string) ([]string, error) { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\t' || r == ' ' + }) + if len(fields) == 0 { + return nil, mailValidationParamError("rule-ids", "--rule-ids is required") + } + + out := make([]string, 0, len(fields)) + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + id := strings.TrimSpace(field) + if id == "" { + continue + } + if _, err := strconv.ParseInt(id, 10, 64); err != nil { + return nil, mailValidationParamError("rule-ids", "--rule-ids must contain numeric rule IDs only: %q", id).WithCause(err) + } + if _, ok := seen[id]; ok { + return nil, mailValidationParamError("rule-ids", "--rule-ids contains duplicate rule ID %q", id) + } + seen[id] = struct{}{} + out = append(out, id) + } + if len(out) == 0 { + return nil, mailValidationParamError("rule-ids", "--rule-ids is required") + } + return out, nil +} + +func listMailboxRules(runtime *common.RuntimeContext, mailboxID string) ([]mailboxRule, error) { + data, err := doJSONAPI(runtime, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: mailboxPath(mailboxID, "rules"), + }, "failed to list mailbox rules") + if err != nil { + return nil, err + } + + items, _ := data["items"].([]interface{}) + if len(items) == 0 { + if nested, ok := data["data"].(map[string]interface{}); ok { + items, _ = nested["items"].([]interface{}) + } + } + + rules := make([]mailboxRule, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + id := anyToString(m["id"]) + if id == "" { + continue + } + rules = append(rules, mailboxRule{ + ID: id, + Name: anyToString(m["name"]), + IsEnable: boolVal(m["is_enable"]), + }) + } + return rules, nil +} + +func buildCompletedRuleOrder(specifiedIDs []string, currentRules []mailboxRule) ([]string, []mailboxRule, error) { + indexByID := make(map[string]int, len(currentRules)) + for i, rule := range currentRules { + indexByID[rule.ID] = i + } + + completed := make([]string, 0, len(currentRules)) + reordered := make([]mailboxRule, 0, len(currentRules)) + seen := make(map[string]struct{}, len(currentRules)) + + for _, id := range specifiedIDs { + idx, ok := indexByID[id] + if !ok { + return nil, nil, mailValidationParamError("rule-ids", "rule %q not found in current mailbox rules", id) + } + completed = append(completed, id) + reordered = append(reordered, currentRules[idx]) + seen[id] = struct{}{} + } + for _, rule := range currentRules { + if _, ok := seen[rule.ID]; ok { + continue + } + completed = append(completed, rule.ID) + reordered = append(reordered, rule) + } + return completed, reordered, nil +} + +func buildRuleReorderPreview(mailboxID string, specifiedIDs []string, before, after []mailboxRule, dryRun bool) ruleReorderPreview { + completedIDs := make([]string, 0, len(after)) + for _, rule := range after { + completedIDs = append(completedIDs, rule.ID) + } + return ruleReorderPreview{ + Mailbox: mailboxID, + SpecifiedIDs: slices.Clone(specifiedIDs), + Before: slices.Clone(before), + After: slices.Clone(after), + CompletedIDs: completedIDs, + DryRun: dryRun, + } +} + +func anyToString(v interface{}) string { + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + case json.Number: + return x.String() + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strings.TrimSpace(fmt.Sprintf("%v", x)) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + default: + return "" + } +} diff --git a/shortcuts/mail/mail_rule_reorder_test.go b/shortcuts/mail/mail_rule_reorder_test.go new file mode 100644 index 000000000..15019f38d --- /dev/null +++ b/shortcuts/mail/mail_rule_reorder_test.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestParseRuleIDsInput(t *testing.T) { + got, err := parseRuleIDsInput("3, 1 9") + if err != nil { + t.Fatalf("parseRuleIDsInput returned error: %v", err) + } + want := []string{"3", "1", "9"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("parseRuleIDsInput = %v, want %v", got, want) + } +} + +func TestParseRuleIDsInputRejectsDuplicate(t *testing.T) { + _, err := parseRuleIDsInput("3,1,3") + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate validation error, got %v", err) + } +} + +func TestBuildCompletedRuleOrder(t *testing.T) { + current := []mailboxRule{ + {ID: "1", Name: "first"}, + {ID: "2", Name: "second"}, + {ID: "3", Name: "third"}, + {ID: "4", Name: "fourth"}, + } + ids, reordered, err := buildCompletedRuleOrder([]string{"3", "1"}, current) + if err != nil { + t.Fatalf("buildCompletedRuleOrder returned error: %v", err) + } + if got, want := strings.Join(ids, ","), "3,1,2,4"; got != want { + t.Fatalf("completed ids = %s, want %s", got, want) + } + if got, want := reordered[0].ID+","+reordered[1].ID+","+reordered[2].ID+","+reordered[3].ID, "3,1,2,4"; got != want { + t.Fatalf("reordered ids = %s, want %s", got, want) + } +} + +func TestMailRuleReorderDryRunListsAndReorders(t *testing.T) { + runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ + "mailbox": "me", + "rule-ids": "3,1", + }) + apis := dryRunAPIsForMailRuleReorderTest(t, MailRuleReorder.DryRun(context.Background(), runtime)) + if len(apis) != 2 { + t.Fatalf("expected 2 API calls in dry-run, got %d", len(apis)) + } + if apis[0].Method != "GET" || apis[0].URL != mailboxPath("me", "rules") { + t.Fatalf("first dry-run API = %+v, want GET %s", apis[0], mailboxPath("me", "rules")) + } + if apis[1].Method != "POST" || apis[1].URL != mailboxPath("me", "rules", "reorder") { + t.Fatalf("second dry-run API = %+v, want POST %s", apis[1], mailboxPath("me", "rules", "reorder")) + } +} + +func TestMailRuleReorderExecuteCompletesMissingIDs(t *testing.T) { + f, stdout, _, reg := mailRuleReorderTestFactory(t) + registerMailboxRulesListStub(reg, "me", []map[string]interface{}{ + {"id": float64(1), "name": "rule-1", "is_enable": true}, + {"id": float64(2), "name": "rule-2", "is_enable": true}, + {"id": float64(3), "name": "rule-3", "is_enable": false}, + {"id": float64(4), "name": "rule-4", "is_enable": true}, + }) + reorderStub := registerMailboxRulesReorderStub(reg, "me") + + if err := runMountedMailShortcut(t, MailRuleReorder, []string{"+rule-reorder", "--rule-ids", "3,1"}, f, stdout); err != nil { + t.Fatalf("runMountedMailShortcut returned error: %v", err) + } + + var body struct { + RuleIDs []string `json:"rule_ids"` + } + if err := json.Unmarshal(reorderStub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal reorder body: %v", err) + } + if got, want := strings.Join(body.RuleIDs, ","), "3,1,2,4"; got != want { + t.Fatalf("reorder body rule_ids = %s, want %s", got, want) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if got, want := data["mailbox"], "me"; got != want { + t.Fatalf("mailbox = %v, want %v", got, want) + } + if got := data["dry_run"]; got != false { + t.Fatalf("dry_run = %v, want false", got) + } + after, ok := data["after"].([]interface{}) + if !ok || len(after) != 4 { + t.Fatalf("after = %#v, want 4 entries", data["after"]) + } +} + +func TestMailRuleReorderExecuteRejectsUnknownRule(t *testing.T) { + f, stdout, _, reg := mailRuleReorderTestFactory(t) + registerMailboxRulesListStub(reg, "me", []map[string]interface{}{ + {"id": "1", "name": "rule-1"}, + {"id": "2", "name": "rule-2"}, + }) + err := runMountedMailShortcut(t, MailRuleReorder, []string{"+rule-reorder", "--rule-ids", "7,1"}, f, stdout) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not-found validation error, got %v", err) + } +} + +type ruleReorderDryRunPayload struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` +} + +func runtimeForMailRuleReorderDryRun(t *testing.T, values map[string]string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, fl := range MailRuleReorder.Flags { + switch fl.Type { + case "bool": + cmd.Flags().Bool(fl.Name, fl.Default == "true", "") + case "int": + cmd.Flags().Int(fl.Name, 0, "") + default: + cmd.Flags().String(fl.Name, fl.Default, "") + } + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("parse flags failed: %v", err) + } + for k, v := range values { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set flag --%s failed: %v", k, err) + } + } + return &common.RuntimeContext{ + Cmd: cmd, + Config: &core.CliConfig{AppID: "cli_test_app"}, + } +} + +func dryRunAPIsForMailRuleReorderTest(t *testing.T, dry *common.DryRunAPI) []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` +} { + t.Helper() + var payload ruleReorderDryRunPayload + b, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + if err := json.Unmarshal(b, &payload); err != nil { + t.Fatalf("unmarshal dry-run failed: %v\njson=%s", err, string(b)) + } + return payload.API +} + +func mailRuleReorderTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + f, stdout, stderr, reg := mailShortcutTestFactory(t) + return f, stdout, stderr, reg +} + +func registerMailboxRulesListStub(reg *httpmock.Registry, mailbox string, items []map[string]interface{}) { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: mailboxPath(mailbox, "rules"), + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": items, + }, + }, + }) +} + +func registerMailboxRulesReorderStub(reg *httpmock.Registry, mailbox string) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: "POST", + URL: mailboxPath(mailbox, "rules", "reorder"), + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + return stub +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 2df01a6f3..18b0c014c 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut { MailMessages, MailThread, MailTriage, + MailRuleReorder, MailWatch, MailReply, MailReplyAll, diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index 06ee7fc0b..57ed2e0d6 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -119,7 +119,7 @@ metadata: - 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。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) +- 收信规则:创建、验证、删除自动处理收到邮件的规则。部分 `rule_ids` 的重排序优先使用 `+rule-reorder`。ref: [lark-mail-rules](references/lark-mail-rules.md)、[lark-mail-rule-reorder](references/lark-mail-rule-reorder.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) @@ -269,6 +269,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) |----------|------| | [`+message`](references/lark-mail-message.md) | Use only when reading full content for one email by one message ID. For multiple message IDs, use `mail +messages`; do not loop `mail +message`. | | [`+messages`](references/lark-mail-messages.md) | Use when reading full content for multiple emails by message ID. Accepts comma-separated message IDs; CLI handles more than 20 IDs in batches and merges output. | +| [`+rule-reorder`](references/lark-mail-rule-reorder.md) | Reorder inbox rules from a partial rule ID list. Fetches current rules first, auto-completes omitted IDs, then calls reorder with the full ordered list. | | [`+thread`](references/lark-mail-thread.md) | Use when querying a full mail conversation/thread by thread ID. Returns all messages in chronological order, including replies and drafts, with body content and attachments metadata, including inline images. | | [`+triage`](references/lark-mail-triage.md) | List mail summaries (date/from/subject/message_id). Use --query for full-text search, --filter for exact-match conditions. | | [`+watch`](references/lark-mail-watch.md) | Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output. | diff --git a/skills/lark-mail/references/lark-mail-rule-reorder.md b/skills/lark-mail/references/lark-mail-rule-reorder.md new file mode 100644 index 000000000..ef02dccf9 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-rule-reorder.md @@ -0,0 +1,45 @@ +# 收信规则重排序 + +当后端 `user_mailbox.rules reorder` 要求传入**全部**规则 ID、但调用方只知道一部分规则 ID 时,优先使用 `+rule-reorder`。该 Shortcut 会先读取当前规则顺序,再把未显式传入的规则按原相对顺序补齐后发起重排。 + +```bash +lark-cli mail +rule-reorder --as user \ + --mailbox me \ + --rule-ids 345,123 +``` + +上面的调用会先读取当前规则列表;如果当前顺序是 `[123,234,345,456]`,最终实际提交的 `rule_ids` 会是 `[345,123,234,456]`。 + +## Dry run + +```bash +lark-cli mail +rule-reorder --as user \ + --mailbox me \ + --rule-ids 345,123 \ + --dry-run +``` + +`--dry-run` 只展示两步计划: + +1. `GET /user_mailboxes/:id/rules` +2. `POST /user_mailboxes/:id/rules/reorder` + +并在输出里返回: + +- `specified_rule_ids` +- `before` +- `after` +- `completed_rule_ids` + +## 参数约束 + +- `--rule-ids` 必填 +- 支持逗号或空白分隔:`1,2,3`、`1 2 3` +- 只接受数字 ID +- 不允许重复 ID +- 如果输入的某个规则 ID 不在当前邮箱规则列表中,命令会直接返回校验错误,不会调用 reorder + +## 何时不要用 + +- 如果你已经有完整且确认无误的全量 `rule_ids`,也可以直接调用原生 `mail user_mailbox.rules reorder` +- 如果你需要创建、删除或更新规则,仍然使用 `user_mailbox.rules create|delete|update` diff --git a/tests/cli_e2e/mail/coverage.md b/tests/cli_e2e/mail/coverage.md index 2910238f2..dabf5c7b3 100644 --- a/tests/cli_e2e/mail/coverage.md +++ b/tests/cli_e2e/mail/coverage.md @@ -65,7 +65,7 @@ | ✕ | mail user_mailbox.rules create | api | | none | rule lifecycle left for a dedicated workflow | | ✕ | mail user_mailbox.rules delete | api | | none | rule lifecycle left for a dedicated workflow | | ✕ | mail user_mailbox.rules list | api | | none | rule lifecycle left for a dedicated workflow | -| ✕ | mail user_mailbox.rules reorder | api | | none | rule lifecycle left for a dedicated workflow | +| ✓ | mail +rule-reorder | shortcut | mail_rule_reorder_test.go::TestMailRuleReorderExecuteCompletesMissingIDs | partial `--rule-ids` input | shortcut auto-completes omitted rule IDs before calling reorder | | ✕ | mail user_mailbox.rules update | api | | none | rule lifecycle left for a dedicated workflow | | ✕ | mail user_mailbox.sent_messages get_recall_detail | api | | none | requires a recallable sent message | | ✕ | mail user_mailbox.sent_messages recall | api | | none | requires a delivered sent message within recall window | From 333413258e880c3de7e7f96983adf620a452c009 Mon Sep 17 00:00:00 2001 From: jqmseu <15628786+jqmseu@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:02:34 +0800 Subject: [PATCH 2/2] fix: support move-based mail rule reorder --- shortcuts/mail/mail_rule_reorder.go | 219 +++++++++++++++++++++-- shortcuts/mail/mail_rule_reorder_test.go | 186 +++++++++++++++++++ 2 files changed, 387 insertions(+), 18 deletions(-) diff --git a/shortcuts/mail/mail_rule_reorder.go b/shortcuts/mail/mail_rule_reorder.go index e04cc00ca..51b25aca1 100644 --- a/shortcuts/mail/mail_rule_reorder.go +++ b/shortcuts/mail/mail_rule_reorder.go @@ -25,48 +25,78 @@ type mailboxRule struct { type ruleReorderPreview struct { Mailbox string `json:"mailbox"` SpecifiedIDs []string `json:"specified_rule_ids"` + Move string `json:"move,omitempty"` + BeforeRuleID string `json:"before_rule_id,omitempty"` + AfterRuleID string `json:"after_rule_id,omitempty"` + ToTop bool `json:"to_top,omitempty"` Before []mailboxRule `json:"before"` After []mailboxRule `json:"after"` CompletedIDs []string `json:"completed_rule_ids"` DryRun bool `json:"dry_run"` } +type ruleReorderInput struct { + SpecifiedIDs []string + MoveRuleID string + BeforeRuleID string + AfterRuleID string + ToTop bool +} + var MailRuleReorder = common.Shortcut{ Service: "mail", Command: "+rule-reorder", - Description: "Reorder inbox rules. Accepts a partial --rule-ids list, fetches the full current order, and appends omitted rules automatically before calling reorder.", + Description: "Reorder inbox rules. Accepts either a partial --rule-ids list or --move with --before/--after/--to-top, then fetches the full current order and completes the final reorder request locally.", Risk: "write", Scopes: []string{"mail:user_mailbox.rule:read", "mail:user_mailbox.rule:write"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, - {Name: "rule-ids", Desc: "Required. Comma or whitespace separated rule IDs. Partial input is allowed; omitted rules keep their relative order and are appended automatically.", Required: true}, + {Name: "rule-ids", Desc: "Comma or whitespace separated rule IDs. Partial input is allowed; omitted rules keep their relative order and are appended automatically."}, + {Name: "move", Desc: "Rule ID to move. Must be used with exactly one of --before, --after, or --to-top."}, + {Name: "before", Desc: "Anchor rule ID. Insert --move before this rule."}, + {Name: "after", Desc: "Anchor rule ID. Insert --move after this rule."}, + {Name: "to-top", Type: "bool", Desc: "Move the rule to the top of the current order."}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if err := validateBotMailboxNotMe(runtime); err != nil { return err } - _, err := parseRuleIDsInput(runtime.Str("rule-ids")) + _, err := parseRuleReorderInput(runtime) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { mailboxID := resolveMailboxID(runtime) - ruleIDsInput := runtime.Str("rule-ids") + input, err := parseRuleReorderInput(runtime) api := common.NewDryRunAPI(). - Desc("Fetch current mailbox rules, complete omitted rule IDs locally, then reorder with the full list"). + Desc("Fetch current mailbox rules, compute the full reorder plan locally, then reorder with the completed list"). GET(mailboxPath(mailboxID, "rules")). POST(mailboxPath(mailboxID, "rules", "reorder")). Body(map[string]interface{}{"rule_ids": []string{""}}) - if ids, err := parseRuleIDsInput(ruleIDsInput); err == nil { - api = api.Set("specified_rule_ids", ids) + if err == nil { + if len(input.SpecifiedIDs) > 0 { + api = api.Set("specified_rule_ids", input.SpecifiedIDs) + } + if input.MoveRuleID != "" { + api = api.Set("move", input.MoveRuleID) + } + if input.BeforeRuleID != "" { + api = api.Set("before", input.BeforeRuleID) + } + if input.AfterRuleID != "" { + api = api.Set("after", input.AfterRuleID) + } + if input.ToTop { + api = api.Set("to_top", true) + } } else { - api = api.Set("rule_ids_error", err.Error()) + api = api.Set("reorder_input_error", err.Error()) } return api }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { mailboxID := resolveMailboxID(runtime) - ruleIDs, err := parseRuleIDsInput(runtime.Str("rule-ids")) + input, err := parseRuleReorderInput(runtime) if err != nil { return err } @@ -78,18 +108,13 @@ var MailRuleReorder = common.Shortcut{ if len(rules) == 0 { return mailValidationError("no mailbox rules found to reorder") } - if len(rules) == 1 { - preview := buildRuleReorderPreview(mailboxID, ruleIDs, rules, rules, runtime.Bool("dry-run")) - runtime.Out(preview, nil) - return nil - } - completedIDs, reorderedRules, err := buildCompletedRuleOrder(ruleIDs, rules) + completedIDs, reorderedRules, err := buildRuleReorderPlan(input, rules) if err != nil { return err } - preview := buildRuleReorderPreview(mailboxID, ruleIDs, rules, reorderedRules, runtime.Bool("dry-run")) + preview := buildRuleReorderPreview(mailboxID, input, rules, reorderedRules, runtime.Bool("dry-run")) if runtime.Bool("dry-run") { runtime.Out(preview, nil) return nil @@ -176,6 +201,79 @@ func listMailboxRules(runtime *common.RuntimeContext, mailboxID string) ([]mailb return rules, nil } +func parseRuleReorderInput(runtime *common.RuntimeContext) (ruleReorderInput, error) { + ruleIDsRaw := strings.TrimSpace(runtime.Str("rule-ids")) + moveRaw := strings.TrimSpace(runtime.Str("move")) + beforeRaw := strings.TrimSpace(runtime.Str("before")) + afterRaw := strings.TrimSpace(runtime.Str("after")) + toTop := runtime.Bool("to-top") + + hasRuleIDs := ruleIDsRaw != "" + hasMove := moveRaw != "" + hasPlacement := beforeRaw != "" || afterRaw != "" || toTop + + switch { + case hasRuleIDs && hasMove: + return ruleReorderInput{}, mailValidationError("--rule-ids and --move are mutually exclusive; choose exactly one input mode") + case !hasRuleIDs && !hasMove: + return ruleReorderInput{}, mailValidationError("either --rule-ids or --move is required") + case hasRuleIDs: + if hasPlacement { + return ruleReorderInput{}, mailValidationError("--before, --after, and --to-top require --move") + } + ids, err := parseRuleIDsInput(ruleIDsRaw) + if err != nil { + return ruleReorderInput{}, err + } + return ruleReorderInput{SpecifiedIDs: ids}, nil + default: + if !hasPlacement { + return ruleReorderInput{}, mailValidationError("--move requires exactly one of --before, --after, or --to-top") + } + placementCount := 0 + if beforeRaw != "" { + placementCount++ + } + if afterRaw != "" { + placementCount++ + } + if toTop { + placementCount++ + } + if placementCount != 1 { + return ruleReorderInput{}, mailValidationError("--move requires exactly one of --before, --after, or --to-top") + } + + moveID, err := parseSingleRuleID("move", moveRaw) + if err != nil { + return ruleReorderInput{}, err + } + input := ruleReorderInput{ + MoveRuleID: moveID, + ToTop: toTop, + } + if beforeRaw != "" { + input.BeforeRuleID, err = parseSingleRuleID("before", beforeRaw) + if err != nil { + return ruleReorderInput{}, err + } + } + if afterRaw != "" { + input.AfterRuleID, err = parseSingleRuleID("after", afterRaw) + if err != nil { + return ruleReorderInput{}, err + } + } + if input.MoveRuleID == input.BeforeRuleID && input.BeforeRuleID != "" { + return ruleReorderInput{}, mailValidationParamError("before", "--before cannot reference the same rule as --move") + } + if input.MoveRuleID == input.AfterRuleID && input.AfterRuleID != "" { + return ruleReorderInput{}, mailValidationParamError("after", "--after cannot reference the same rule as --move") + } + return input, nil + } +} + func buildCompletedRuleOrder(specifiedIDs []string, currentRules []mailboxRule) ([]string, []mailboxRule, error) { indexByID := make(map[string]int, len(currentRules)) for i, rule := range currentRules { @@ -205,14 +303,99 @@ func buildCompletedRuleOrder(specifiedIDs []string, currentRules []mailboxRule) return completed, reordered, nil } -func buildRuleReorderPreview(mailboxID string, specifiedIDs []string, before, after []mailboxRule, dryRun bool) ruleReorderPreview { +func parseSingleRuleID(flagName, raw string) (string, error) { + id := strings.TrimSpace(raw) + if id == "" { + return "", mailValidationParamError("--"+flagName, "--%s is required", flagName) + } + if strings.ContainsAny(id, ", \n\t") { + return "", mailValidationParamError("--"+flagName, "--%s accepts exactly one numeric rule ID", flagName) + } + if _, err := strconv.ParseInt(id, 10, 64); err != nil { + return "", mailValidationParamError("--"+flagName, "--%s must be a numeric rule ID", flagName).WithCause(err) + } + return id, nil +} + +func buildRuleReorderPlan(input ruleReorderInput, currentRules []mailboxRule) ([]string, []mailboxRule, error) { + if len(input.SpecifiedIDs) > 0 { + return buildCompletedRuleOrder(input.SpecifiedIDs, currentRules) + } + return buildMoveRuleOrder(input, currentRules) +} + +func buildMoveRuleOrder(input ruleReorderInput, currentRules []mailboxRule) ([]string, []mailboxRule, error) { + indexByID := make(map[string]int, len(currentRules)) + for i, rule := range currentRules { + indexByID[rule.ID] = i + } + + moveIdx, ok := indexByID[input.MoveRuleID] + if !ok { + return nil, nil, mailValidationParamError("--move", "rule %q not found in current mailbox rules", input.MoveRuleID) + } + + moveRule := currentRules[moveIdx] + withoutMove := make([]mailboxRule, 0, len(currentRules)-1) + for _, rule := range currentRules { + if rule.ID == input.MoveRuleID { + continue + } + withoutMove = append(withoutMove, rule) + } + + insertAt := 0 + switch { + case input.ToTop: + insertAt = 0 + case input.BeforeRuleID != "": + insertAt = indexOfRuleID(withoutMove, input.BeforeRuleID) + if insertAt < 0 { + return nil, nil, mailValidationParamError("--before", "rule %q not found in current mailbox rules", input.BeforeRuleID) + } + case input.AfterRuleID != "": + insertAt = indexOfRuleID(withoutMove, input.AfterRuleID) + if insertAt < 0 { + return nil, nil, mailValidationParamError("--after", "rule %q not found in current mailbox rules", input.AfterRuleID) + } + insertAt++ + default: + return nil, nil, mailValidationError("--move requires exactly one of --before, --after, or --to-top") + } + + reordered := make([]mailboxRule, 0, len(currentRules)) + reordered = append(reordered, withoutMove[:insertAt]...) + reordered = append(reordered, moveRule) + reordered = append(reordered, withoutMove[insertAt:]...) + + completed := make([]string, 0, len(reordered)) + for _, rule := range reordered { + completed = append(completed, rule.ID) + } + return completed, reordered, nil +} + +func indexOfRuleID(rules []mailboxRule, id string) int { + for i, rule := range rules { + if rule.ID == id { + return i + } + } + return -1 +} + +func buildRuleReorderPreview(mailboxID string, input ruleReorderInput, before, after []mailboxRule, dryRun bool) ruleReorderPreview { completedIDs := make([]string, 0, len(after)) for _, rule := range after { completedIDs = append(completedIDs, rule.ID) } return ruleReorderPreview{ Mailbox: mailboxID, - SpecifiedIDs: slices.Clone(specifiedIDs), + SpecifiedIDs: slices.Clone(input.SpecifiedIDs), + Move: input.MoveRuleID, + BeforeRuleID: input.BeforeRuleID, + AfterRuleID: input.AfterRuleID, + ToTop: input.ToTop, Before: slices.Clone(before), After: slices.Clone(after), CompletedIDs: completedIDs, diff --git a/shortcuts/mail/mail_rule_reorder_test.go b/shortcuts/mail/mail_rule_reorder_test.go index 15019f38d..55ac83127 100644 --- a/shortcuts/mail/mail_rule_reorder_test.go +++ b/shortcuts/mail/mail_rule_reorder_test.go @@ -36,6 +36,43 @@ func TestParseRuleIDsInputRejectsDuplicate(t *testing.T) { } } +func TestParseRuleReorderInputMoveBefore(t *testing.T) { + runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ + "mailbox": "me", + "move": "4", + "before": "2", + }) + input, err := parseRuleReorderInput(runtime) + if err != nil { + t.Fatalf("parseRuleReorderInput returned error: %v", err) + } + if input.MoveRuleID != "4" || input.BeforeRuleID != "2" || input.AfterRuleID != "" || input.ToTop { + t.Fatalf("unexpected input: %#v", input) + } +} + +func TestParseRuleReorderInputRejectsMixedModes(t *testing.T) { + runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ + "rule-ids": "1,2", + "move": "3", + "before": "2", + }) + _, err := parseRuleReorderInput(runtime) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually exclusive validation error, got %v", err) + } +} + +func TestParseRuleReorderInputRejectsMissingPlacement(t *testing.T) { + runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ + "move": "3", + }) + _, err := parseRuleReorderInput(runtime) + if err == nil || !strings.Contains(err.Error(), "exactly one of --before, --after, or --to-top") { + t.Fatalf("expected placement validation error, got %v", err) + } +} + func TestBuildCompletedRuleOrder(t *testing.T) { current := []mailboxRule{ {ID: "1", Name: "first"}, @@ -55,6 +92,80 @@ func TestBuildCompletedRuleOrder(t *testing.T) { } } +func TestBuildMoveRuleOrderBefore(t *testing.T) { + current := []mailboxRule{ + {ID: "1", Name: "first"}, + {ID: "2", Name: "second"}, + {ID: "3", Name: "third"}, + {ID: "4", Name: "fourth"}, + } + ids, reordered, err := buildMoveRuleOrder(ruleReorderInput{ + MoveRuleID: "4", + BeforeRuleID: "2", + }, current) + if err != nil { + t.Fatalf("buildMoveRuleOrder returned error: %v", err) + } + if got, want := strings.Join(ids, ","), "1,4,2,3"; got != want { + t.Fatalf("completed ids = %s, want %s", got, want) + } + if got, want := reordered[0].ID+","+reordered[1].ID+","+reordered[2].ID+","+reordered[3].ID, "1,4,2,3"; got != want { + t.Fatalf("reordered ids = %s, want %s", got, want) + } +} + +func TestBuildMoveRuleOrderAfter(t *testing.T) { + current := []mailboxRule{ + {ID: "1", Name: "first"}, + {ID: "2", Name: "second"}, + {ID: "3", Name: "third"}, + {ID: "4", Name: "fourth"}, + } + ids, _, err := buildMoveRuleOrder(ruleReorderInput{ + MoveRuleID: "1", + AfterRuleID: "3", + }, current) + if err != nil { + t.Fatalf("buildMoveRuleOrder returned error: %v", err) + } + if got, want := strings.Join(ids, ","), "2,3,1,4"; got != want { + t.Fatalf("completed ids = %s, want %s", got, want) + } +} + +func TestBuildMoveRuleOrderToTop(t *testing.T) { + current := []mailboxRule{ + {ID: "1", Name: "first"}, + {ID: "2", Name: "second"}, + {ID: "3", Name: "third"}, + {ID: "4", Name: "fourth"}, + } + ids, _, err := buildMoveRuleOrder(ruleReorderInput{ + MoveRuleID: "3", + ToTop: true, + }, current) + if err != nil { + t.Fatalf("buildMoveRuleOrder returned error: %v", err) + } + if got, want := strings.Join(ids, ","), "3,1,2,4"; got != want { + t.Fatalf("completed ids = %s, want %s", got, want) + } +} + +func TestBuildMoveRuleOrderRejectsUnknownAnchor(t *testing.T) { + current := []mailboxRule{ + {ID: "1", Name: "first"}, + {ID: "2", Name: "second"}, + } + _, _, err := buildMoveRuleOrder(ruleReorderInput{ + MoveRuleID: "1", + BeforeRuleID: "7", + }, current) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected not-found validation error, got %v", err) + } +} + func TestMailRuleReorderDryRunListsAndReorders(t *testing.T) { runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ "mailbox": "me", @@ -72,6 +183,18 @@ func TestMailRuleReorderDryRunListsAndReorders(t *testing.T) { } } +func TestMailRuleReorderDryRunMoveBeforeIncludesFlags(t *testing.T) { + runtime := runtimeForMailRuleReorderDryRun(t, map[string]string{ + "mailbox": "me", + "move": "4", + "before": "2", + }) + apis := dryRunAPIsForMailRuleReorderTest(t, MailRuleReorder.DryRun(context.Background(), runtime)) + if len(apis) != 2 { + t.Fatalf("expected 2 API calls in dry-run, got %d", len(apis)) + } +} + func TestMailRuleReorderExecuteCompletesMissingIDs(t *testing.T) { f, stdout, _, reg := mailRuleReorderTestFactory(t) registerMailboxRulesListStub(reg, "me", []map[string]interface{}{ @@ -121,6 +244,69 @@ func TestMailRuleReorderExecuteRejectsUnknownRule(t *testing.T) { } } +func TestMailRuleReorderExecuteMoveBefore(t *testing.T) { + f, stdout, _, reg := mailRuleReorderTestFactory(t) + registerMailboxRulesListStub(reg, "me", []map[string]interface{}{ + {"id": float64(1), "name": "rule-1", "is_enable": true}, + {"id": float64(2), "name": "rule-2", "is_enable": true}, + {"id": float64(3), "name": "rule-3", "is_enable": false}, + {"id": float64(4), "name": "rule-4", "is_enable": true}, + }) + reorderStub := registerMailboxRulesReorderStub(reg, "me") + + if err := runMountedMailShortcut(t, MailRuleReorder, []string{"+rule-reorder", "--move", "4", "--before", "2"}, f, stdout); err != nil { + t.Fatalf("runMountedMailShortcut returned error: %v", err) + } + + var body struct { + RuleIDs []string `json:"rule_ids"` + } + if err := json.Unmarshal(reorderStub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal reorder body: %v", err) + } + if got, want := strings.Join(body.RuleIDs, ","), "1,4,2,3"; got != want { + t.Fatalf("reorder body rule_ids = %s, want %s", got, want) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if got, want := data["move"], "4"; got != want { + t.Fatalf("move = %v, want %v", got, want) + } + if got, want := data["before_rule_id"], "2"; got != want { + t.Fatalf("before_rule_id = %v, want %v", got, want) + } +} + +func TestMailRuleReorderExecuteMoveToTop(t *testing.T) { + f, stdout, _, reg := mailRuleReorderTestFactory(t) + registerMailboxRulesListStub(reg, "me", []map[string]interface{}{ + {"id": float64(1), "name": "rule-1", "is_enable": true}, + {"id": float64(2), "name": "rule-2", "is_enable": true}, + {"id": float64(3), "name": "rule-3", "is_enable": false}, + {"id": float64(4), "name": "rule-4", "is_enable": true}, + }) + reorderStub := registerMailboxRulesReorderStub(reg, "me") + + if err := runMountedMailShortcut(t, MailRuleReorder, []string{"+rule-reorder", "--move", "3", "--to-top"}, f, stdout); err != nil { + t.Fatalf("runMountedMailShortcut returned error: %v", err) + } + + var body struct { + RuleIDs []string `json:"rule_ids"` + } + if err := json.Unmarshal(reorderStub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal reorder body: %v", err) + } + if got, want := strings.Join(body.RuleIDs, ","), "3,1,2,4"; got != want { + t.Fatalf("reorder body rule_ids = %s, want %s", got, want) + } + + data := decodeShortcutEnvelopeData(t, stdout) + if got, want := data["to_top"], true; got != want { + t.Fatalf("to_top = %v, want %v", got, want) + } +} + type ruleReorderDryRunPayload struct { API []struct { Method string `json:"method"`