From e9a5f4f7d398a785efe7cb88325f0f25ab17c776 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:35:47 +0100 Subject: [PATCH 1/2] feat: inbox-scope mail-list and rule-verify; release 0.5.0 mail-list and rule-verify queried GET /me/messages (all folders), so the triage overview mixed already-filed mail with true inbox stragglers and the rule catch-set counted matches no longer in the inbox. Both now read the Inbox via GET /me/mailFolders/inbox/messages. - mail-list gains optional --folder (default inbox), reusing the renamed graph._resolve_folder; unresolvable folder steers. - rule-verify is always inbox-only (rules act on inbox arrivals; no flag). - No new OAuth scope (stays Mail.Read). - describe/catalog + both SKILL.md + handover status updated. - 77 offline tests green (+3); stdlib-only guard passes. Source: docs/HANDOVER-inbox-scope.md (feature 006-inbox-scope). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 ++++++++++++++ CLAUDE.md | 7 +++++- docs/HANDOVER-inbox-scope.md | 4 ++++ plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/mail-list/SKILL.md | 15 ++++++++---- plugin/skills/rule-verify/SKILL.md | 10 ++++---- plugin/src/msgraph/catalog.py | 14 +++++++++-- plugin/src/msgraph/client.py | 5 ++++ plugin/src/msgraph/graph.py | 10 ++++---- plugin/src/msgraph/verbs.py | 37 ++++++++++++++++++++++-------- tests/test_client.py | 33 +++++++++++++++++++++++++- 11 files changed, 127 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3771a74..ec24e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,23 @@ 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. --> +## [0.5.0] - 2026-06-22 + +### Fixed + +- **Inbox-scoped reads** — `mail-list` and `rule-verify` previously queried `GET /me/messages`, which + returns messages across **all** mail folders, so the triage overview mixed already-filed mail with + true inbox stragglers and the rule catch-set counted matches no longer in the inbox (false + confidence in a rule's value). Both now read the Inbox via `GET /me/mailFolders/inbox/messages`. + `rule-verify` is always inbox-only (rules act on inbox arrivals; no flag). No new OAuth scope — + stays `Mail.Read`. Source: `docs/HANDOVER-inbox-scope.md` (feature `006-inbox-scope`). + +### Added + +- **`mail-list --folder `** — optional flag to list a folder other than the + Inbox (default `inbox`), reusing the existing folder resolver; an unresolvable folder steers + rather than returning a raw 404. + ## [0.4.1] - 2026-06-22 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index be65225..1cc6da0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,12 @@ or advertise it. reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — it resolves to `plugin/`. -Active feature plan: `specs/005-docs-refresh/plan.md` (documentation-only refresh to match shipped +Active feature plan: `specs/006-inbox-scope/plan.md` (re-scope `mail-list` and `rule-verify` reads +from all-folders `GET /me/messages` to the Inbox via `GET /me/mailFolders/inbox/messages`; add an +optional `mail-list --folder ` flag reusing the `graph.py` folder resolver; +`rule-verify` is always inbox-only; no new OAuth scope — stays `Mail.Read`; tests re-point the mocked +read URL and add inbox-scope coverage; source `docs/HANDOVER-inbox-scope.md`). Prior feature plan: +`specs/005-docs-refresh/plan.md` (documentation-only refresh to match shipped 0.3.0 — add `message-move` + the `--mode messages` tier across the docs, describe the layered package, and HONESTLY reconcile the docs' "No imperative per-message mutation" constraint to its true narrower guarantee: move-only, never-delete, reversible, separately consented; no code change, diff --git a/docs/HANDOVER-inbox-scope.md b/docs/HANDOVER-inbox-scope.md index 8f1a9c0..847abb9 100644 --- a/docs/HANDOVER-inbox-scope.md +++ b/docs/HANDOVER-inbox-scope.md @@ -1,5 +1,9 @@ # Handover: `rule-verify` and `mail-list` search all folders, not inbox-only +**Status:** RESOLVED 2026-06-22 — feature `006-inbox-scope` (see `specs/006-inbox-scope/`, CHANGELOG +[Unreleased]). Both verbs now read `GET /me/mailFolders/inbox/messages`; `mail-list` gained an +optional `--folder` flag; `rule-verify` is always inbox-only. + **Raised:** 2026-06-22 **Repo context:** kypr `/kypr-triage` session surfaced this during live use. diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index f3806bc..4993662 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "msgraph-stdlib", - "version": "0.4.1", + "version": "0.5.0", "description": "Stdlib-only, zero-backend Claude Code plugin for Microsoft Graph: read Outlook mail and author/verify native Outlook message rules. Read-only by default, with a least-privilege scope ratchet and read-only catch-set verification before any rule is installed.", "author": { "name": "Neil Foster", diff --git a/plugin/skills/mail-list/SKILL.md b/plugin/skills/mail-list/SKILL.md index a5af30a..3892a2a 100644 --- a/plugin/skills/mail-list/SKILL.md +++ b/plugin/skills/mail-list/SKILL.md @@ -1,7 +1,7 @@ --- name: "mail-list" -description: "List recent inbox messages from Outlook, agent-legibly, for triage or to find a message to inspect. Read-only (Mail.Read). Use when you need an overview of what's in the inbox — e.g. before proposing a rule, or to locate a message id for mail-get. concise (default) returns readable summaries (subject, sender, received time); detailed adds Graph ids needed for follow-up calls. Bounded by --limit (default 25) so it never dumps the whole mailbox. Requires a prior read sign-in (run /msgraph-auth-login)." -argument-hint: "[--limit N] [--format concise|detailed]" +description: "List recent messages from one Outlook folder, agent-legibly, for triage or to find a message to inspect. Defaults to the Inbox — what actually needs triage, not already-filed mail across every folder. Read-only (Mail.Read). Use when you need an overview of what's in the inbox — e.g. before proposing a rule, or to locate a message id for mail-get; pass --folder to inspect a different folder. concise (default) returns readable summaries (subject, sender, received time); detailed adds Graph ids needed for follow-up calls. Bounded by --limit (default 25) so it never dumps the whole mailbox. Requires a prior read sign-in (run /msgraph-auth-login)." +argument-hint: "[--limit N] [--format concise|detailed] [--folder NAME]" user-invocable: true disable-model-invocation: false annotations: @@ -13,9 +13,12 @@ annotations: ## What this does -Fetches recent inbox messages via `GET /me/messages` (newest first), resolving the sender to a -readable address and shaping the output for an agent. It reads only — it holds `Mail.Read` and -reaches no write endpoint, so it cannot move, archive, or delete anything. +Fetches recent messages from a single folder via `GET /me/mailFolders/{folder}/messages` (newest +first), resolving the sender to a readable address and shaping the output for an agent. It defaults +to the **Inbox**, so the listing reflects true triage stragglers rather than mail already filed in +Newsletters/Archive/etc. Pass `--folder ` (e.g. `--folder archive`) to list +elsewhere; an unresolvable folder steers rather than erroring obscurely. It reads only — it holds +`Mail.Read` and reaches no write endpoint, so it cannot move, archive, or delete anything. Reach for this for **triage/overview**. When you need one message's full content or its internet headers (e.g. to inspect `List-Unsubscribe` before proposing a rule), follow up with **mail-get** @@ -34,6 +37,8 @@ python3 "${CLAUDE_PLUGIN_ROOT}/src/msgraph/client.py" describe --name mail-list ```bash python3 "${CLAUDE_PLUGIN_ROOT}/src/msgraph/client.py" mail-list --limit 10 --format concise +# inbox by default; inspect another folder: +python3 "${CLAUDE_PLUGIN_ROOT}/src/msgraph/client.py" mail-list --folder archive # or: python3 -m msgraph.client mail-list ... ``` diff --git a/plugin/skills/rule-verify/SKILL.md b/plugin/skills/rule-verify/SKILL.md index 404b325..ad813d5 100644 --- a/plugin/skills/rule-verify/SKILL.md +++ b/plugin/skills/rule-verify/SKILL.md @@ -13,10 +13,12 @@ annotations: ## What this does -Fetches recent messages **with their internet headers** read-only and applies the same coarse -case-insensitive substring matching Outlook's `headerContains` predicate uses, returning the exact -set of messages (and a count) the proposed rule would match right now. It performs **only GETs** — -nothing is written to the mailbox (SC-002). +Fetches recent **Inbox** messages **with their internet headers** read-only and applies the same +coarse case-insensitive substring matching Outlook's `headerContains` predicate uses, returning the +exact set of messages (and a count) the proposed rule would match right now. The catch-set is +**inbox-only by design** — native message rules act on inbox arrivals, so the count reflects mail +currently in the Inbox, not matches already filed away in other folders (there is no flag to broaden +it). It performs **only GETs** — nothing is written to the mailbox (SC-002). It also records a small **verification marker** (a hash of the normalised predicate set) next to the token cache. `rule-create` checks for that marker and **refuses** to install a rule whose criteria diff --git a/plugin/src/msgraph/catalog.py b/plugin/src/msgraph/catalog.py index 0306790..9ca494b 100644 --- a/plugin/src/msgraph/catalog.py +++ b/plugin/src/msgraph/catalog.py @@ -76,8 +76,10 @@ { "name": "mail-list", "description": ( - "List recent inbox messages, agent-legibly. Use for triage/overview. concise (default) " - "returns readable summaries; detailed adds IDs for follow-up calls. Requires read sign-in." + "List recent messages from a single folder, agent-legibly. Defaults to the Inbox — what " + "actually needs triage, not already-filed mail across every folder. Use for " + "triage/overview; pass folder to inspect another folder. concise (default) returns " + "readable summaries; detailed adds IDs for follow-up calls. Requires read sign-in." ), "annotations": { "readOnlyHint": True, @@ -99,6 +101,14 @@ "default": "concise", "description": "concise = agent-legible summary; detailed = adds IDs.", }, + "folder": { + "type": "string", + "default": "inbox", + "description": ( + "Folder to list: well-known name (e.g. inbox, archive) or display name. " + "Defaults to inbox." + ), + }, }, "required": [], }, diff --git a/plugin/src/msgraph/client.py b/plugin/src/msgraph/client.py index 76a31dd..0172ab6 100644 --- a/plugin/src/msgraph/client.py +++ b/plugin/src/msgraph/client.py @@ -130,6 +130,11 @@ def _build_parser() -> argparse.ArgumentParser: ) for verb in ("mail-list",): sub.choices[verb].add_argument("--limit", type=int, default=25, help="max items (pagination)") + sub.choices[verb].add_argument( + "--folder", + default="inbox", + help="folder to list: well-known name or display name (default: inbox)", + ) for verb in ( "mail-list", "mail-get", diff --git a/plugin/src/msgraph/graph.py b/plugin/src/msgraph/graph.py index 8ab58c5..55cfbde 100644 --- a/plugin/src/msgraph/graph.py +++ b/plugin/src/msgraph/graph.py @@ -43,12 +43,14 @@ def _resolve_folder_id(token: str, name: str) -> str: } -def _resolve_destination_folder(token: str, dest: str) -> tuple[str, str]: - """Resolve a move destination to (folder_id, label) — never a delete target (MOVE only). +def _resolve_folder(token: str, dest: str) -> tuple[str, str]: + """Resolve a folder reference to (folder_id, label). General-purpose: used for reads + (mail-list --folder) and as a move destination (message-move) alike — it never names a + delete target. Accepts a well-known folder name (used verbatim), a display name (resolved to its id, at any nesting depth, via the folder name map), or an opaque folder id (used verbatim when no name - matches). Read-only resolution; the caller performs the actual move. + matches). Read-only resolution; the caller decides what to do with the resolved folder. """ if dest.casefold() in _WELL_KNOWN_FOLDERS: return dest.casefold(), dest.casefold() @@ -56,7 +58,7 @@ def _resolve_destination_folder(token: str, dest: str) -> tuple[str, str]: for fid, fname in _folder_name_map(token).items(): if fname.casefold() == dest.casefold(): return fid, fname - # No name match — treat the value as an opaque id and let Graph validate it per message. + # No name match — treat the value as an opaque id and let Graph validate it per request. return dest, dest diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index 0b3a404..7b48d5d 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -91,14 +91,29 @@ def cmd_auth_login(args) -> int: def cmd_mail_list(args) -> int: - """List recent inbox messages (GET /me/messages), shaped concise/detailed (FR-005).""" + """List recent messages from a single folder, shaped concise/detailed (FR-005). + + Defaults to the Inbox (GET /me/mailFolders/inbox/messages) so the listing reflects what actually + needs triage, not already-filed mail across every folder. An optional --folder (well-known name + or display name) scopes the listing elsewhere; an unresolvable folder steers rather than 404s. + """ tok = runtime._authed_token("Mail.Read") + token = tok["access_token"] + folder = getattr(args, "folder", None) or "inbox" + folder_id, label = graph._resolve_folder(token, folder) sel = "id,subject,from,receivedDateTime" - data = runtime._graph_get( - tok["access_token"], - "/me/messages", - params={"$top": args.limit, "$select": sel, "$orderby": "receivedDateTime desc"}, - ) + fid = urllib.parse.quote(folder_id, safe="") + try: + data = runtime._graph_get( + token, + f"/me/mailFolders/{fid}/messages", + params={"$top": args.limit, "$select": sel, "$orderby": "receivedDateTime desc"}, + ) + except runtime.SteerError as e: + raise runtime.SteerError( + f'Could not list folder "{label}": {e} ' + "Check the folder name (run folder-list to see available folders)." + ) print(render._render_messages(data.get("value", []), args.format)) return 0 @@ -142,7 +157,7 @@ def cmd_message_move(args) -> int: tok = runtime._authed_token(runtime.MESSAGE_WRITE_SCOPE) token = tok["access_token"] ids = list(dict.fromkeys(args.message_ids)) # de-dupe, preserve order - dest_id, dest_label = graph._resolve_destination_folder(token, args.destination_folder) + dest_id, dest_label = graph._resolve_folder(token, args.destination_folder) if args.dry_run: previews = [{"id": mid, **_message_summary(token, mid)} for mid in ids] @@ -182,11 +197,15 @@ def cmd_message_move(args) -> int: def _fetch_messages_with_headers(token: str, limit: int = 100) -> list: - """Read messages + their internet headers for catch-set evaluation (read-only, GETs only).""" + """Read inbox messages + their internet headers for catch-set evaluation (read-only, GETs only). + + Inbox-scoped: native message rules act on inbox arrivals, so the catch-set must count only the + mail that currently sits in the Inbox — not matches already filed away in other folders. + """ sel = "id,subject,from,receivedDateTime,internetMessageHeaders" data = runtime._graph_get( token, - "/me/messages", + "/me/mailFolders/inbox/messages", params={"$top": limit, "$select": sel, "$orderby": "receivedDateTime desc"}, ) return data.get("value", []) diff --git a/tests/test_client.py b/tests/test_client.py index bdd36a7..ac8718e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -254,13 +254,42 @@ def test_mail_list_shaping_and_limit(self): } rec = _HttpRecorder(lambda method, url, **kw: payload) runtime._http = rec - out = self._capture(client.cmd_mail_list, _Args(limit=10, format="concise")) + out = self._capture(client.cmd_mail_list, _Args(limit=10, format="concise", folder="inbox")) self.assertIn("Hello", out) self.assertIn("a@x.com", out) # only a read (GET) endpoint is reached self.assertEqual(rec.methods(), ["GET"]) self.assertIn("$top=10", rec.calls[0][1]) + def test_mail_list_defaults_to_inbox_scope(self): + # No-args (folder omitted) must read the inbox-scoped path, not all-folders /me/messages. + self._sign_in("Mail.Read MailboxSettings.Read offline_access") + rec = _HttpRecorder(lambda method, url, **kw: {"value": []}) + runtime._http = rec + self._capture(client.cmd_mail_list, _Args(limit=5, format="concise")) + url = rec.calls[0][1] + self.assertIn("/me/mailFolders/inbox/messages", url) + self.assertNotIn("/me/messages?", url) + + def test_mail_list_folder_scopes_to_named_folder(self): + # A well-known folder name is used verbatim in the path. + self._sign_in("Mail.Read MailboxSettings.Read offline_access") + rec = _HttpRecorder(lambda method, url, **kw: {"value": []}) + runtime._http = rec + self._capture(client.cmd_mail_list, _Args(limit=5, format="concise", folder="archive")) + self.assertIn("/me/mailFolders/archive/messages", rec.calls[0][1]) + + def test_mail_list_unresolvable_folder_steers(self): + # An opaque/unknown folder that Graph rejects surfaces a steering error, not a raw 404. + self._sign_in("Mail.Read MailboxSettings.Read offline_access") + + def _boom(method, url, **kw): + raise client.SteerError("Graph 404: folder not found") + + runtime._http = _HttpRecorder(_boom) + with self.assertRaises(client.SteerError): + self._capture(client.cmd_mail_list, _Args(limit=5, format="concise", folder="Nope")) + def test_mail_get_surfaces_headers(self): self._sign_in("Mail.Read MailboxSettings.Read offline_access") msg = { @@ -333,6 +362,8 @@ def test_verify_records_marker_and_only_reads(self): self.assertTrue(client.read_verification(["List-Unsubscribe"])) # verify performs no writes — only GETs against Graph self.assertEqual(set(rec.methods()), {"GET"}) + # catch-set is computed over the Inbox only (rules act on inbox arrivals), not all folders + self.assertIn("/me/mailFolders/inbox/messages", rec.calls[0][1]) # ================================================================================================ From 99d192a280c1ee96d7a7bc18cfc32575fec2c0d5 Mon Sep 17 00:00:00 2001 From: neilgfoster <1370457+neilgfoster@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:36:46 +0100 Subject: [PATCH 2/2] fix: chain SteerError re-raise (ruff B904) Co-Authored-By: Claude Opus 4.8 --- plugin/src/msgraph/verbs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index 7b48d5d..c89cdd5 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -113,7 +113,7 @@ def cmd_mail_list(args) -> int: raise runtime.SteerError( f'Could not list folder "{label}": {e} ' "Check the folder name (run folder-list to see available folders)." - ) + ) from e print(render._render_messages(data.get("value", []), args.format)) return 0