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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <well-known-or-name>`** — 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
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,12 @@ or advertise it.
reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — it resolves to `plugin/`.

<!-- SPECKIT START -->
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 <well-known-or-name>` 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,
Expand Down
4 changes: 4 additions & 0 deletions docs/HANDOVER-inbox-scope.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 10 additions & 5 deletions plugin/skills/mail-list/SKILL.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 <well-known-or-name>` (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**
Expand All @@ -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 ...
```

Expand Down
10 changes: 6 additions & 4 deletions plugin/skills/rule-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions plugin/src/msgraph/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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": [],
},
Expand Down
5 changes: 5 additions & 0 deletions plugin/src/msgraph/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions plugin/src/msgraph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,22 @@ 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()
# Invert id→name to resolve a display name to its id at any depth (covers nested folders).
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


Expand Down
37 changes: 28 additions & 9 deletions plugin/src/msgraph/verbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
) from e
print(render._render_messages(data.get("value", []), args.format))
return 0

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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", [])
Expand Down
33 changes: 32 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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])


# ================================================================================================
Expand Down
Loading