Skip to content

Commit a8ac008

Browse files
authored
Merge pull request #4 from neilgfoster/fix/graph-query-encoding
fix: percent-encode Graph query params (mail-list InvalidURL)
2 parents a8ae810 + 523b8b5 commit a8ac008

6 files changed

Lines changed: 372 additions & 27 deletions

File tree

‎CLAUDE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ or advertise it.
101101
reference bundled files via `${CLAUDE_PLUGIN_ROOT}/...` — it resolves to `plugin/`.
102102

103103
<!-- SPECKIT START -->
104-
Active feature plan: `specs/001-msgraph-mail-rules/plan.md` (spec, research, data-model,
105-
contracts/tools.md, quickstart alongside it). Read it for the technical context, the stdlib-only
106-
device-code design, the `TOOLS` catalog contract, and the safety-model decisions before implementing.
104+
Active feature plan: `specs/002-fix-graph-query-encoding/plan.md` (defect fix: percent-encode all
105+
Graph query params + real-URL regression coverage + Azure setup docs). Foundation feature plan:
106+
`specs/001-msgraph-mail-rules/plan.md` (spec, research, data-model, contracts/tools.md, quickstart
107+
alongside it) — read it for the technical context, the stdlib-only device-code design, the `TOOLS`
108+
catalog contract, and the safety-model decisions before implementing.
107109
<!-- SPECKIT END -->

‎DEFINITION_OF_DONE.md‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ plugin is "done" for its first release when **every** box below is true. Read `C
3939
## Quality / hygiene
4040

4141
- [ ] **Offline-testable.** Classification/verification/output-shaping logic is unit-tested without
42-
network (Graph HTTP boundary mockable). Tests pass with stdlib only.
42+
network (Graph HTTP boundary mockable). Tests pass with stdlib only. **Includes real-URL
43+
construction coverage** (`tests/test_url_construction.py`): each verb's URL is built through the
44+
real path and asserted free of raw spaces/control characters — the `_http`-mock tests alone
45+
missed a malformed `$orderby` that crashed the first live run (feature 002).
4346
- [ ] **Two-tier layout.** Shippable payload lives under `plugin/` (`plugin/.claude-plugin/plugin.json`,
4447
`plugin/skills/`, `plugin/src/msgraph/`); the root carries `.claude-plugin/marketplace.json`
4548
whose plugin `source` resolves to `./plugin`.
@@ -53,7 +56,15 @@ plugin is "done" for its first release when **every** box below is true. Read `C
5356

5457
## Acceptance demo (the working slice)
5558

56-
A reviewer can, from a clean checkout + a one-time Azure app reg:
59+
**One-time Azure app reg prerequisites** (verified on the first live run — see auth-login SKILL.md
60+
for the full walkthrough): a personal Microsoft account has **no tenant** by default, so you must
61+
create one via the Azure free trial (card-verified; tenant + app reg are free forever after the trial
62+
lapses); register the app at **entra.microsoft.com** (not portal.azure.com) as **Personal Microsoft
63+
accounts only**, blank redirect URI, **Allow public client flows = Yes** (Authentication → Settings
64+
sub-tab), delegated `Mail.Read` + `MailboxSettings.Read` (+ `MailboxSettings.ReadWrite`); export
65+
`MSGRAPH_CLIENT_ID` and `MSGRAPH_TENANT_ID="consumers"`.
66+
67+
A reviewer can, from a clean checkout + the one-time Azure app reg above:
5768

5869
1. `auth-login` (read-only) → succeeds.
5970
2. `mail-list` → sees real inbox messages.

‎plugin/skills/auth-login/SKILL.md‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,36 @@ Stay in `read` until you actually need to install or remove a rule.
3232

3333
## One-time prerequisite (free, human)
3434

35-
Register a free **Azure AD app** (public client, device-code/public-client flow **enabled**) with
36-
delegated permissions `Mail.Read` + `MailboxSettings.Read` (+ `MailboxSettings.ReadWrite` for rule
37-
authoring). Personal Microsoft accounts need no admin consent. Then export, before first sign-in:
35+
You register a free **Azure AD (Entra) app** once. The app registration and tenant are Entra-level
36+
and **free forever** — only the steps to *get* a tenant trip people up, so follow these exactly. (All
37+
verified during the first live run.)
38+
39+
**0. A personal Microsoft account has NO tenant by default.** It sits in the shared "Microsoft
40+
Services" directory, where app registration is impossible. You must create your own tenant first.
41+
42+
**1. Create a tenant.** Per Microsoft docs the prerequisite is an Azure subscription — start the free
43+
trial at <https://azure.microsoft.com/free>. It requires **card verification** (~£1 reversible hold,
44+
no auto-charge; the trial subscription is *disabled* at 30 days, not upgraded). Durability: once the
45+
tenant exists, the app registration and token issuance keep working at **zero cost** after the trial
46+
subscription is disabled — the subscription is only needed to create the tenant, not to run the app.
47+
48+
**2. Use the Entra admin center — <https://entra.microsoft.com>, NOT portal.azure.com.** A tenant with
49+
no active subscription makes the Azure portal default to the wrong directory (error `AADSTS160021`).
50+
51+
**3. Register the app** (Entra → App registrations → New registration):
52+
53+
| Setting | Value |
54+
|---|---|
55+
| Supported account types | **Personal Microsoft accounts only** (this makes `MSGRAPH_TENANT_ID="consumers"` correct) |
56+
| Redirect URI | **leave blank** (device-code flow needs none) |
57+
| Authentication → **Allow public client flows** | **Yes** — REQUIRED, or device-code fails. In the new "Authentication (Preview)" tab this toggle lives under the **Settings** sub-tab (no longer under "Advanced settings" — docs that say otherwise are outdated). |
58+
| API permissions (delegated) | `Mail.Read`, `MailboxSettings.Read` (+ `MailboxSettings.ReadWrite` for rule authoring). Personal accounts need **no admin consent** — consent happens in-browser at sign-in. The default `User.Read` can stay; the kernel never requests it. |
59+
60+
**4. Export the values** before first sign-in:
3861

3962
```bash
40-
export MSGRAPH_CLIENT_ID="<application (client) id>"
41-
export MSGRAPH_TENANT_ID="consumers" # or "common" for work/school + personal
63+
export MSGRAPH_CLIENT_ID="<application (client) id>" # a public client's id is NOT a secret — plaintext is fine
64+
export MSGRAPH_TENANT_ID="consumers" # "Personal Microsoft accounts only" ⇒ consumers
4265
```
4366

4467
These are read from the environment and never hardcoded. If `MSGRAPH_CLIENT_ID` is unset, the verb

‎plugin/src/msgraph/client.py‎

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -516,13 +516,27 @@ def _render_messages(items: list, fmt: str) -> str:
516516
# ================================================================================================
517517
# Graph helpers
518518
# ================================================================================================
519-
def _graph_get(token: str, path: str) -> dict:
520-
return _http("GET", f"{GRAPH}{path}", token=token)
519+
def _graph_url(path: str, params: dict | None = None) -> str:
520+
"""Build a Graph URL, percent-encoding query values so the URL is valid for urllib/http.client.
521+
522+
OData values routinely contain spaces (``$orderby=receivedDateTime desc``) and other reserved
523+
characters; an unencoded space raises ``http.client.InvalidURL`` on first live use. ``$`` and
524+
``,`` are kept literal because Graph expects them verbatim in option names (``$top``, ``$orderby``)
525+
and ``$select`` lists; everything else (notably spaces → ``%20``) is percent-encoded.
526+
"""
527+
url = f"{GRAPH}{path}"
528+
if params:
529+
url += "?" + urllib.parse.urlencode(params, quote_via=urllib.parse.quote, safe="$,")
530+
return url
531+
532+
533+
def _graph_get(token: str, path: str, params: dict | None = None) -> dict:
534+
return _http("GET", _graph_url(path, params), token=token)
521535

522536

523537
def _resolve_folder_id(token: str, name: str) -> str:
524538
"""Look up a mail folder id by display name for the move-to-folder action (data-model)."""
525-
data = _graph_get(token, "/me/mailFolders?$top=100&$select=id,displayName")
539+
data = _graph_get(token, "/me/mailFolders", params={"$top": 100, "$select": "id,displayName"})
526540
for f in data.get("value", []):
527541
if f.get("displayName", "").casefold() == name.casefold():
528542
return f["id"]
@@ -532,6 +546,37 @@ def _resolve_folder_id(token: str, name: str) -> str:
532546
)
533547

534548

549+
# Action keys whose value is an opaque folder id we can resolve to a display name (read-only).
550+
_FOLDER_ACTION_KEYS = ("moveToFolder", "copyToFolder")
551+
552+
553+
def _folder_name_map(token: str) -> dict:
554+
"""id→displayName for all mail folders at any nesting depth.
555+
556+
Lets rule-list show move/copy-to-folder targets by name. Walks the folder tree breadth-first,
557+
descending only into folders that report children (``childFolderCount``) so the number of GETs is
558+
proportional to the folders-with-children, not the whole tree. Read-only; failures degrade
559+
silently, leaving unresolved ids to fall back to the raw id so output never lies about the target.
560+
"""
561+
names: dict = {}
562+
params = {"$top": 200, "$select": "id,displayName,childFolderCount"}
563+
# Queue of folder-listing paths to fetch; seed with the top level.
564+
pending = ["/me/mailFolders"]
565+
try:
566+
while pending:
567+
data = _graph_get(token, pending.pop(), params=params)
568+
for f in data.get("value", []):
569+
fid = f.get("id")
570+
if not fid:
571+
continue
572+
names[fid] = f.get("displayName", "")
573+
if f.get("childFolderCount", 0):
574+
pending.append(f"/me/mailFolders/{urllib.parse.quote(fid, safe='')}/childFolders")
575+
except SteerError:
576+
pass # partial map is fine — unresolved ids render as raw ids
577+
return names
578+
579+
535580
# ================================================================================================
536581
# Verb implementations
537582
# ================================================================================================
@@ -595,7 +640,9 @@ def cmd_mail_list(args) -> int:
595640
tok = _authed_token("Mail.Read")
596641
sel = "id,subject,from,receivedDateTime"
597642
data = _graph_get(
598-
tok["access_token"], f"/me/messages?$top={args.limit}&$select={sel}&$orderby=receivedDateTime desc"
643+
tok["access_token"],
644+
"/me/messages",
645+
params={"$top": args.limit, "$select": sel, "$orderby": "receivedDateTime desc"},
599646
)
600647
print(_render_messages(data.get("value", []), args.format))
601648
return 0
@@ -605,7 +652,8 @@ def cmd_mail_get(args) -> int:
605652
"""Fetch one message including internet headers (FR-006)."""
606653
tok = _authed_token("Mail.Read")
607654
sel = "id,subject,from,receivedDateTime,body,internetMessageHeaders"
608-
msg = _graph_get(tok["access_token"], f"/me/messages/{args.message_id}?$select={sel}")
655+
mid = urllib.parse.quote(args.message_id, safe="")
656+
msg = _graph_get(tok["access_token"], f"/me/messages/{mid}", params={"$select": sel})
609657
if args.format == "detailed":
610658
print(json.dumps(msg, indent=2))
611659
else:
@@ -622,7 +670,11 @@ def cmd_mail_get(args) -> int:
622670
def _fetch_messages_with_headers(token: str, limit: int = 100) -> list:
623671
"""Read messages + their internet headers for catch-set evaluation (read-only, GETs only)."""
624672
sel = "id,subject,from,receivedDateTime,internetMessageHeaders"
625-
data = _graph_get(token, f"/me/messages?$top={limit}&$select={sel}&$orderby=receivedDateTime desc")
673+
data = _graph_get(
674+
token,
675+
"/me/messages",
676+
params={"$top": limit, "$select": sel, "$orderby": "receivedDateTime desc"},
677+
)
626678
return data.get("value", [])
627679

628680

@@ -644,23 +696,64 @@ def cmd_rule_verify(args) -> int:
644696
return 0
645697

646698

647-
def _render_rules(rules: list, fmt: str) -> str:
699+
def _humanize_key(key: str) -> str:
700+
"""Turn a camelCase predicate/action name into spaced lower-case words (agent-legible)."""
701+
return "".join(f" {c.lower()}" if c.isupper() else c for c in key).strip()
702+
703+
704+
def _humanize_value(value) -> str:
705+
"""Render a Graph predicate/action value (list, recipient list, bool, enum, range) legibly."""
706+
if isinstance(value, bool):
707+
return "yes" if value else "no"
708+
if isinstance(value, list):
709+
parts = []
710+
for item in value:
711+
if isinstance(item, dict): # recipient: {"emailAddress": {"name", "address"}}
712+
addr = (item.get("emailAddress") or {})
713+
parts.append(addr.get("address") or addr.get("name") or json.dumps(item))
714+
else:
715+
parts.append(str(item))
716+
return ", ".join(parts)
717+
if isinstance(value, dict): # e.g. withinSizeRange {minimumSize, maximumSize}
718+
return ", ".join(f"{_humanize_key(k)} {v}" for k, v in value.items())
719+
return str(value)
720+
721+
722+
def _summarize_clauses(clauses: dict, folders: dict | None = None) -> list:
723+
"""Summarize whichever conditions/actions are present, skipping empty/false ones.
724+
725+
``folders`` (id→displayName) resolves move/copy-to-folder action ids to legible names; an
726+
unresolved id falls back to the raw id so the output never misrepresents the target.
727+
"""
728+
folders = folders or {}
729+
lines = []
730+
for key, value in (clauses or {}).items():
731+
if value in (None, [], {}, "", False):
732+
continue # absent predicate/action — don't pretend it's set
733+
if key in _FOLDER_ACTION_KEYS and isinstance(value, str) and value in folders:
734+
rendered = f'"{folders[value]}"'
735+
else:
736+
rendered = _humanize_value(value)
737+
lines.append(f"{_humanize_key(key)}: {rendered}")
738+
return lines
739+
740+
741+
def _render_rules(rules: list, fmt: str, folders: dict | None = None) -> str:
648742
if fmt == "detailed":
649743
return json.dumps(rules, indent=2)
650744
if not rules:
651745
return "No inbox message rules."
652746
out = []
653747
for r in rules:
654-
conds = r.get("conditions") or {}
655-
hc = conds.get("headerContains") or []
656-
action = r.get("actions") or {}
657-
folder = action.get("moveToFolder") or ""
658-
out.append(
748+
conds = _summarize_clauses(r.get("conditions") or {}, folders)
749+
actions = _summarize_clauses(r.get("actions") or {}, folders)
750+
line = (
659751
f'- "{r.get("displayName", "(unnamed)")}"'
660752
f"{' enabled' if r.get('isEnabled', True) else ' disabled'}"
661-
f"\n if header contains: {hc or '(other criteria)'}"
662-
f"\n → move to folder id: {folder or '(other action)'}"
753+
f"\n if {'; '.join(conds) or '(no conditions)'}"
754+
f"\n then {'; '.join(actions) or '(no actions)'}"
663755
)
756+
out.append(line)
664757
out.append(f"{len(rules)} rule(s). Pass --format detailed for ids needed by rule-remove.")
665758
return "\n".join(out)
666759

@@ -669,7 +762,10 @@ def cmd_rule_list(args) -> int:
669762
"""Enumerate existing inbox message rules (FR-007). Rules are mailbox settings (MailboxSettings.Read)."""
670763
tok = _authed_token("MailboxSettings.Read")
671764
data = _graph_get(tok["access_token"], "/me/mailFolders/inbox/messageRules")
672-
print(_render_rules(data.get("value", []), args.format))
765+
rules = data.get("value", [])
766+
# Resolve folder ids to names only when needed for the legible (concise) view.
767+
folders = _folder_name_map(tok["access_token"]) if args.format != "detailed" and rules else {}
768+
print(_render_rules(rules, args.format, folders))
673769
return 0
674770

675771

‎tests/test_client.py‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,81 @@ def test_shaping_and_empty_state(self):
265265
empty = self._render({"value": []})
266266
self.assertIn("No inbox message rules", empty)
267267

268-
def _render(self, payload):
268+
def test_renders_non_header_predicates_and_actions(self):
269+
# Most real rules use predicates other than headerContains; they must be legible, not hidden.
270+
self._sign_in("Mail.Read MailboxSettings.Read offline_access")
271+
rules = {
272+
"value": [
273+
{
274+
"id": "r2",
275+
"displayName": "From boss",
276+
"isEnabled": False,
277+
"conditions": {
278+
"senderContains": ["boss@example.com"],
279+
"fromAddresses": [{"emailAddress": {"name": "Boss", "address": "boss@example.com"}}],
280+
"sentToMe": True,
281+
"importance": "high",
282+
},
283+
"actions": {"markAsRead": True, "assignCategories": ["Work"]},
284+
}
285+
]
286+
}
287+
out = self._render(rules)
288+
self.assertIn("From boss", out)
289+
self.assertIn("disabled", out)
290+
self.assertIn("sender contains: boss@example.com", out)
291+
self.assertIn("from addresses: boss@example.com", out)
292+
self.assertIn("sent to me: yes", out)
293+
self.assertIn("importance: high", out)
294+
self.assertIn("mark as read: yes", out)
295+
self.assertIn("assign categories: Work", out)
296+
# No false "(other criteria)" / "(other action)" placeholder leaks through.
297+
self.assertNotIn("other criteria", out)
298+
299+
def test_resolves_deeply_nested_folder_id_to_name(self):
300+
# rule-list shows the folder NAME, resolved at ANY nesting depth, not the opaque id.
301+
self._sign_in("Mail.Read MailboxSettings.Read offline_access")
302+
rules = {
303+
"value": [
304+
{
305+
"id": "r3",
306+
"displayName": "Filed",
307+
"isEnabled": True,
308+
"conditions": {"senderContains": ["x@y.com"]},
309+
"actions": {"moveToFolder": "grandchild-1", "copyToFolder": "unknown-id"},
310+
}
311+
]
312+
}
313+
# Folder tree served across the child-folder endpoints (top → child → grandchild).
314+
folder_tree = {
315+
"/me/mailFolders": [{"id": "top-1", "displayName": "Top", "childFolderCount": 1}],
316+
"/me/mailFolders/top-1/childFolders": [
317+
{"id": "child-1", "displayName": "Mid", "childFolderCount": 1}
318+
],
319+
"/me/mailFolders/child-1/childFolders": [
320+
{"id": "grandchild-1", "displayName": "House 2026", "childFolderCount": 0}
321+
],
322+
}
323+
out = self._render(rules, folder_tree)
324+
self.assertIn('move to folder: "House 2026"', out) # depth-3 child resolved
325+
self.assertNotIn("grandchild-1", out)
326+
self.assertIn("copy to folder: unknown-id", out) # unresolved id shown raw, not hidden
327+
328+
def _render(self, payload, folder_tree=None):
269329
import contextlib
270330
import io
271331

272-
client._http = _HttpRecorder(lambda method, url, **kw: payload)
332+
def responder(method, url, **kw):
333+
if "/messageRules" in url:
334+
return payload
335+
if folder_tree:
336+
for path, value in folder_tree.items():
337+
# Match the path portion of the URL, ignoring the query string.
338+
if url.split("?", 1)[0].endswith(path):
339+
return {"value": value}
340+
return {"value": []}
341+
342+
client._http = _HttpRecorder(responder)
273343
buf = io.StringIO()
274344
with contextlib.redirect_stdout(buf):
275345
self.assertEqual(client.cmd_rule_list(_Args(format="concise")), 0)

0 commit comments

Comments
 (0)