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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/research/2026-09-02-msgraph-node-feature-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`"<METHOD> <pathname><search>"` → sha256 hex[0:16] → `<dir>/<hash>.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/<slug>/` (rewriting it on every run), runs plain and `--json`, asserts exit 0 and `stdout === tests/fixtures/snapshots/<name>.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 <id>`; `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/<slug>/` (rewriting it on every run), runs plain and `--json`, asserts exit 0 and `stdout === tests/fixtures/snapshots/<name>.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 <id>`; `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.
Expand Down
14 changes: 11 additions & 3 deletions skills/mgraphctl/reference/commands/mail.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/mgraphctl/commands/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions src/mgraphctl/graph/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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,
Expand Down
60 changes: 57 additions & 3 deletions tests/test_cli_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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."""
Expand All @@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_mcp_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
}


Expand Down
Loading