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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.specify/feature.json merge=ours
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
4 changes: 3 additions & 1 deletion plugin/src/msgraph/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions plugin/src/msgraph/verbs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 23 additions & 0 deletions tests/test_url_construction.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import tempfile
import time
import unittest
import urllib.parse
import urllib.request
from pathlib import Path

Expand Down Expand Up @@ -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)
Expand Down
Loading