Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions plugin/skills/rule-create/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <rule name> --header_contains SUBSTR [SUBSTR ...] [--move_to_folder <folder name>] [--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 <rule name> --header_contains SUBSTR [SUBSTR ...] [--move_to_folder <folder name>] [--assign_category NAME ...] [--sequence N] [--stop_processing_rules true|false]"
user-invocable: true
disable-model-invocation: false
annotations:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
```
14 changes: 14 additions & 0 deletions plugin/src/msgraph/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
Expand Down
12 changes: 12 additions & 0 deletions plugin/src/msgraph/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 7 additions & 2 deletions plugin/src/msgraph/verbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading