From 3775cdddd86fa13e78ba3d2a900d7ad588789a6c Mon Sep 17 00:00:00 2001 From: Neil Foster Date: Thu, 23 Jul 2026 06:47:02 +0000 Subject: [PATCH 1/2] feat: expose isRead and categories in mail-list --format detailed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mail-list --format detailed only returned a minimal projection (from, id, receivedDateTime, subject), so no read-only verb could surface per-message read status or category tags — blocking any read-only client from enumerating unread, category-tagged mail without degrading to folder-level unread counts. Add isRead and categories to the Graph $select (and therefore the JSON output) for mail-list --format detailed. No new OAuth scope: both fields are readable under the existing Mail.Read grant. --format concise is unchanged. Closes #20 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011NWPnfcJ9JXDvRThCCUcCH --- .gitattributes | 1 + .gitignore | 5 +++ plugin/src/msgraph/catalog.py | 4 ++- plugin/src/msgraph/verbs.py | 2 +- tests/test_client.py | 59 ++++++++++++++++++++++++++++++++++ tests/test_url_construction.py | 12 +++++++ 6 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0c5990d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.specify/feature.json merge=ours diff --git a/.gitignore b/.gitignore index 83ee508..10712ab 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,8 @@ specs/ .github/agents/speckit* .github/prompts/speckit* # --- end tredl --- +# --- kord: GitHub Spec Kit substrate --- +.specify/ +specs/ +.claude/skills/speckit-*/ +# --- end kord: GitHub Spec Kit substrate --- diff --git a/plugin/src/msgraph/catalog.py b/plugin/src/msgraph/catalog.py index 9ca494b..d3ea634 100644 --- a/plugin/src/msgraph/catalog.py +++ b/plugin/src/msgraph/catalog.py @@ -79,7 +79,9 @@ "List recent messages from a single folder, agent-legibly. Defaults to the Inbox — what " "actually needs triage, not already-filed mail across every folder. Use for " "triage/overview; pass folder to inspect another folder. concise (default) returns " - "readable summaries; detailed adds IDs for follow-up calls. Requires read sign-in." + "readable summaries; detailed adds IDs plus each message's isRead (boolean) and " + "categories (list of names, [] if none) for follow-up calls — e.g. filtering unread, " + "category-tagged mail. Requires read sign-in." ), "annotations": { "readOnlyHint": True, diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index 27fe61f..e62f9f7 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -121,7 +121,7 @@ def cmd_mail_list(args) -> int: token = tok["access_token"] folder = getattr(args, "folder", None) or "inbox" folder_id, label = graph._resolve_folder(token, folder) - sel = "id,subject,from,receivedDateTime" + sel = "id,subject,from,receivedDateTime,isRead,categories" fid = urllib.parse.quote(folder_id, safe="") try: data = runtime._graph_get( diff --git a/tests/test_client.py b/tests/test_client.py index 2ee7058..b20719f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -261,6 +261,65 @@ def test_mail_list_shaping_and_limit(self): self.assertEqual(rec.methods(), ["GET"]) self.assertIn("$top=10", rec.calls[0][1]) + def test_mail_list_detailed_includes_isread_and_categories(self): + # 001-mail-list-fields: --format detailed must surface per-message isRead/categories so a + # read-only client can filter unread, category-tagged mail without extra Graph calls. + self._sign_in("Mail.Read MailboxSettings.Read offline_access") + payload = { + "value": [ + { + "id": "m1", + "subject": "Unread tagged", + "from": {"emailAddress": {"address": "a@x.com"}}, + "receivedDateTime": "2026-06-01T00:00:00Z", + "isRead": False, + "categories": ["Follow Up"], + }, + { + "id": "m2", + "subject": "Read, no categories", + "from": {"emailAddress": {"address": "b@x.com"}}, + "receivedDateTime": "2026-06-02T00:00:00Z", + "isRead": True, + "categories": [], + }, + ] + } + rec = _HttpRecorder(lambda method, url, **kw: payload) + runtime._http = rec + out = self._capture(client.cmd_mail_list, _Args(limit=10, format="detailed", folder="inbox")) + messages = json.loads(out) + self.assertEqual(messages[0]["isRead"], False) + self.assertEqual(messages[0]["categories"], ["Follow Up"]) + self.assertEqual(messages[1]["isRead"], True) + # Uncategorized message must report [] — never omitted or null (FR-006). + self.assertIn("categories", messages[1]) + self.assertEqual(messages[1]["categories"], []) + self.assertIsNotNone(messages[1]["categories"]) + + def test_mail_list_concise_format_unchanged(self): + # 001-mail-list-fields FR-003 regression guard: concise output must not gain isRead/categories. + self._sign_in("Mail.Read MailboxSettings.Read offline_access") + payload = { + "value": [ + { + "id": "m1", + "subject": "Hello", + "from": {"emailAddress": {"address": "a@x.com"}}, + "receivedDateTime": "2026-06-01T00:00:00Z", + "isRead": False, + "categories": ["Follow Up"], + } + ] + } + rec = _HttpRecorder(lambda method, url, **kw: payload) + runtime._http = rec + out = self._capture(client.cmd_mail_list, _Args(limit=10, format="concise", folder="inbox")) + self.assertIn("Hello", out) + self.assertNotIn("isRead", out) + self.assertNotIn("Follow Up", out) + self.assertNotIn("categories", out) + def test_mail_list_defaults_to_inbox_scope(self): # No-args (folder omitted) must read the inbox-scoped path, not all-folders /me/messages. self._sign_in("Mail.Read MailboxSettings.Read offline_access") diff --git a/tests/test_url_construction.py b/tests/test_url_construction.py index d6c43b4..8fa3a77 100644 --- a/tests/test_url_construction.py +++ b/tests/test_url_construction.py @@ -16,6 +16,7 @@ import tempfile import time import unittest +import urllib.parse import urllib.request from pathlib import Path @@ -119,6 +120,17 @@ def test_mail_list_url_is_valid(self): for url in self.urls: _assert_valid_url(self, url) + def test_mail_list_select_includes_isread_and_categories(self): + # 001-mail-list-fields: the $select sent to Graph must request isRead/categories so + # --format detailed can surface them without an extra round-trip. + self._run(client.cmd_mail_list, _Args(limit=10, format="detailed")) + self.assertTrue(self.urls) + url = self.urls[0] + select = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)["$select"][0] + fields = select.split(",") + self.assertIn("isRead", fields) + self.assertIn("categories", fields) + def test_rule_verify_fetch_url_is_valid(self): self._run(client.cmd_rule_verify, _Args(header_contains=["List-Unsubscribe"], format="concise")) self.assertTrue(self.urls) From b007c9ca671fe83b25db6ce0d25f132fc49b9395 Mon Sep 17 00:00:00 2001 From: Neil Foster Date: Thu, 23 Jul 2026 09:12:00 +0000 Subject: [PATCH 2/2] fix: only request isRead/categories in mail-list $select for detailed format Concise mail-list calls don't render isRead/categories, so requesting them in $select wasted bandwidth on every concise call. Now the extra fields are only added to the Graph $select when --format detailed is used; concise keeps the original minimal select. Addresses adversarial-review efficiency note on PR #22. --- plugin/src/msgraph/verbs.py | 4 +++- tests/test_url_construction.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/plugin/src/msgraph/verbs.py b/plugin/src/msgraph/verbs.py index e62f9f7..31254ca 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -121,7 +121,9 @@ def cmd_mail_list(args) -> int: token = tok["access_token"] folder = getattr(args, "folder", None) or "inbox" folder_id, label = graph._resolve_folder(token, folder) - sel = "id,subject,from,receivedDateTime,isRead,categories" + sel = "id,subject,from,receivedDateTime" + if args.format == "detailed": + sel += ",isRead,categories" fid = urllib.parse.quote(folder_id, safe="") try: data = runtime._graph_get( diff --git a/tests/test_url_construction.py b/tests/test_url_construction.py index 8fa3a77..0aa4b90 100644 --- a/tests/test_url_construction.py +++ b/tests/test_url_construction.py @@ -131,6 +131,17 @@ def test_mail_list_select_includes_isread_and_categories(self): self.assertIn("isRead", fields) self.assertIn("categories", fields) + def test_mail_list_select_omits_isread_and_categories_for_concise(self): + # Efficiency: concise format doesn't render isRead/categories, so the $select sent to Graph + # shouldn't request them either — no wasted bandwidth on every concise call. + self._run(client.cmd_mail_list, _Args(limit=10, format="concise")) + self.assertTrue(self.urls) + url = self.urls[0] + select = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query)["$select"][0] + fields = select.split(",") + self.assertNotIn("isRead", fields) + self.assertNotIn("categories", fields) + def test_rule_verify_fetch_url_is_valid(self): self._run(client.cmd_rule_verify, _Args(header_contains=["List-Unsubscribe"], format="concise")) self.assertTrue(self.urls)