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..31254ca 100644 --- a/plugin/src/msgraph/verbs.py +++ b/plugin/src/msgraph/verbs.py @@ -122,6 +122,8 @@ def cmd_mail_list(args) -> int: folder = getattr(args, "folder", None) or "inbox" folder_id, label = graph._resolve_folder(token, folder) 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_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..0aa4b90 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,28 @@ 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_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)