Guide

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 Software Engineer

VerifiedCLI 3.1.22 · Outlook · last tested June 19, 2026

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, 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.

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 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.

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." 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 guide for the exact response body.

# 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 doesStructured boolean predicates on message propertiesFree-text relevance match via the search index
ExampleisRead eq false"subject:invoice"
When to useExact values, date ranges, sortable jobsKeyword hunts, fuzzy human queries
SortingWorks with $orderbyRelevance only; $orderby rejected
Case sensitivityCase-sensitive string comparesCase-insensitive
What it can't doNo relevance ranking or full-body text searchCan'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.

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 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.

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.

# 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