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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ 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.
-->

### Fixed

- `rule-create --move_to_folder` (and `searchfolder-create` source folders) now resolve a
folder name at **any nesting depth** via the recursive folder name map, matching
`message-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 '<name>' was found". A genuinely non-existent name still raises the steering
error. No scope change.

## [0.5.0] - 2026-06-22

### Fixed
Expand Down
62 changes: 0 additions & 62 deletions docs/HANDOVER-nested-folder-rule-target.md

This file was deleted.

17 changes: 12 additions & 5 deletions plugin/src/msgraph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@


def _resolve_folder_id(token: str, name: str) -> str:
"""Look up a mail folder id by display name for the move-to-folder action (data-model)."""
data = runtime._graph_get(token, "/me/mailFolders", params={"$top": 100, "$select": "id,displayName"})
for f in data.get("value", []):
if f.get("displayName", "").casefold() == name.casefold():
return f["id"]
"""Look up a mail folder id by display name for the move-to-folder action (data-model).

Accepts a well-known folder name verbatim, otherwise matches a display name at ANY nesting
depth via the recursive folder name map — parity with message-move / mail-list, so a folder
nested under Inbox (e.g. Inbox/Newsletters) resolves. Raises the steering error only when the
name exists nowhere at any depth (feature 007).
"""
if name.casefold() in _WELL_KNOWN_FOLDERS:
return name.casefold()
for fid, fname in _folder_name_map(token).items():
if fname.casefold() == name.casefold():
return fid
raise runtime.SteerError(
f"No mail folder named '{name}' was found. Create it in Outlook first, or pass an "
f"existing folder name (rule actions file mail to a folder; they never delete)."
Expand Down
69 changes: 67 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,9 @@ def test_success_builds_move_to_folder_and_never_delete(self):
client.record_verification(["List-Unsubscribe"], 3)

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

def test_success_resolves_move_to_folder_nested_under_inbox(self):
# feature 007: a folder nested under Inbox resolves to the nested folder's id.
self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access")
client.record_verification(["List-Unsubscribe"], 2)

# Folder tree: Inbox (top) → Newsletters (nested). Served path-based, ignoring query.
tree = {
"/me/mailFolders": [{"id": "inbox-1", "displayName": "Inbox", "childFolderCount": 1}],
"/me/mailFolders/inbox-1/childFolders": [
{"id": "nested-9", "displayName": "Newsletters", "childFolderCount": 0}
],
}

def responder(method, url, **kw):
if method == "POST":
return {"id": "rule-new"}
base = url.split("?", 1)[0]
for path, value in tree.items():
if base.endswith(path):
return {"value": value}
return {"value": []}

import contextlib
import io

rec = _HttpRecorder(responder)
runtime._http = rec
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(
client.cmd_rule_create(
_Args(
name="Newsletters",
header_contains=["List-Unsubscribe"],
move_to_folder="Newsletters",
)
),
0,
)
post = next(c for c in rec.calls if c[0] == "POST")
self.assertEqual(post[3]["actions"]["moveToFolder"], "nested-9")

def test_refuses_when_move_to_folder_name_exists_nowhere(self):
# feature 007: a name matching no folder at any depth still raises the steering error,
# and no rule is POSTed.
self._sign_in("Mail.Read MailboxSettings.ReadWrite offline_access")
client.record_verification(["List-Unsubscribe"], 1)

def responder(method, url, **kw):
if url.split("?", 1)[0].endswith("/me/mailFolders"):
return {"value": [{"id": "f1", "displayName": "Inbox", "childFolderCount": 0}]}
return {"value": []}

rec = _HttpRecorder(responder)
runtime._http = rec
with self.assertRaises(client.SteerError):
client.cmd_rule_create(
_Args(
name="X",
header_contains=["List-Unsubscribe"],
move_to_folder="DoesNotExist",
)
)
self.assertNotIn("POST", rec.methods())


# ================================================================================================
# T032 — rule-remove (reversibility primitive)
Expand Down
Loading