Skip to content

Commit 523b8b5

Browse files
neilgfosterclaude
andcommitted
feat: resolve folder names at any nesting depth in rule-list
The one-level folder map left deeply-nested move/copy targets showing the raw id. Walk the folder tree breadth-first, descending only into folders that report children (childFolderCount), so GET count is proportional to folders-with-children rather than the whole tree. Read-only; partial maps on error still fall back to raw ids. Test covers a depth-3 folder. Verified live: all rule folder targets resolve to names; no raw ids remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8846bda commit 523b8b5

2 files changed

Lines changed: 41 additions & 41 deletions

File tree

plugin/src/msgraph/client.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -551,33 +551,29 @@ def _resolve_folder_id(token: str, name: str) -> str:
551551

552552

553553
def _folder_name_map(token: str) -> dict:
554-
"""Best-effort id→displayName for mail folders (top level + one level of children).
554+
"""id→displayName for all mail folders at any nesting depth.
555555
556-
Lets rule-list show move/copy-to-folder targets by name. Folders nested deeper than one level
557-
may stay unresolved; those fall back to the raw id so the output never lies about which folder a
558-
rule targets. Read-only (a single GET); failures degrade silently to ids.
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.
559560
"""
560561
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"]
561565
try:
562-
data = _graph_get(
563-
token,
564-
"/me/mailFolders",
565-
params={
566-
"$top": 200,
567-
"$select": "id,displayName",
568-
"$expand": "childFolders($select=id,displayName)",
569-
},
570-
)
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")
571575
except SteerError:
572-
return names
573-
574-
def walk(folders):
575-
for f in folders:
576-
if f.get("id"):
577-
names[f["id"]] = f.get("displayName", "")
578-
walk(f.get("childFolders") or [])
579-
580-
walk(data.get("value", []))
576+
pass # partial map is fine — unresolved ids render as raw ids
581577
return names
582578

583579

tests/test_client.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,8 @@ def test_renders_non_header_predicates_and_actions(self):
296296
# No false "(other criteria)" / "(other action)" placeholder leaks through.
297297
self.assertNotIn("other criteria", out)
298298

299-
def test_resolves_move_to_folder_id_to_name(self):
300-
# rule-list should show the folder NAME, not the opaque id, for move/copy actions.
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.
301301
self._sign_in("Mail.Read MailboxSettings.Read offline_access")
302302
rules = {
303303
"value": [
@@ -306,34 +306,38 @@ def test_resolves_move_to_folder_id_to_name(self):
306306
"displayName": "Filed",
307307
"isEnabled": True,
308308
"conditions": {"senderContains": ["x@y.com"]},
309-
"actions": {"moveToFolder": "child-1", "copyToFolder": "unknown-id"},
309+
"actions": {"moveToFolder": "grandchild-1", "copyToFolder": "unknown-id"},
310310
}
311311
]
312312
}
313-
folders = {
314-
"value": [
315-
{
316-
"id": "top-1",
317-
"displayName": "Top",
318-
"childFolders": [{"id": "child-1", "displayName": "House 2026"}],
319-
}
320-
]
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+
],
321322
}
322-
out = self._render(rules, folders)
323-
self.assertIn('move to folder: "House 2026"', out) # nested child resolved
324-
self.assertNotIn("child-1", out)
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)
325326
self.assertIn("copy to folder: unknown-id", out) # unresolved id shown raw, not hidden
326327

327-
def _render(self, payload, folders=None):
328+
def _render(self, payload, folder_tree=None):
328329
import contextlib
329330
import io
330331

331332
def responder(method, url, **kw):
332-
if "/mailFolders/inbox/messageRules" in url:
333+
if "/messageRules" in url:
333334
return payload
334-
if "/mailFolders" in url:
335-
return folders or {"value": []}
336-
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": []}
337341

338342
client._http = _HttpRecorder(responder)
339343
buf = io.StringIO()

0 commit comments

Comments
 (0)