Skip to content

Commit 393f007

Browse files
authored
Merge pull request #14 from neilgfoster/007-nested-folder-target
fix: rule-create resolves move-to-folder at any nesting depth
2 parents a3ced39 + ec6ef4b commit 393f007

4 files changed

Lines changed: 88 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ Add notes here under Added / Changed / Fixed / Removed. On release, move them un
1515
## [X.Y.Z] - YYYY-MM-DD heading and bump plugin/.claude-plugin/plugin.json to match.
1616
-->
1717

18+
### Fixed
19+
20+
- `rule-create --move_to_folder` (and `searchfolder-create` source folders) now resolve a
21+
folder name at **any nesting depth** via the recursive folder name map, matching
22+
`message-move`/`mail-list`. Previously only immediate children of the mailbox root were
23+
searched, so a folder nested under Inbox (e.g. `Inbox/Newsletters`) failed with "No mail
24+
folder named '<name>' was found". A genuinely non-existent name still raises the steering
25+
error. No scope change.
26+
1827
## [0.5.0] - 2026-06-22
1928

2029
### Fixed

docs/HANDOVER-nested-folder-rule-target.md

Lines changed: 0 additions & 62 deletions
This file was deleted.

plugin/src/msgraph/graph.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@
1010

1111

1212
def _resolve_folder_id(token: str, name: str) -> str:
13-
"""Look up a mail folder id by display name for the move-to-folder action (data-model)."""
14-
data = runtime._graph_get(token, "/me/mailFolders", params={"$top": 100, "$select": "id,displayName"})
15-
for f in data.get("value", []):
16-
if f.get("displayName", "").casefold() == name.casefold():
17-
return f["id"]
13+
"""Look up a mail folder id by display name for the move-to-folder action (data-model).
14+
15+
Accepts a well-known folder name verbatim, otherwise matches a display name at ANY nesting
16+
depth via the recursive folder name map — parity with message-move / mail-list, so a folder
17+
nested under Inbox (e.g. Inbox/Newsletters) resolves. Raises the steering error only when the
18+
name exists nowhere at any depth (feature 007).
19+
"""
20+
if name.casefold() in _WELL_KNOWN_FOLDERS:
21+
return name.casefold()
22+
for fid, fname in _folder_name_map(token).items():
23+
if fname.casefold() == name.casefold():
24+
return fid
1825
raise runtime.SteerError(
1926
f"No mail folder named '{name}' was found. Create it in Outlook first, or pass an "
2027
f"existing folder name (rule actions file mail to a folder; they never delete)."

tests/test_client.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,8 +490,9 @@ def test_success_builds_move_to_folder_and_never_delete(self):
490490
client.record_verification(["List-Unsubscribe"], 3)
491491

492492
def responder(method, url, **kw):
493-
if url.endswith("/me/mailFolders?$top=100&$select=id,displayName"):
494-
return {"value": [{"id": "folder-123", "displayName": "Newsletters"}]}
493+
# Top-level folder match (no regression for non-nested targets).
494+
if url.split("?", 1)[0].endswith("/me/mailFolders"):
495+
return {"value": [{"id": "folder-123", "displayName": "Newsletters", "childFolderCount": 0}]}
495496
if method == "POST":
496497
return {"id": "rule-new"}
497498
return {}
@@ -520,6 +521,70 @@ def responder(method, url, **kw):
520521
# no DELETE on any message endpoint
521522
self.assertNotIn("DELETE", rec.methods())
522523

524+
def test_success_resolves_move_to_folder_nested_under_inbox(self):
525+
# feature 007: a folder nested under Inbox resolves to the nested folder's id.
526+
self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access")
527+
client.record_verification(["List-Unsubscribe"], 2)
528+
529+
# Folder tree: Inbox (top) → Newsletters (nested). Served path-based, ignoring query.
530+
tree = {
531+
"/me/mailFolders": [{"id": "inbox-1", "displayName": "Inbox", "childFolderCount": 1}],
532+
"/me/mailFolders/inbox-1/childFolders": [
533+
{"id": "nested-9", "displayName": "Newsletters", "childFolderCount": 0}
534+
],
535+
}
536+
537+
def responder(method, url, **kw):
538+
if method == "POST":
539+
return {"id": "rule-new"}
540+
base = url.split("?", 1)[0]
541+
for path, value in tree.items():
542+
if base.endswith(path):
543+
return {"value": value}
544+
return {"value": []}
545+
546+
import contextlib
547+
import io
548+
549+
rec = _HttpRecorder(responder)
550+
runtime._http = rec
551+
with contextlib.redirect_stdout(io.StringIO()):
552+
self.assertEqual(
553+
client.cmd_rule_create(
554+
_Args(
555+
name="Newsletters",
556+
header_contains=["List-Unsubscribe"],
557+
move_to_folder="Newsletters",
558+
)
559+
),
560+
0,
561+
)
562+
post = next(c for c in rec.calls if c[0] == "POST")
563+
self.assertEqual(post[3]["actions"]["moveToFolder"], "nested-9")
564+
565+
def test_refuses_when_move_to_folder_name_exists_nowhere(self):
566+
# feature 007: a name matching no folder at any depth still raises the steering error,
567+
# and no rule is POSTed.
568+
self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access")
569+
client.record_verification(["List-Unsubscribe"], 1)
570+
571+
def responder(method, url, **kw):
572+
if url.split("?", 1)[0].endswith("/me/mailFolders"):
573+
return {"value": [{"id": "f1", "displayName": "Inbox", "childFolderCount": 0}]}
574+
return {"value": []}
575+
576+
rec = _HttpRecorder(responder)
577+
runtime._http = rec
578+
with self.assertRaises(client.SteerError):
579+
client.cmd_rule_create(
580+
_Args(
581+
name="X",
582+
header_contains=["List-Unsubscribe"],
583+
move_to_folder="DoesNotExist",
584+
)
585+
)
586+
self.assertNotIn("POST", rec.methods())
587+
523588

524589
# ================================================================================================
525590
# T032 — rule-remove (reversibility primitive)

0 commit comments

Comments
 (0)