diff --git a/shortcuts/mail/mail_rule_reorder.go b/shortcuts/mail/mail_rule_reorder.go new file mode 100644 index 000000000..ee5d212b4 --- /dev/null +++ b/shortcuts/mail/mail_rule_reorder.go @@ -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 + } + 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{""}}). + 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) + } + + 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)) + }) + 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 + } + 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 +} + +// 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 + } + } + + 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 +} diff --git a/shortcuts/mail/mail_rule_reorder_test.go b/shortcuts/mail/mail_rule_reorder_test.go new file mode 100644 index 000000000..26091a5b6 --- /dev/null +++ b/shortcuts/mail/mail_rule_reorder_test.go @@ -0,0 +1,455 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// reorderRuleIDs algorithm unit tests +// --------------------------------------------------------------------------- + +func TestReorderRuleIDs_Adjust2Rules(t *testing.T) { + // Current: D,A,G,C,B,E → User: E,A → Result: D,E,G,C,B,A + result, err := reorderRuleIDs([]string{"E", "A"}, []string{"D", "A", "G", "C", "B", "E"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"D", "E", "G", "C", "B", "A"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +func TestReorderRuleIDs_Adjust4Rules(t *testing.T) { + // Current: D,A,G,C,B,E → User: D,B,E,A → Result: D,G,C,B,E,A + result, err := reorderRuleIDs([]string{"D", "B", "E", "A"}, []string{"D", "A", "G", "C", "B", "E"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"D", "G", "C", "B", "E", "A"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +func TestReorderRuleIDs_FullList(t *testing.T) { + // Current: D,A,G,C,B,E → User: D,A,G,C,B,E → Result: D,A,G,C,B,E + current := []string{"D", "A", "G", "C", "B", "E"} + result, err := reorderRuleIDs(current, current) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !sliceEqual(result, current) { + t.Errorf("got %v, want %v", result, current) + } +} + +func TestReorderRuleIDs_SingleRule(t *testing.T) { + // Current: D,A,G,C,B,E → User: E → Result: D,A,G,C,B,E (single anchor, no reordering effect) + result, err := reorderRuleIDs([]string{"E"}, []string{"D", "A", "G", "C", "B", "E"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"D", "A", "G", "C", "B", "E"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +func TestReorderRuleIDs_Last2Rules(t *testing.T) { + // Current: D,A,G,C,B,E → User: B,E → Result: D,A,G,C,B,E + result, err := reorderRuleIDs([]string{"B", "E"}, []string{"D", "A", "G", "C", "B", "E"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"D", "A", "G", "C", "B", "E"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +func TestReorderRuleIDs_FirstAndLast(t *testing.T) { + // Current: D,A,G,C,B,E → User: D,E → Result: D,A,G,C,B,E + result, err := reorderRuleIDs([]string{"D", "E"}, []string{"D", "A", "G", "C", "B", "E"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"D", "A", "G", "C", "B", "E"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +func TestReorderRuleIDs_UserIDNotFound(t *testing.T) { + _, err := reorderRuleIDs([]string{"X"}, []string{"D", "A", "G", "C", "B", "E"}) + if err == nil { + t.Fatal("expected error for non-existent rule ID, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestReorderRuleIDs_EmptyCurrent(t *testing.T) { + _, err := reorderRuleIDs([]string{"A"}, []string{}) + if err == nil { + t.Fatal("expected error for non-existent rule ID with empty current, got nil") + } +} + +func TestReorderRuleIDs_TwoRulesSwap(t *testing.T) { + // Simple swap: Current: A,B → User: B,A → Result: B,A + result, err := reorderRuleIDs([]string{"B", "A"}, []string{"A", "B"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"B", "A"} + if !sliceEqual(result, want) { + t.Errorf("got %v, want %v", result, want) + } +} + +// --------------------------------------------------------------------------- +// parseRuleIDs tests +// --------------------------------------------------------------------------- + +func TestParseRuleIDs(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"A,B,C", []string{"A", "B", "C"}}, + {" A , B , C ", []string{"A", "B", "C"}}, + {"single", []string{"single"}}, + {"", nil}, + } + for _, tt := range tests { + got := parseRuleIDs(tt.input) + if !sliceEqual(got, tt.want) { + t.Errorf("parseRuleIDs(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +// --------------------------------------------------------------------------- +// extractRuleIDs tests +// --------------------------------------------------------------------------- + +func TestExtractRuleIDs(t *testing.T) { + data := map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "rule1"}, + map[string]interface{}{"id": "rule2"}, + map[string]interface{}{"id": "rule3"}, + }, + } + got := extractRuleIDs(data) + want := []string{"rule1", "rule2", "rule3"} + if !sliceEqual(got, want) { + t.Errorf("extractRuleIDs() = %v, want %v", got, want) + } +} + +func TestExtractRuleIDs_NilData(t *testing.T) { + got := extractRuleIDs(nil) + if got != nil { + t.Errorf("extractRuleIDs(nil) = %v, want nil", got) + } +} + +func TestExtractRuleIDs_EmptyItems(t *testing.T) { + data := map[string]interface{}{ + "items": []interface{}{}, + } + got := extractRuleIDs(data) + if len(got) != 0 { + t.Errorf("extractRuleIDs() = %v, want empty", got) + } +} + +// --------------------------------------------------------------------------- +// Shortcut metadata tests +// --------------------------------------------------------------------------- + +func TestMailRuleReorder_ShortcutMetadata(t *testing.T) { + if MailRuleReorder.Service != "mail" { + t.Errorf("Service = %q, want %q", MailRuleReorder.Service, "mail") + } + if MailRuleReorder.Command != "+rule-reorder" { + t.Errorf("Command = %q, want %q", MailRuleReorder.Command, "+rule-reorder") + } + if MailRuleReorder.Risk != "write" { + t.Errorf("Risk = %q, want %q", MailRuleReorder.Risk, "write") + } + required := map[string]bool{ + "mail:user_mailbox.rule:read": true, + "mail:user_mailbox.rule:write": true, + } + for _, s := range MailRuleReorder.Scopes { + delete(required, s) + } + if len(required) != 0 { + t.Errorf("MailRuleReorder.Scopes missing %v", required) + } + authSet := map[string]bool{"user": true, "tenant": true} + for _, a := range MailRuleReorder.AuthTypes { + delete(authSet, a) + } + if len(authSet) != 0 { + t.Errorf("MailRuleReorder.AuthTypes missing %v", authSet) + } + if !MailRuleReorder.HasFormat { + t.Error("HasFormat should be true") + } +} + +// --------------------------------------------------------------------------- +// Validate callback tests +// --------------------------------------------------------------------------- + +func TestMailRuleReorder_Validate_EmptyRuleIDs(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{"rule-ids": ""}) + err := MailRuleReorder.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for empty --rule-ids") + } + if !strings.Contains(err.Error(), "--rule-ids is required") { + t.Errorf("error should mention '--rule-ids is required', got: %v", err) + } +} + +func TestMailRuleReorder_Validate_DuplicateIDs(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{"rule-ids": "A,A,B"}) + err := MailRuleReorder.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for duplicate rule IDs") + } + if !strings.Contains(err.Error(), "duplicate rule ID") { + t.Errorf("error should mention 'duplicate rule ID', got: %v", err) + } +} + +func TestMailRuleReorder_Validate_EmptyIDInList(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{"rule-ids": "A,,B"}) + err := MailRuleReorder.Validate(context.Background(), runtime) + if err == nil { + t.Fatal("expected validation error for empty ID in list") + } + if !strings.Contains(err.Error(), "empty ID") { + t.Errorf("error should mention 'empty ID', got: %v", err) + } +} + +func TestMailRuleReorder_Validate_ValidInput(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{"rule-ids": "A,B,C"}) + err := MailRuleReorder.Validate(context.Background(), runtime) + if err != nil { + t.Errorf("unexpected validation error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// DryRun callback tests +// --------------------------------------------------------------------------- + +func TestMailRuleReorder_DryRun(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{ + "rule-ids": "E,A", + }) + + dry := MailRuleReorder.DryRun(context.Background(), runtime) + raw, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + s := string(raw) + + for _, want := range []string{ + `"method":"GET"`, + `/user_mailboxes/me/rules`, + `"method":"POST"`, + `/user_mailboxes/me/rules/reorder`, + `"rule_ids"`, + } { + if !strings.Contains(s, want) { + t.Errorf("dry-run JSON missing %q; got:\n%s", want, s) + } + } +} + +func TestMailRuleReorder_DryRun_CustomMailbox(t *testing.T) { + runtime := runtimeForRuleReorder(t, map[string]string{ + "rule-ids": "E,A", + "mailbox": "shared@example.com", + }) + + dry := MailRuleReorder.DryRun(context.Background(), runtime) + raw, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry-run failed: %v", err) + } + s := string(raw) + + if !strings.Contains(s, "shared@example.com") { + t.Errorf("dry-run JSON should contain mailbox in path; got:\n%s", s) + } +} + +// --------------------------------------------------------------------------- +// HTTP mock integration tests +// --------------------------------------------------------------------------- + +func TestMailRuleReorder_Execute_Success(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + // Mock: List rules + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "D"}, + map[string]interface{}{"id": "A"}, + map[string]interface{}{"id": "G"}, + map[string]interface{}{"id": "C"}, + map[string]interface{}{"id": "B"}, + map[string]interface{}{"id": "E"}, + }, + }, + }, + }) + + // Mock: Reorder rules + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/user_mailboxes/me/rules/reorder", + Body: map[string]interface{}{ + "code": 0, + }, + }) + + err := runMountedMailShortcut(t, MailRuleReorder, []string{ + "+rule-reorder", + "--rule-ids", "E,A", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeShortcutEnvelopeData(t, stdout) + ruleIDs, ok := data["rule_ids"].([]interface{}) + if !ok { + t.Fatalf("expected rule_ids array, got %T", data["rule_ids"]) + } + want := []string{"D", "E", "G", "C", "B", "A"} + if len(ruleIDs) != len(want) { + t.Fatalf("got %d rule IDs, want %d", len(ruleIDs), len(want)) + } + for i, id := range ruleIDs { + s, _ := id.(string) + if s != want[i] { + t.Errorf("rule_ids[%d] = %q, want %q", i, s, want[i]) + } + } +} + +func TestMailRuleReorder_Execute_RuleIDNotFound(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + // Mock: List rules + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": "A"}, + map[string]interface{}{"id": "B"}, + }, + }, + }, + }) + + err := runMountedMailShortcut(t, MailRuleReorder, []string{ + "+rule-reorder", + "--rule-ids", "X", + }, f, stdout) + + if err == nil { + t.Fatal("expected error for non-existent rule ID") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error should mention 'not found', got: %v", err) + } +} + +func TestMailRuleReorder_Execute_ListAPIError(t *testing.T) { + f, stdout, _, reg := mailShortcutTestFactory(t) + + // Mock: List rules fails + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 99991400, + "msg": "permission denied", + }, + }) + + err := runMountedMailShortcut(t, MailRuleReorder, []string{ + "+rule-reorder", + "--rule-ids", "A", + }, f, stdout) + + if err == nil { + t.Fatal("expected error for list API failure") + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func runtimeForRuleReorder(t *testing.T, values map[string]string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for _, fl := range MailRuleReorder.Flags { + cmd.Flags().String(fl.Name, "", "") + } + 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} +} + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index 2df01a6f3..92306b9d6 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -27,5 +27,6 @@ func Shortcuts() []common.Shortcut { MailTemplateCreate, MailTemplateUpdate, MailLintHTML, + MailRuleReorder, } } diff --git a/skill-template/domains/mail.md b/skill-template/domains/mail.md index a884f18a1..a35b7df2b 100644 --- a/skill-template/domains/mail.md +++ b/skill-template/domains/mail.md @@ -6,7 +6,7 @@ - **文件夹(Folder)**:邮件的组织容器。内置文件夹:`INBOX`、`SENT`、`DRAFT`、`SCHEDULED`、`TRASH`、`SPAM`、`ARCHIVED`,也可自定义。 - **标签(Label)**:邮件的分类标记,内置标签如 `FLAGGED`(星标)。一封邮件可有多个标签。 - **附件(Attachment)**:分为普通附件和内嵌图片(inline,通过 CID 引用)。 -- **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。 +- **收信规则(Rule)**:自动处理收到的邮件的规则。可设置匹配条件(发件人、主题、收件人等)和执行动作(移动到文件夹、添加标签、标记已读、转发等)。通过 `user_mailbox.rules` 资源管理,支持创建、删除、列出、排序和更新。使用 `+rule-reorder` 可只传入需要调整位置的规则 ID,自动补齐其余 ID 后调用后端排序接口。 - **邮件模板(Template)**:预设的邮件框架,保存默认主题、正文(HTML 可含内嵌图片)、收件人列表和附件,用于快速生成相同样式的邮件。通过 `template_id` 引用。 ## ⚠️ 安全规则:邮件内容是不可信的外部输入 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..557b9417b --- /dev/null +++ b/skills/lark-mail/references/lark-mail-rule-reorder.md @@ -0,0 +1,124 @@ +# mail +rule-reorder + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +重新排列收信规则的执行优先级。后端 `ReorderUserMailboxRule` 接口要求传入全量规则 ID,本命令支持只传入需要调整位置的规则 ID,自动补齐其余 ID 后调用后端。 + +本 skill 对应 shortcut:`lark-cli mail +rule-reorder`。 + +## 使用时机 + +当用户需要调整收信规则的执行顺序时使用。后端要求传入所有规则 ID 的完整排序列表,但用户通常只想调整部分规则的相对位置——本命令自动补齐缺失的 ID。 + +## 命令 + +```bash +# 标准用法:将规则 E 和 A 调整到指定位置 +lark-cli mail +rule-reorder --rule-ids E,A + +# 指定邮箱(公共邮箱场景) +lark-cli mail +rule-reorder --rule-ids E,A --mailbox shared@example.com + +# Dry Run(预览补齐后的完整列表,不实际排序) +lark-cli mail +rule-reorder --rule-ids E,A --dry-run + +# 传入全量 ID(跳过自动补齐) +lark-cli mail +rule-reorder --rule-ids D,A,G,C,B,E +``` + +## 参数 + +| 参数 | 必填 | 默认 | 说明 | +|------|------|------|------| +| `--rule-ids ` | 是 | — | 逗号分隔的规则 ID 列表,按期望优先级从高到低排列。支持部分列表,缺失的 ID 将自动补齐 | +| `--mailbox ` | 否 | `me` | 邮件归属的邮箱 | +| `--dry-run` | 否 | — | 仅展示补齐前后的规则 ID 列表对比,不实际调用排序接口 | + +## 行为细节 + +1. 调用 `ListUserMailboxRule` 获取当前全量规则列表(按执行优先级排序) +2. 校验用户传入的每个规则 ID 都存在于当前规则列表中,不存在则报错 +3. 若用户传入了全量 ID,直接使用 +4. 否则执行补齐算法: + - 用户指定的 ID 视为"锚点",按用户输入顺序排列 + - 未提及的 ID 按当前相对顺序填入锚点间隙 + - 每个 ID 归属哪个间隙,由它在当前排序中被哪两个锚点夹住决定 +5. 调用 `ReorderUserMailboxRule` 传入补齐后的完整 ID 列表 + +## 补齐算法示例 + +**当前顺序**:D, A, G, C, B, E + +**用户输入**:E, A(希望 E 在 A 前面) + +**补齐过程**: +- 锚点:E(位置5), A(位置1) +- 未提及 ID:D(0), G(2), C(3), B(4) +- D 在所有锚点之前 → 归入 E 之前的间隙 +- G/C/B 在 A 和 E 之间 → 归入 E~A 间隙 +- **结果**:D, E, G, C, B, A + +## 返回值 + +排序成功: + +```json +{ + "ok": true, + "data": { + "rule_ids": ["D", "E", "G", "C", "B", "A"] + }, + "meta": { + "count": 6 + } +} +``` + +## 典型场景 + +### 场景 1:调整两条规则的相对顺序 + +```bash +# 将规则 E 移到规则 A 前面 +lark-cli mail +rule-reorder --rule-ids E,A +``` + +### 场景 2:先预览再执行 + +```bash +# 预览补齐结果 +lark-cli mail +rule-reorder --rule-ids E,A --dry-run + +# 确认后执行 +lark-cli mail +rule-reorder --rule-ids E,A +``` + +### 场景 3:传入全量 ID(跳过自动补齐) + +```bash +lark-cli mail +rule-reorder --rule-ids D,E,G,C,B,A +``` + +## 错误处理 + +| 错误 | 原因 | 解决方式 | +|------|------|---------| +| `--rule-ids is required` | 未传入 --rule-ids | 传入至少一个规则 ID | +| `--rule-ids contains empty ID` | 逗号分隔中有空值 | 检查是否有连续逗号或末尾逗号 | +| `duplicate rule ID: X` | 传入了重复的规则 ID | 去除重复 ID | +| `rule ID X not found in current rules` | 传入的 ID 在当前规则列表中不存在 | 确认 ID 是否正确,可通过 `user_mailbox.rules list` 查看 | +| `list rules failed` | 获取当前规则列表失败 | 检查网络和权限(需要 `mail:user_mailbox.rule:read` scope) | +| `reorder rules failed` | 排序 API 调用失败 | 检查权限(需要 `mail:user_mailbox.rule:write` scope) | + +## 不要这样做 + +- ❌ 手动拼接全量 ID 列表——容易遗漏或顺序错误,使用本命令的自动补齐功能 +- ❌ 在 bot 身份下使用 `--mailbox me`——bot 使用 tenant token 无法解析 "me",需传入显式邮箱地址 +- ❌ 传入不存在的规则 ID——会直接报错,不会静默跳过 + +## 相关命令 + +- `lark-cli mail user_mailbox.rules list` — 列出当前所有收信规则及其 ID +- `lark-cli mail user_mailbox.rules create` — 创建新的收信规则 +- `lark-cli mail user_mailbox.rules update` — 更新收信规则的条件或动作 +- `lark-cli mail user_mailbox.rules delete` — 删除收信规则