Source: https://cli.nylas.com/guides/microsoft-graph-mail-query

# Microsoft Graph Mail Query Filters

Two ways to query Outlook mail with Microsoft Graph: $filter runs structured OData predicates, $search runs free-text KQL. They cannot appear in the same request. This guide shows how to compose each one against /me/messages, the UTC date rules, paging with $top and @odata.nextLink, and a single CLI query that maps to both.

Written by [Pouya Sanooei](https://cli.nylas.com/authors/pouya-sanooei) Software Engineer

Updated June 19, 2026

> **TL;DR:** Microsoft Graph gives you two query languages on `/me/messages`. Use `$filter` for structured predicates like `isRead eq false` or a date range, and `$search` for free-text keyword matching like `"subject:invoice"`. The documented hard rule: `$search` cannot be combined with `$filter` or `$orderby` in the same request. To skip the OData-versus-KQL choice, one `nylas email search` call expresses both intents and maps them per provider.

> **Related paths:** Pair this with the [Microsoft Graph email quickstart](https://cli.nylas.com/guides/microsoft-graph-email-quickstart) for app registration and auth, [Graph API error codes](https://cli.nylas.com/guides/graph-api-error-codes) for the responses a malformed query returns, and [Gmail API search query examples](https://cli.nylas.com/guides/gmail-api-search-query) for the same problem on Google's side.

## What is a Microsoft Graph mail query?

A Microsoft Graph mail query is a GET request to `/me/messages` with OData system query parameters that narrow, sort, and shape the response. The two that filter messages are `$filter` and `$search`. Graph also accepts `$select`, `$top`, `$skip`, `$orderby`, and `$count` to control fields, page size, and ordering.

These parameters do different jobs. The `$filter` parameter evaluates structured predicates against message properties, so `isRead eq false` returns exactly the unread messages. The `$search` parameter runs a relevance query through Microsoft's search index. According to the Microsoft Graph [query parameters reference](https://learn.microsoft.com/en-us/graph/query-parameters), all OData system query options start with `$`. Picking the wrong one is the most common reason a query returns 0 results or a 400.

## How does $filter work on /me/messages?

The `$filter` parameter applies OData boolean predicates to message properties and returns only the rows that evaluate to true. It supports the comparison operators `eq`, `ne`, `gt`, `ge`, `lt`, and `le`, the logical operators `and`, `or`, and `not`, and the function `startswith()` for prefix matching on string properties.

Two property shapes trip people up. Dates use `receivedDateTime` and must be UTC ISO 8601 with a trailing `Z`, so `receivedDateTime ge 2026-01-01T00:00:00Z` reads everything from January 1 onward. Sender is a nested object, so you filter on the path `from/emailAddress/address`, not a flat `from` field. The example below combines 3 predicates with `and` to return unread mail from one sender since the start of 2026, ordered newest-first. Because `$filter` and `$orderby` are compatible, this single request returns a sorted, filtered page.

One detail saves debugging time: the query parameters reference recommends that the property used in `$orderby` also appear in `$filter` for some combinations, and Graph may require the `ConsistencyLevel: eventual` header when an `$orderby` column is not the same property the filter ranges over. If a sorted, filtered request returns a 400, add that header before changing the predicate. String compares in `$filter` are also case-sensitive, so `from/emailAddress/address eq 'Boss@co.com'` will not match a stored lowercase address.

```bash
curl -s -G "https://graph.microsoft.com/v1.0/me/messages" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode "\$filter=isRead eq false and from/emailAddress/address eq 'boss@co.com' and receivedDateTime ge 2026-01-01T00:00:00Z" \
  --data-urlencode "\$orderby=receivedDateTime desc" \
  --data-urlencode "\$select=subject,from,receivedDateTime" \
  --data-urlencode "\$top=25" \
  | jq '.value[] | {subject, received: .receivedDateTime}'
```

## How does $search work on /me/messages?

The `$search` parameter runs a free-text query through Microsoft's search index using KQL (Keyword Query Language). It matches across indexed fields and is case-insensitive, so the 2 queries `$search="invoice"` and `$search="INVOICE"` return the same set. KQL property restrictions let you scope the term: `$search="subject:invoice"` matches the subject line and `$search="from:boss@co.com"` matches the sender.

Two encoding details matter. The whole value goes in double quotes, and a KQL property restriction needs its own inner quotes, so the raw value is `$search="\\"from:boss@co.com\\""` before URL encoding. The search index is eventually consistent, so a message that arrived seconds ago may not surface yet. The Microsoft Graph [search query parameter reference](https://learn.microsoft.com/en-us/graph/search-query-parameter) states that results are sorted by relevance, which is why `$orderby` is rejected alongside `$search`. The example below keeps `$select` and `$top` (both allowed) but uses no `$filter`.

```bash
curl -s -G "https://graph.microsoft.com/v1.0/me/messages" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode '$search="subject:invoice"' \
  --data-urlencode "\$select=subject,from,receivedDateTime" \
  --data-urlencode "\$top=25" \
  | jq '.value[].subject'
```

## Why can't $search combine with $filter or $orderby?

Microsoft Graph rejects a request that uses `$search` together with `$filter` or `$orderby` on mail. The two systems answer different questions: `$search` ranks by relevance from the search index, while `$filter` and `$orderby` impose a deterministic predicate and sort. Graph does not reconcile a relevance ranking with an explicit order, so it returns an error rather than guess.

The search query parameter reference is explicit. It states: ["You can't combine `$orderby` and `$search` in the same request."](https://learn.microsoft.com/en-us/graph/search-query-parameter) The same page documents that mail search relies on the index rather than property comparison. The practical consequence: pick one mode per request. If you need both a keyword and a structured predicate, run a `$search` request, then narrow the returned page client-side, or restructure the keyword as a `$filter` string operator like `startswith(subject,'Invoice')`. The malformed request below returns a 400; see the [Graph API error codes](https://cli.nylas.com/guides/graph-api-error-codes) guide for the exact response body.

```bash
# This request is rejected by Graph (400 Bad Request).
# $search cannot share a request with $orderby (or $filter).
curl -s -G "https://graph.microsoft.com/v1.0/me/messages" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode '$search="invoice"' \
  --data-urlencode "\$orderby=receivedDateTime desc"
```

## $filter vs $search: which one do I use?

Choose `$filter` when you know the exact property values and need a repeatable, sortable result. Choose `$search` when a human is hunting for a keyword and approximate relevance is acceptable. The table below compares both across what they do, when to reach for them, and the constraint each one carries. Roughly 90% of automation jobs want `$filter` because cron and agent runs need deterministic output.

| Dimension | `$filter` (OData) | `$search` (KQL) |
| --- | --- | --- |
| What it does | Structured boolean predicates on message properties | Free-text relevance match via the search index |
| Example | `isRead eq false` | `"subject:invoice"` |
| When to use | Exact values, date ranges, sortable jobs | Keyword hunts, fuzzy human queries |
| Sorting | Works with `$orderby` | Relevance only; `$orderby` rejected |
| Case sensitivity | Case-sensitive string compares | Case-insensitive |
| What it can't do | No relevance ranking or full-body text search | Can't combine with `$filter` or `$orderby` |

## How do I trim fields and page results?

Use `$select` to return only the fields you need and `$top` to set page size. A message object carries dozens of properties, so requesting `$select=subject,from,receivedDateTime` shrinks each row substantially. The `$top` parameter defaults to 10 per page; page through larger result sets by following the `@odata.nextLink` URL Graph returns. Both parameters are compatible with either `$filter` or `$search`.

Paging uses `@odata.nextLink`, an opaque URL Graph returns when more results exist. You follow it as-is rather than building `$skip` yourself; the query parameters reference recommends `@odata.nextLink` over manual `$skip` arithmetic for mail. Some advanced queries, such as returning `$count` or using certain `$filter` and `$orderby` combinations, require the request header `ConsistencyLevel: eventual`. The loop below follows `@odata.nextLink` until the link disappears, capping at 200 messages so a cron job stays bounded.

```bash
url="https://graph.microsoft.com/v1.0/me/messages?\$filter=isRead eq false&\$top=50&\$select=subject,receivedDateTime"
count=0

while [ -n "$url" ] && [ "$count" -lt 200 ]; do
  page=$(curl -s "$url" \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "ConsistencyLevel: eventual")
  echo "$page" | jq -r '.value[].subject'
  count=$((count + $(echo "$page" | jq '.value | length')))
  url=$(echo "$page" | jq -r '."@odata.nextLink" // empty')
done
```

## How do I run one query without OData or KQL?

The [`nylas email search`](https://cli.nylas.com/docs/commands/email-search) command takes a keyword and structured filters, then maps them to a provider-appropriate query. Against an Outlook grant it picks the right Graph approach — `$search` for keywords or `$filter` for predicates, which Graph won't accept in the same request — and against Gmail it builds the equivalent `q` string. You never hand-write the OData-versus-KQL distinction.

This matters because the same script then runs unchanged across providers. A Graph query that hard-codes `from/emailAddress/address` and `receivedDateTime` breaks the moment you point it at a Gmail or IMAP mailbox, since those backends use a different query grammar. The CLI keeps the sender, date, and read-state intent in flags and resolves the per-provider syntax at runtime, which is why a cron job written once works for all 6 provider families the tool supports.

The command below combines a positional query term with 5 verified flags: `--from`, `--after`, `--before`, `--unread`, and `--has-attachment`, scoped to one folder with `--in` and emitted as JSON. That single line covers what would otherwise be a keyword decision (KQL) plus a sender, date, and read-state predicate (OData) that Graph refuses to evaluate in 1 request. The CLI runs them as one bounded query that returns machine-readable output for a script or agent tool.

```bash
nylas email search "invoice" \
  --from boss@co.com \
  --after 2026-01-01 \
  --before 2026-12-31 \
  --unread \
  --has-attachment \
  --in INBOX \
  --json
```

## How do I test a Graph mail query before shipping it?

Test a Graph mail query with a small `$top` first, confirm the shape, then widen. Start at `$top=5`, inspect whether the returned subjects and senders match the intent, and only then raise the cap or add paging. This catches the two failure modes early: an over-broad `$filter` that returns the whole mailbox, and a `$search` term that misses recent mail because the index lagged by a few seconds.

The smoke test below runs both modes against the same mailbox at `$top=5` so you can compare results side by side without a 60-second wait between runs. If the `$filter` result looks right but `$search` comes back empty, the index is still catching up. If `$search` returns mail your `$filter` missed, your predicate property or date format is wrong; recheck the `Z` suffix on `receivedDateTime` and the nested `from/emailAddress/address` path.

```bash
# Mode 1: structured predicate
curl -s -G "https://graph.microsoft.com/v1.0/me/messages" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode "\$filter=isRead eq false" \
  --data-urlencode "\$top=5" | jq '.value[].subject'

# Mode 2: keyword search (separate request)
curl -s -G "https://graph.microsoft.com/v1.0/me/messages" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode '$search="invoice"' \
  --data-urlencode "\$top=5" | jq '.value[].subject'
```

Tested on Nylas CLI 3.1.22 against an Outlook grant on 2026-06-19. The `nylas email search` flags shown here are verified against that release. Graph's `$filter` and `$search` behavior is described from the Microsoft Graph query parameters and search query parameter references, not from a verified end-to-end test against every tenant configuration; confirm against your own mailbox before deploying.

## Next steps

- [Microsoft Graph email quickstart](https://cli.nylas.com/guides/microsoft-graph-email-quickstart) — app registration, OAuth scopes, and basic read and send before you query
- [Graph API error codes](https://cli.nylas.com/guides/graph-api-error-codes) — the 400 and 403 responses a malformed mail query returns and how to fix them
- [Graph API delta query explained](https://cli.nylas.com/guides/graph-api-delta-query-explained) — track only changed messages with delta tokens instead of refiltering
- [Gmail API search query examples](https://cli.nylas.com/guides/gmail-api-search-query) — the same filtering problem with Gmail's q parameter
- [Email search command](https://cli.nylas.com/docs/commands/email-search) — exact flags for sender, dates, attachments, folders, and JSON output
- [Microsoft Graph mail folders](https://cli.nylas.com/guides/microsoft-graph-mail-folders) — scope queries to a specific mail folder
- [Full command reference](https://cli.nylas.com/docs/commands) — every email, calendar, contact, and audit command

## Related hubs

- [Email agents](https://cli.nylas.com/ai-answers/email-agents.md)
- [Calendar agents](https://cli.nylas.com/ai-answers/calendar-agents.md)
- [Scheduling and availability agents](https://cli.nylas.com/ai-answers/scheduling-agents.md)
- [Contacts agents](https://cli.nylas.com/ai-answers/contacts-agents.md)
- [Notetaker and meeting agents](https://cli.nylas.com/ai-answers/notetaker-agents.md)
- [MCP agents](https://cli.nylas.com/ai-answers/mcp-agents.md)
- [Agent accounts](https://cli.nylas.com/ai-answers/agent-accounts.md)
- [Framework and language email agents](https://cli.nylas.com/ai-answers/framework-email-agents.md)
- [Email and calendar API comparisons](https://cli.nylas.com/ai-answers/ai-agent-email-api-comparisons.md)
- [Email integration and automation recipes](https://cli.nylas.com/ai-answers/email-integration-recipes.md)
- [Agent email workflows](https://cli.nylas.com/ai-answers/agent-email-workflows.md)
- [Security for email and calendar agents](https://cli.nylas.com/ai-answers/security-for-email-agents.md)
- [Operations runbooks for agents](https://cli.nylas.com/ai-answers/operations-for-email-calendar-agents.md)

## Try Nylas CLI

Install the CLI with `curl -fsSL https://cli.nylas.com/install.sh | bash` (macOS, Linux, WSL) or `brew install nylas/nylas-cli/nylas`, then run `nylas init` to create an account and authenticate.

**Free Sandbox** (no credit card): 5 connected accounts — bring your own Gmail, Outlook, Yahoo, iCloud, Exchange, or IMAP — plus 3 agent accounts (managed inboxes on `*.nylas.email`). Agent free plan: 3 GB storage, unlimited inbound, 200 sent emails/day, 5 rules, 1 `*.nylas.email` subdomain, and unlimited custom domains. Production is uncapped and requires a credit card: https://www.nylas.com/pricing/
