diff --git a/CHANGELOG.md b/CHANGELOG.md index 4764e8d..0862dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ Add notes here under Added / Changed / Fixed / Removed. On release, move them un ## [X.Y.Z] - YYYY-MM-DD heading and bump plugin/.claude-plugin/plugin.json to match. --> +### Added + +- **`rule-create --sequence` / `--stop_processing_rules`** — the created rule's evaluation order + and chain-stop behaviour are now caller-controlled instead of hardcoded to `sequence: 1` / + `stopProcessingRules: false`. Lets a more specific rule be ordered before, and take precedence + over, a broader overlapping rule. Both are optional and default to the prior hardcoded values, so + existing callers are unaffected; an invalid `--sequence` (not a positive integer) is refused + before any Graph call. +- **`mail-list --format detailed` exposes `isRead` and `categories`** — previously only a minimal + projection (`from`, `id`, `receivedDateTime`, `subject`) was returned, so no read-only verb could + surface per-message read status or category tags; a client had to degrade to folder-level unread + counts. Both fields are now included in the Graph `$select` and JSON output for + `--format detailed`. No new OAuth scope — both are readable under the existing `Mail.Read` grant. + `--format concise` is unchanged. + ### Fixed - **`searchfolder-create`'s documented `--source_folders` default was inert** — well-known folder diff --git a/plugin/skills/rule-create/SKILL.md b/plugin/skills/rule-create/SKILL.md index 4f628fb..632f38d 100644 --- a/plugin/skills/rule-create/SKILL.md +++ b/plugin/skills/rule-create/SKILL.md @@ -1,7 +1,7 @@ --- name: "rule-create" -description: "Install a native Outlook message rule that FILES matching mail to a folder and/or ASSIGNS a category to it (move-to-folder and/or assign-category — never delete). Requires rule-authoring sign-in (run /msgraph-auth-login --mode rules; MailboxSettings.ReadWrite). REFUSES unless the exact same header_contains criteria were verified first with rule-verify — verify-then-install is a hard safety gate, not a convention — and refuses if you give no action at all. Any assigned category is ensured to exist (coloured) first. Use after you have inspected headers (mail-get) and confirmed the catch-set (rule-verify). The rule appears in Outlook's own Rules UI and is fully reversible: remove it with rule-remove and any mail already filed/labelled stays put. Pass a name, the verified header_contains substrings, and at least one of --move_to_folder or --assign_category." -argument-hint: "--name --header_contains SUBSTR [SUBSTR ...] [--move_to_folder ] [--assign_category NAME ...]" +description: "Install a native Outlook message rule that FILES matching mail to a folder and/or ASSIGNS a category to it (move-to-folder and/or assign-category — never delete). Requires rule-authoring sign-in (run /msgraph-auth-login --mode rules; MailboxSettings.ReadWrite). REFUSES unless the exact same header_contains criteria were verified first with rule-verify — verify-then-install is a hard safety gate, not a convention — and refuses if you give no action at all. Any assigned category is ensured to exist (coloured) first. Use after you have inspected headers (mail-get) and confirmed the catch-set (rule-verify). The rule appears in Outlook's own Rules UI and is fully reversible: remove it with rule-remove and any mail already filed/labelled stays put. Pass a name, the verified header_contains substrings, and at least one of --move_to_folder or --assign_category. Optionally pass --sequence to control evaluation order among your rules (lower runs first, default 1) and --stop_processing_rules true to stop lower-priority rules from also acting once this one matches (default false) — useful when predicates overlap and a more specific rule should win over a broader one." +argument-hint: "--name --header_contains SUBSTR [SUBSTR ...] [--move_to_folder ] [--assign_category NAME ...] [--sequence N] [--stop_processing_rules true|false]" user-invocable: true disable-model-invocation: false annotations: @@ -37,6 +37,15 @@ a specific colour. The target folder must already exist in Outlook (the command resolves its name to an id); rules file mail to a folder, they never create or delete one. +**Ordering and chain-stop.** By default every rule is created with `sequence: 1` and +`stopProcessingRules: false`, matching Outlook's own defaults. When your rules' predicates overlap +(e.g. a broad "any newsletter" rule and a narrower "billing newsletter" rule), pass a lower +`--sequence` on the more specific rule so it evaluates first, and pass `--stop_processing_rules true` on +it so a message it already matched isn't also acted on by a later, broader rule. `--sequence` must be +a positive integer; an invalid value is refused before any Graph call is made. Reordering an already +installed rule isn't supported directly — remove it (`rule-remove`) and recreate it with the new +`--sequence`. + ## Typical flow ```bash @@ -48,6 +57,12 @@ mail to a folder, they never create or delete one. # or label on arrival (alone or combined with --move_to_folder): /msgraph-rule-create --name "Flag newsletters" \ --header_contains "List-Unsubscribe" --assign_category "Needs attention" +# or make a specific rule win over a broader one, and stop further rules from also acting: +/msgraph-rule-create --name "Billing" \ + --header_contains "billing@newsletter.example" --move_to_folder "Billing" \ + --sequence 1 --stop_processing_rules true +/msgraph-rule-create --name "Newsletters" \ + --header_contains "@newsletter.example" --move_to_folder "Newsletters" --sequence 2 ``` ## Discoverability @@ -68,7 +83,8 @@ python3 "${CLAUDE_PLUGIN_ROOT}/src/msgraph/client.py" rule-create \ ``` Created rule "Newsletters" (id: AAMk...). It files mail whose headers contain ['List-Unsubscribe'] -into "Newsletters". Verified catch-set was 7 message(s). Reverse anytime with rule-remove. +into "Newsletters". Verified catch-set was 7 message(s). Sequence: 1, stop processing rules: false. +Reverse anytime with rule-remove. ``` ## Errors steer the agent @@ -77,4 +93,5 @@ into "Newsletters". Verified catch-set was 7 message(s). Reverse anytime with ru error: This action needs rule-authoring permission … run /msgraph-auth-login --mode rules. error: Refusing to create this rule: its criteria were not verified first. Run rule-verify … then retry. error: No mail folder named 'X' was found. Create it in Outlook first, or pass an existing folder name. +error: rule-create: --sequence must be a positive integer (got: 0) ``` diff --git a/plugin/src/msgraph/catalog.py b/plugin/src/msgraph/catalog.py index d3ea634..a1b592a 100644 --- a/plugin/src/msgraph/catalog.py +++ b/plugin/src/msgraph/catalog.py @@ -288,6 +288,20 @@ "exist (coloured) before install. At least one of move_to_folder/assign_category " "required.", }, + "sequence": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Evaluation order among this mailbox's rules; lower runs first. " + "Optional — defaults to 1 (prior behavior) when omitted.", + }, + "stop_processing_rules": { + "type": "boolean", + "default": False, + "description": "When true, Graph stops evaluating lower-priority rules once this " + "rule's action(s) have run on a matching message. Optional — defaults to false " + "(prior behavior) when omitted.", + }, }, "required": ["name", "header_contains"], }, diff --git a/plugin/src/msgraph/client.py b/plugin/src/msgraph/client.py index 0172ab6..807fc67 100644 --- a/plugin/src/msgraph/client.py +++ b/plugin/src/msgraph/client.py @@ -180,6 +180,18 @@ def _build_parser() -> argparse.ArgumentParser: sub.choices["rule-create"].add_argument( "--assign_category", nargs="+", metavar="NAME", help="optional category name(s) to assign" ) + sub.choices["rule-create"].add_argument( + "--sequence", + type=int, + default=1, + help="evaluation order among this mailbox's rules; lower runs first (default 1)", + ) + sub.choices["rule-create"].add_argument( + "--stop_processing_rules", + type=lambda v: str(v).lower() not in ("false", "0", "no"), + default=False, + help="stop evaluating lower-priority rules once this rule's action(s) run (default false)", + ) sub.choices["rule-remove"].add_argument("--rule_id", required=True, help="Graph rule id") sub.choices["category-ensure"].add_argument("--name", required=True, help="category display name") diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index acd1e6c..d84e010 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -282,9 +282,13 @@ def cmd_rule_create(args) -> int: f"rule-verify --header_contains {args.header_contains} to preview the catch-set, " "then retry. (verify-then-install is a hard safety gate.)" ) + sequence = getattr(args, "sequence", 1) + if not isinstance(sequence, int) or sequence < 1: + raise runtime.SteerError(f"rule-create: --sequence must be a positive integer (got: {sequence})") + stop_processing_rules = getattr(args, "stop_processing_rules", False) # Build actions conditionally — only move-to-folder and/or assign-category; never a delete-style # action (FR-009/FR-012). Each assigned category is ensured to exist (coloured) first (FR-005). - actions: dict = {"stopProcessingRules": False} + actions: dict = {"stopProcessingRules": stop_processing_rules} summary = [] if move_to_folder: actions["moveToFolder"] = graph._resolve_folder_id(tok["access_token"], move_to_folder) @@ -296,7 +300,7 @@ def cmd_rule_create(args) -> int: summary.append(f"assigns category {assign_category}") body = { "displayName": args.name, - "sequence": 1, + "sequence": sequence, "isEnabled": True, "conditions": {"headerContains": list(args.header_contains)}, "actions": actions, @@ -308,6 +312,7 @@ def cmd_rule_create(args) -> int: f'Created rule "{args.name}" (id: {created.get("id", "?")}). For mail whose headers ' f"contain {args.header_contains}, it {' and '.join(summary)}. " f"Verified catch-set was {marker.get('count', '?')} message(s). " + f"Sequence: {sequence}, stop processing rules: {str(stop_processing_rules).lower()}. " f"Reverse anytime with rule-remove." ) return 0 diff --git a/tests/test_client.py b/tests/test_client.py index 9e08b69..4d44ead 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -800,6 +800,77 @@ def test_rule_create_category_still_requires_verify(self): _Args(name="N", header_contains=["Unverified"], move_to_folder=None, assign_category=["Lbl"]) ) + def test_rule_create_default_sequence_and_stop_processing_unchanged(self): + # 019: omitting --sequence/--stop_processing_rules must reproduce the prior hardcoded body. + self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access") + client.record_verification(["X"], 1) + rec = _HttpRecorder(lambda method, url, **kw: {"id": "rule-new"}) + runtime._http = rec + out = self._capture( + client.cmd_rule_create, + _Args( + name="N", + header_contains=["X"], + move_to_folder=None, + assign_category=["Lbl"], + sequence=1, + stop_processing_rules=False, + ), + ) + rule_post = next(c for c in rec.calls if c[0] == "POST" and c[1].endswith("/messageRules")) + self.assertEqual(rule_post[3]["sequence"], 1) + self.assertEqual(rule_post[3]["actions"]["stopProcessingRules"], False) + self.assertIn("Sequence: 1, stop processing rules: false", out) + + def test_rule_create_explicit_sequence_and_stop_processing_rules(self): + # 019: explicit --sequence/--stop_processing_rules must land verbatim in the POST body. + self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access") + client.record_verification(["billing@newsletter.example"], 3) + + def responder(method, url, **kw): + if "/me/mailFolders?" in url: + return {"value": [{"id": "f-billing", "displayName": "Billing"}]} + if method == "POST": + return {"id": "rule-new"} + return {} + + rec = _HttpRecorder(responder) + runtime._http = rec + out = self._capture( + client.cmd_rule_create, + _Args( + name="Billing", + header_contains=["billing@newsletter.example"], + move_to_folder="Billing", + assign_category=None, + sequence=3, + stop_processing_rules=True, + ), + ) + rule_post = next(c for c in rec.calls if c[0] == "POST" and c[1].endswith("/messageRules")) + self.assertEqual(rule_post[3]["sequence"], 3) + self.assertEqual(rule_post[3]["actions"]["stopProcessingRules"], True) + self.assertIn("Sequence: 3, stop processing rules: true", out) + + def test_rule_create_rejects_non_positive_sequence(self): + # 019: an invalid --sequence must be refused client-side, before any Graph call. + self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access") + client.record_verification(["X"], 1) + rec = _HttpRecorder() + runtime._http = rec + with self.assertRaises(client.SteerError): + client.cmd_rule_create( + _Args( + name="N", + header_contains=["X"], + move_to_folder=None, + assign_category=["Lbl"], + sequence=0, + stop_processing_rules=False, + ) + ) + self.assertEqual(rec.calls, []) # refused before touching the network seam + # ================================================================================================ # T030 — US2: search-folder create / list / remove + the new scope tier