From 1b324d4674c6b8d76c4b156f6b6ea3975af6e605 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Thu, 5 Nov 2026 21:02:39 +0300 Subject: [PATCH 1/2] fix(mail): OR the values of one mail list --from/--to flag Several --from or --to values, repeated or comma-separated, were joined into the KQL with AND, which matches nothing. Each flag's values now form one group, (from:a OR from:b); a single value stays from:a. --search, the groups and the received date terms are still joined with AND, and --search is parenthesised when other terms join it, so an OR in it stays inside it. A bare domain such as @example.com passes through as from:@example.com. --- CHANGELOG.md | 8 +++ skills/mgraphctl/reference/commands/mail.md | 14 +++-- src/mgraphctl/commands/mail.py | 10 +++- src/mgraphctl/graph/mail.py | 20 ++++++- tests/test_cli_mail.py | 60 +++++++++++++++++++-- tests/test_mcp_schema.py | 2 +- 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b537d0..d334229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [Unreleased] + +- `mail list --from` and `--to` match any of their values. Several values, repeated or + comma-separated, become one OR group, `(from:a OR from:b)`, where they used to be ANDed and match + nothing. `--search`, the `--from` group, the `--to` group and the dates are still ANDed, and + `--search` is now parenthesised when other terms join it, so `--search "a OR b" --from x` keeps + its OR inside the search. A bare domain such as `@example.com` matches every address at it. + ## [0.4.0] — 2026-09-08 - An MCP server, `mgraphctl mcp serve`, exposing the CLI's verbs as tools to any MCP client. The diff --git a/skills/mgraphctl/reference/commands/mail.md b/skills/mgraphctl/reference/commands/mail.md index 4cd1d5a..1da9062 100644 --- a/skills/mgraphctl/reference/commands/mail.md +++ b/skills/mgraphctl/reference/commands/mail.md @@ -9,8 +9,8 @@ are in [`../commands.md`](../commands.md). |---|---|---| | `--folder NAME\|ID\|all` | `inbox` | Folder name, well-known name, id, or `all` for the whole mailbox. | | `--unread` | off | Only unread messages. | -| `--from ADDR` | — | Sender address. Repeatable. | -| `--to ADDR` | — | Recipient address. Repeatable. | +| `--from ADDR[,ADDR]` | — | Sender address or `@domain`. Repeatable; values match as OR. | +| `--to ADDR[,ADDR]` | — | Recipient address or `@domain`. Repeatable; values match as OR. | | `--search KQL` | — | KQL query. | | `--after DT` | — | Only messages received after this. | | `--before DT` | — | Only messages received before this. | @@ -26,7 +26,15 @@ are in [`../commands.md`](../commands.md). `$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,importance,bodyPreview,conversationId,webLink,inferenceClassification` and `Prefer: outlook.timezone`. - **Scopes:** `Mail.Read` -- **Notes:** in search mode `--unread` is applied client-side, because KQL has no `isRead` term. +- **Notes:** `--from` and `--to` take repeated flags, comma lists or both; segments are trimmed + and empty ones dropped. One value becomes `from:a`; several become `(from:a OR from:b)`. A bare + domain such as `@example.com` passes through as `from:@example.com` and matches every address at + it. `--search`, the `--from` group, the `--to` group and the dates are joined with ` AND `, in + that order; `--search` is parenthesised when anything else joins it, so an `OR` inside it stays + inside it. `--search "budget OR forecast" --from a@x.com --from @y.com --to z@w.com --after + 2026-11-01` sends + `$search="(budget OR forecast) AND (from:a@x.com OR from:@y.com) AND to:z@w.com AND received>=2026-11-01"`. + In search mode `--unread` is applied client-side, because KQL has no `isRead` term. So are `--after`/`--before` when they carry a time of day: KQL's `received` compares on the calendar date only, so `--after 2026-09-02T14:00` reaches Graph as `received>=2026-09-02` and the earlier part of that day is dropped here. A date-only bound needs no such pass and gets none. diff --git a/src/mgraphctl/commands/mail.py b/src/mgraphctl/commands/mail.py index 5c1521c..ec65c73 100644 --- a/src/mgraphctl/commands/mail.py +++ b/src/mgraphctl/commands/mail.py @@ -207,10 +207,16 @@ def list_( folder: FolderOpt = "inbox", unread: Annotated[bool, typer.Option("--unread", help="Only unread messages.")] = False, from_: Annotated[ - list[str] | None, typer.Option("--from", help="Sender address (repeatable).") + list[str] | None, + typer.Option( + "--from", help="Sender address or @domain; repeatable, comma-separated, ORed." + ), ] = None, to: Annotated[ - list[str] | None, typer.Option("--to", help="Recipient address (repeatable).") + list[str] | None, + typer.Option( + "--to", help="Recipient address or @domain; repeatable, comma-separated, ORed." + ), ] = None, search: Annotated[str | None, typer.Option("--search", help="KQL query.")] = None, after: Annotated[str | None, typer.Option("--after", help="Only messages after this.")] = None, diff --git a/src/mgraphctl/graph/mail.py b/src/mgraphctl/graph/mail.py index 0a2f82a..791f46a 100644 --- a/src/mgraphctl/graph/mail.py +++ b/src/mgraphctl/graph/mail.py @@ -177,6 +177,20 @@ def keep(message: dict) -> bool: return keep +def _address_term(prop: str, addresses: list[str]) -> str | None: + """One KQL term matching any of `addresses` on `prop` (`from` or `to`). + + A single address is `prop:a`; several are `(prop:a OR prop:b)`, parenthesised so the + surrounding ` AND ` joins treat the group as one term. Values pass through verbatim, so a + bare domain such as `@example.com` matches every address at it. + """ + if not addresses: + return None + if len(addresses) == 1: + return f"{prop}:{addresses[0]}" + return "(" + " OR ".join(f"{prop}:{a}" for a in addresses) + ")" + + def list_messages( client: GraphClient, *, @@ -199,12 +213,14 @@ def list_messages( path = messages_path(folder_id) params: dict[str, object] = {"$select": select or LIST_SELECT} if search or senders or recipients: - terms = [search] if search else [] - terms += [f"from:{a}" for a in senders] + [f"to:{a}" for a in recipients] + terms = [t for t in (_address_term("from", senders), _address_term("to", recipients)) if t] if after: terms.append(f"received>={kql_date(after, tz)}") if before: terms.append(f"received<={kql_date(before, tz)}") + if search: + # Parenthesised beside other terms, so an OR inside the user's KQL stays inside it. + terms.insert(0, f"({search})" if terms else search) params["$search"] = odata.kql(*terms) page = client.paginate( path, diff --git a/tests/test_cli_mail.py b/tests/test_cli_mail.py index 2fe750c..47b8b2c 100644 --- a/tests/test_cli_mail.py +++ b/tests/test_cli_mail.py @@ -96,7 +96,7 @@ def test_mail_list_envelope_reports_the_window_and_the_fetch(invoke, graph): # The client-side unread pass dropped one, so `fetched` exceeds `count`. assert doc["count"] == 1 and doc["fetched"] == 2 assert doc["cap"] == 10 # the default --limit, which is the bound the fetch ran under - assert doc["query"]["$search"] == '"budget AND received>=2026-08-01 AND received<=2026-08-31"' + assert doc["query"]["$search"] == '"(budget) AND received>=2026-08-01 AND received<=2026-08-31"' @covers("mail list") @@ -121,13 +121,67 @@ def test_mail_list_search_mode_and_unread_client_side(invoke, graph): ) assert result.exit_code == 0, result.stderr params = route.calls.last.request.url.params - assert params["$search"] == '"budget AND from:ada@example.com AND received>=2026-08-01"' + assert params["$search"] == '"(budget) AND from:ada@example.com AND received>=2026-08-01"' assert params["$top"] == "25" and params["$select"] == LIST_SELECT assert "$orderby" not in params and "$filter" not in params doc = json.loads(result.stdout) assert [m["id"] for m in doc["items"]] == ["AAMk-msg-0001"] +@covers("mail list") +@pytest.mark.parametrize( + ("args", "expected"), + [ + (["--from", "a@x.com"], "from:a@x.com"), + (["--from", "a@x.com", "--from", "b@x.com"], "(from:a@x.com OR from:b@x.com)"), + (["--from", "a@x.com,b@x.com"], "(from:a@x.com OR from:b@x.com)"), + ( + ["--from", "a@x.com,b@x.com", "--from", "c@x.com"], + "(from:a@x.com OR from:b@x.com OR from:c@x.com)", + ), + (["--from", " a@x.com , ,", "--from", " "], "from:a@x.com"), + (["--from", "a@x.com, ,b@x.com "], "(from:a@x.com OR from:b@x.com)"), + (["--from", "@example.com"], "from:@example.com"), + ( + ["--from", "@example.com,x@partner.example"], + "(from:@example.com OR from:x@partner.example)", + ), + (["--to", "a@x.com,b@x.com"], "(to:a@x.com OR to:b@x.com)"), + ( + ["--from", "a@x.com", "--from", "@y.com", "--to", "z@w.com"], + "(from:a@x.com OR from:@y.com) AND to:z@w.com", + ), + ( + ["--from", "a@x.com,b@x.com", "--to", "c@x.com,d@x.com"], + "(from:a@x.com OR from:b@x.com) AND (to:c@x.com OR to:d@x.com)", + ), + ( + ["--search", "budget", "--from", "a@x.com,@y.com"], + "(budget) AND (from:a@x.com OR from:@y.com)", + ), + (["--search", "budget OR forecast"], "budget OR forecast"), + ( + ["--search", "budget OR forecast", "--from", "a@x.com,b@x.com"], + "(budget OR forecast) AND (from:a@x.com OR from:b@x.com)", + ), + ( + ["--from", "a@x.com,@y.com", "--after", "2026-11-01", "--before", "2026-11-30"], + "(from:a@x.com OR from:@y.com) AND received>=2026-11-01 AND received<=2026-11-30", + ), + ( + ["--search", "budget", "--to", "a@x.com", "--to", "b@x.com", "--after", "2026-11-01"], + "(budget) AND (to:a@x.com OR to:b@x.com) AND received>=2026-11-01", + ), + ], +) +def test_mail_list_address_values_or_within_a_flag(invoke, graph, args, expected): + """Values of one --from/--to flag are ORed; the flags, --search and dates are ANDed.""" + route = graph.get(INBOX).mock(return_value=httpx.Response(200, json={"value": []})) + result = invoke("mail", "list", *args, "--json") + assert result.exit_code == 0, result.stderr + assert route.calls.last.request.url.params["$search"] == f'"{expected}"' + + @covers("mail list") def test_mail_list_search_mode_refilters_a_bound_with_a_time(invoke, graph): """KQL compares dates only, so a bound with a time of day is re-applied client-side.""" @@ -145,7 +199,7 @@ def test_mail_list_search_mode_refilters_a_bound_with_a_time(invoke, graph): result = invoke("mail", "list", "--search", "budget", "--after", "2026-08-31T12:00", "--json") assert result.exit_code == 0, result.stderr params = route.calls.last.request.url.params - assert params["$search"] == '"budget AND received>=2026-08-31"' + assert params["$search"] == '"(budget) AND received>=2026-08-31"' doc = json.loads(result.stdout) assert [m["id"] for m in doc["items"]] == ["AAMk-late"] diff --git a/tests/test_mcp_schema.py b/tests/test_mcp_schema.py index 54dfa57..5380757 100644 --- a/tests/test_mcp_schema.py +++ b/tests/test_mcp_schema.py @@ -34,7 +34,7 @@ def test_a_repeatable_option_becomes_an_array(commands): assert doc["properties"]["to"] == { "type": "array", "items": {"type": "string"}, - "description": "Recipient address (repeatable). (--to)", + "description": "Recipient address or @domain; repeatable, comma-separated, ORed. (--to)", } From 3d7a5908c5a562fb9d76ce58c05ca9ffca8213b1 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Thu, 5 Nov 2026 21:11:25 +0300 Subject: [PATCH 2/2] docs: use a synthetic search term in the Node feature inventory --- docs/research/2026-09-02-msgraph-node-feature-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/research/2026-09-02-msgraph-node-feature-inventory.md b/docs/research/2026-09-02-msgraph-node-feature-inventory.md index 05caa33..6e9394d 100644 --- a/docs/research/2026-09-02-msgraph-node-feature-inventory.md +++ b/docs/research/2026-09-02-msgraph-node-feature-inventory.md @@ -382,7 +382,7 @@ None. No debug flag, no request logging. stderr carries only errors and the pagi ### 8.1 `tests/` (untracked; Node built-in runner) - Framework: `node:test` + `node:assert/strict`; CLI executed out-of-process with `spawnSync(process.execPath, [msgraph.js, …])` because the script runs `main()` at load. - `helpers.js`: duplicates the fixture hashing scheme (`" "` → sha256 hex[0:16] → `/.json`); `writeFixture(dir, method, urlOrPath, entries)`, `jsonFixture(status, obj)`, `makeFixtureDir()` (os.tmpdir), `runCli(argv, fixtureDir, extraEnv)` (sets `MSGRAPH_FIXTURE_DIR`, `MSGRAPH_RECORD=''`). -- `compat.test.js` (14 tests): "compatibility baseline" — for each documented command shape writes the fixture into the **checked-in** `tests/fixtures/compat//` (rewriting it on every run), runs plain and `--json`, asserts exit 0 and `stdout === tests/fixtures/snapshots/.snap`. Shapes: `status`; `emails` list / `--search "invoice Q4"` / `--read AAMk-msg-0001`; `calendar --after 2026-07-01 --before 2026-07-21`; `teams --chats` / `--search PPG` / `--messages chat-0002`; `channels --team-id team-0001 --list`; `sharepoint --resolve-site contoso.sharepoint.com:/sites/Engineering` / `--site `; `transcripts --start 2026-07-08` / `--meeting meeting-0001`; `onedrive` list. All data synthetic (`example.com`, `contoso.sharepoint.com`). `GENERATE_SNAPSHOTS=1` regenerates. +- `compat.test.js` (14 tests): "compatibility baseline" — for each documented command shape writes the fixture into the **checked-in** `tests/fixtures/compat//` (rewriting it on every run), runs plain and `--json`, asserts exit 0 and `stdout === tests/fixtures/snapshots/.snap`. Shapes: `status`; `emails` list / `--search "invoice Q4"` / `--read AAMk-msg-0001`; `calendar --after 2026-07-01 --before 2026-07-21`; `teams --chats` / `--search Contoso` / `--messages chat-0002`; `channels --team-id team-0001 --list`; `sharepoint --resolve-site contoso.sharepoint.com:/sites/Engineering` / `--site `; `transcripts --start 2026-07-08` / `--meeting meeting-0001`; `onedrive` list. All data synthetic (`example.com`, `contoso.sharepoint.com`). `GENERATE_SNAPSHOTS=1` regenerates. - `fixture-mode.test.js` (5 tests): single fixture replay (`me --json`); array fixture consumed sequentially across `graphGetAll` pagination (`emails --all --json` with a self-referencing nextLink); missing fixture → exit 1 + `No fixture recorded for GET /v1.0/me (hash …)`; `status` bypasses cache; fixture `status: 404` surfaces as `Error: Resource not found`. - How to run: `cd plugins/msgraph && node --test tests/compat.test.js tests/fixture-mode.test.js` (on Node 26 `node --test tests/` fails — the directory is treated as a test file; bare `node --test` also discovers `**/*.test.js`). Not wired into CI or any npm script. - Results observed: **19/19 pass** on the author's machine (UTC+3). Under `TZ=UTC` **2 fail** (`calendar`, `transcripts --start`) because the snapshots baked in the local-time rendering of zone-less datetimes (`09:00` → `06:00`). `status.snap` embeds the author's absolute `~/.ms_graph_token_cache.json` path, so it fails for any other user/home.