All notable changes to this plugin are recorded here. The format follows Keep a Changelog, and this plugin uses Semantic Versioning. Pre-1.0, breaking changes may land on a minor bump and are called out explicitly.
The version in plugin/.claude-plugin/plugin.json is the single source of truth; the release
workflow refuses to publish a tag that disagrees with it.
rule-create --sequence/--stop_processing_rules— the created rule's evaluation order and chain-stop behaviour are now caller-controlled instead of hardcoded tosequence: 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 detailedexposesisReadandcategories— 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$selectand JSON output for--format detailed. No new OAuth scope — both are readable under the existingMail.Readgrant.--format conciseis unchanged.
searchfolder-create's documented--source_foldersdefault was inert — well-known folder names (including theinboxdefault) were passed to Graph verbatim as strings insourceFolderIds, mirroring thedestinationIdshortcut used elsewhere in this codebase. Graph does not document (or honor) that shortcut forsourceFolderIds: it accepted the creation request but silently produced a search folder with zero source folders — a filter that could structurally never match any mail. Well-known names are now resolved to real Graph folder ids before creation, and the verb now verifies the created folder'ssourceFolderIdsactually applied, refusing loudly (rather than reporting a false success) if it comes back short.
- Refresh token lost after silent renewal — per RFC 6749 §6, a token-endpoint response MAY
omit
refresh_token(meaning "keep using the previous one")._store_token_responsewas storing""in that case, so the very next expiry would raise "no refresh token — run auth-login again" and force a full re-authentication. The old refresh token is now preserved when the response does not supply a new one.
rule-create --move_to_folder(andsearchfolder-createsource folders) now resolve a folder name at any nesting depth via the recursive folder name map, matchingmessage-move/mail-list. Previously only immediate children of the mailbox root were searched, so a folder nested under Inbox (e.g.Inbox/Newsletters) failed with "No mail folder named '' was found". A genuinely non-existent name still raises the steering error. No scope change.- Dual-stack connect hang (auth + silent refresh) — every HTTP call went through a bare
urllib.request.urlopen, which on a host with a blackholed IPv6 route tried the dead address with the full operation timeout and hung indefinitely (no device code, reads never return, and the silent refresh hung too — the likely cause of "re-auth every session").runtime._httpnow connects via a bounded, IPv4-first path (happy-eyeballs-lite oversocket/http.client): each address is tried with a short connect timeout (MSGRAPH_CONNECT_TIMEOUT, default 5s) so a dead address fails fast to a reachable one.MSGRAPH_FORCE_IPV4=1restricts to IPv4. Stdlib only; the 30s read timeout and the single_httpseam are unchanged. - Silent refresh is now observable — a successful token renewal prints
msgraph: renewed access token silentlyto stderr, so occasional users can see refresh working rather than assuming the session expired.
- Scope-superset warning at sign-in — Microsoft consent is sticky/cumulative, so a
--mode readsign-in on an account that previously consented to a write tier returns a write-capable token.auth-loginnow warns on stderr when the granted scopes are a write-capable superset of the requested mode, so the read-mode token's true capability is never hidden.
- Honest scope-model documentation — added
docs/adr/0001-scope-isolation-one-app-vs-per-tier.mdrecording the decision to keep one app registration and frame--modeas consent-shaping + guardrail (rejecting per-tier app registrations). Reconciled theauth-loginskill doc and README: the unqualified "structurally cannot write" claim is replaced with the true, qualified guarantee (structural read-only holds only before any write mode has ever been consented). Source:docs/HANDOVER-runtime-and-scope.md(feature008-runtime-and-scope; the two-phase agent sign-in, Issue 4, is deferred).
- Inbox-scoped reads —
mail-listandrule-verifypreviously queriedGET /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 viaGET /me/mailFolders/inbox/messages.rule-verifyis always inbox-only (rules act on inbox arrivals; no flag). No new OAuth scope — staysMail.Read. Source:docs/HANDOVER-inbox-scope.md(feature006-inbox-scope).
mail-list --folder <well-known-or-name>— optional flag to list a folder other than the Inbox (defaultinbox), reusing the existing folder resolver; an unresolvable folder steers rather than returning a raw 404.
auth-loginidempotency —cmd_auth_loginnow checks the cached token before initiating a new device-code flow. If the token is present, non-expired, and covers all scopes required by the requested mode, it returns immediately with"Already signed in (<mode>). Scopes: …"and exits 0 without making any HTTP requests or opening a browser. Falls through to the full flow only when the token is absent, expired, or insufficient for the requested mode (e.g. a read token asked to escalate to--mode rules). Fixes repeated browser-consent prompts during multi-step triage sessions. Four unit tests added covering each fall-through boundary.
DEFINITION_OF_DONE.mdand all references to it. The build target it described has been met — the plugin has shipped with all 14 verbs (+describe), a green offline suite, and the full safety model in place. Going forward,CHANGELOG.md(release history) and the runtimedescribecatalog are the source of truth for what's built;CLAUDE.md,README.md, andCONTRIBUTING.mdno longer point at the retired document.
- Per-message move —
message-moverelocates one or more messages to a destination folder (POST /me/messages/{id}/move). MOVE only — it never deletes, and no delete-capable scope is ever requested, so deletion stays structurally impossible; a move is reversible (move it back). Batched over--message_idswith a per-message outcome (one stale id never aborts the rest), and a--dry_runpreview that resolves the destination and lists what would move while writing nothing. Behind a new, separately-consentedMail.ReadWritetier viaauth-login --mode messages— distinct from the read, rule-authoring, and search-folder tiers. Re-files backlog mail that incoming-only rules cannot touch.
- Kernel restructured into a layered package (no behavioural change, identical CLI/discovery
contract). The single ~1519-line
plugin/src/msgraph/client.pyis split intoruntime.py(the HTTP seam, token cache, markers + catch-set, Graph primitives),catalog.py(theTOOLScatalog),render.py,graph.py,verbs.py, and a thinclient.pyentrypoint — a one-way dependency graph that is far easier to read and extend. Bothpython3 -m msgraph.client …and the file-path form still work and emit the identical 15-verb catalog.
- Folder audit —
folder-listenumerates the mailbox's real mail folders as a nested tree (each folder's name, total/unread message counts, child count; ids +parentFolderIdin detailed mode), read-only under the existingMail.Readtier.GET /me/mailFoldersrecursed throughchildFolders, with--include_hiddenfor hidden folders. Distinct fromsearchfolder-list: the virtual Search Folders node is excluded so the two verbs never double-report.
First release. A stdlib-only, zero-backend Claude Code plugin for Microsoft Graph (Outlook): read
mail and author safe, verified, reversible Outlook message rules, plus category labelling and virtual
category search folders. No third-party dependencies (just urllib + json), no backend — the
constraint is the product: portable, auditable, no supply-chain surface.
- Auth — OAuth 2.0 device-code sign-in; token cached
0600outside the repo at the XDG path and silently refreshed. A three-tier scope ratchet: read-only (Mail.Read + MailboxSettings.Read, default), rule authoring (+ MailboxSettings.ReadWrite,--mode rules), and search folders (Mail.ReadWrite,--mode folders) — each a separate, deliberate, auditable escalation. - Mail read —
mail-listandmail-get(including internet headers likeList-Unsubscribe), concise/detailed output, pagination default. - Rules —
rule-list(agent-legible),rule-verify(read-only catch-set),rule-create(files to a folder and/or assigns a coloured category; refuses unless a catch-set was verified and an action is given),rule-remove(the reversibility primitive). - Categories —
category-listandcategory-ensure(create-if-absent coloured master category) so assigned labels always render with a colour, under the rule-authoring scope. - Search folders —
searchfolder-create/searchfolder-list/searchfolder-removefor virtualmailSearchFolderviews (e.g. all mail tagged a category) behind the separateMail.ReadWritetier; creating or removing one never moves or deletes mail. - Discovery —
python3 -m msgraph.client describeemits the runtimeTOOLScatalog (the zero-backend equivalent of MCPtools/list).
- Read-only by default — the token carries no write grant, so even a bug cannot mutate mail.
- Verify-then-act: a rule is checked against real mail before it can be installed.
- Reversible by construction: rules file or label (never delete); search folders are virtual views; no imperative per-message mutation anywhere.
- Offline unit tests over a single mockable HTTP seam plus real-URL construction coverage; ruff lint + format clean; a stdlib-only import guard enforced in CI. All capabilities live-proven against a real mailbox and reverted cleanly.