# Filter Before You Fetch: Graph Mail Notifications That Carry Their Own Metadata

- Date: 2026-09-25
- Author: Jeppe Spanggaard
- Description: Learn how to use Microsoft Graph rich notifications with $select to decide from the webhook payload whether a mail is worth fetching, without an extra API call.
- URL: https://jeppe-spanggaard.dk/blogs/graph-mail-rich-notifications-filter-before-fetch/
- Tags: csharp, Microsoft Graph, Outlook Add-ins, Webhooks

## TL;DR

Create the subscription with `includeResourceData = true` and a `$select` on the resource URL listing the fields your rule needs (sender, recipients, subject...). Graph then delivers those fields inside the notification, encrypted with a certificate you provide. Run your rule on the decrypted payload, and only call Graph for the body when a message actually matches. Zero API calls for the noise.


I built a service that watches a few shared mailboxes and reacts when mail arrives from (or goes to) specific partner addresses. Classic Graph webhook territory: subscribe to the folder, get a notification per message, done.

Except a basic Graph notification is nearly empty. You get a message ID and not much else. To find out *who sent it*, you call Graph. And 99% of the mail in those mailboxes is noise, so I'd be making a `GET` per message just to conclude "don't care" - burning through throttling budget to ignore things.

The fix is to make the notification itself carry enough data to decide. Graph calls this rich notifications, and combined with `$select` it turns the webhook payload into a pre-filter.

## The Subscription Is the Contract

Everything is decided at subscription time. The `$select` on the resource URL is what your notifications will contain, no more, no less:

```csharp
var subscription = new Subscription
{
    ChangeType = "created",
    NotificationUrl = $"{baseUrl}/api/notifications",
    LifecycleNotificationUrl = $"{baseUrl}/api/lifecycle",
    Resource = $"/users/{mailbox}/mailFolders('{folder}')/messages" +
               "?$select=id,subject,from,toRecipients,ccRecipients," +
               "receivedDateTime,hasAttachments,importance,internetMessageId",
    IncludeResourceData = true,
    EncryptionCertificate = publicKeyBase64,   // Graph encrypts the payload with this
    EncryptionCertificateId = "notifications-2026",
    ClientState = clientState,
    ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(23)
};
await graph.Subscriptions.PostAsync(subscription);
```

**What's happening here?**

1. `IncludeResourceData = true` upgrades the subscription from "something changed" pings to payloads carrying the selected fields of the message itself.
2. The `$select` is the whole trick: addressing fields, subject, flags - everything my rule needs, and nothing heavy. Graph refuses `$select=Body` here; the body can never travel in a notification.
3. Rich payloads arrive encrypted, so you hand Graph a public key at subscription time (a self-signed cert is fine) and keep the private key to decrypt. That's a post of its own; here it's one method call.
4. I create one of these per mailbox for `inbox` *and* one for `sentitems`, so both directions of the conversation are covered.

## The Gate

When a notification lands (validated, decrypted - hand-waved here), the payload is just the message JSON with my selected fields. The decision runs entirely on it:

```csharp
string json = decryptor.Decrypt(ec.Data, ec.DataKey, ec.DataSignature, cert);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;

string? sender = GetAddress(root, "from");
var recipients = GetAddresses(root, "toRecipients")
    .Concat(GetAddresses(root, "ccRecipients")).ToList();

if (!policy.ShouldReadBody(sender, recipients)) return; // 99% stop here. Free.

// Only a match earns a Graph call - and only for what the payload can't carry.
var full = await graph.Users[mailbox].Messages[messageId].GetAsync(rc =>
{
    rc.QueryParameters.Select = new[] { "subject", "body" };
    rc.Headers.Add("Prefer", "outlook.body-content-type=\"text\"");
});
```

The policy itself is boring on purpose: read the body if the sender or any recipient matches a configured address or domain. One rule works for both folders - on received mail the interesting address is the sender, on sent mail it's a recipient, and checking all of them covers both without branching on folder.

That `Prefer` header saves a second round of work: Graph converts the body to plain text server-side, so a rule engine or an LLM downstream doesn't choke on 40 KB of Outlook HTML.

## What This Buys You

With basic notifications, a mailbox receiving 500 mails a day where 5 matter costs 500 `GET`s. With the payload gate it costs 5. The other 495 decisions happen in memory on data that was pushed to you anyway.

It also changes what a throttling incident looks like. Message fetches now correlate with *interesting* mail volume, not total mail volume, and interesting mail is what your business case already sized for.

## Gotchas

- **Rich subscriptions live under a day.** Basic Outlook subscriptions get up to 7 days; set `includeResourceData = true` and the max drops to 1440 minutes. You need a renewal loop (a timed `PATCH` on `expirationDateTime`) and a `lifecycleNotificationUrl` for the events that tell you when renewal isn't enough.
- **The body is not an option.** `$select=Body,UniqueBody` is explicitly rejected on the resource URL, along with `$top`, `$skip`, `$orderby` and most `$expand`. The payload gate can never be "check if the body contains X" - design the rule around addressing fields and subject.
- **Answer the webhook fast, think later.** Graph wants a quick 202; do the decrypt-gate-fetch work on a background queue, not in the request handler.
- **`Mail.Read` as an application permission is tenant-wide.** Scope it down with an Exchange Application Access Policy so the app can only touch the mailboxes it watches. Do this before someone asks in a security review, not after.
- **New messages only.** A subscription delivers changes from creation onward. For history, or after a `missed` lifecycle event, reconcile with a delta query per folder.

## Wrapping Up

Put your filter's inputs in the subscription's `$select`, and the notification becomes the filter. Fetch the full message only for the few that pass. When the data you need to *decide* is small and the data you need to *act* is big, let the webhook carry the first and pay an API call for the second only when it's earned.

