How to find large mailbox items without downloading them all

The obvious move is to pull every message and sort by size in PowerShell. Do that against a 90,000-item mailbox and you'll hit throttling limits. The better move: make the server do the filtering…

2 a.m., a VIP mailbox is bumping against its quota, and someone wants to know which items are the problem before the archive policy eats something a lawyer cared about. The obvious move is to pull every message and sort by size in PowerShell. Do that against a 90,000-item mailbox and you’ll spend the small hours watching 429 Too Many Requests scroll past while Graph politely rate-limits you into next week.

The better move is to make the server do the filtering. It has always been possible — it’s just been buried behind a property most people never look at.

The long-standing catch: the property you want can’t be filtered

The message resource in Microsoft Graph has a perfectly good size property. It returns the item size in bytes. It looks like exactly what you need.

You cannot $filter on it. Only a small subset of message properties support server-side filtering, and size isn’t one of them. So the naive query — “give me messages where size is over 10 MB” — gets rejected, and everyone quietly falls back to downloading everything and running Where-Object { $_.Size -gt 10MB } locally. Which works, and which throttles, and which is slow, in exactly that order.

The workaround has been sitting in MAPI since the 1990s: PR_MESSAGE_SIZE, property tag 0x0E08, a plain 32-bit integer holding the message size in bytes. Graph exposes MAPI properties through extended properties, and — this is the part that matters — you can filter on an extended property’s value. Same number, different door, and this door is unlocked.

September 22, 2026: Practical365 publishes the worked example

The Practical365 walkthrough that landed on the 22nd is the tidy version of a trick that has circulated in support forums for years: construct a $filter against singleValueExtendedProperties, reference the old PR_MESSAGE_SIZE tag, cast its value to an integer, and compare. The predicate runs on Exchange Online’s side. You get back only the items over your threshold — not the mailbox and then a sad little Where-Object.

The filter reads like this once you know the shape of it:

notes.txtText
singleValueExtendedProperties/any(ep: ep/id eq 'Integer 0x0E08' and cast(ep/value, Edm.Int32) gt 10485760)

The any() is OData’s lambda for collections — “does any extended property on this message match both conditions.” The first condition pins the property by its ID (Integer 0x0E08); the second casts its string value to Edm.Int32 and compares. 10485760 is 10 MB in bytes. That’s the whole idea.

Today: the version that runs in production

Get-MgUserMessage lives in the Microsoft.Graph.Mail sub-module. Confirm what you’ve actually got installed before you trust any of this — module churn is a way of life here:

audit.ps1PowerShell
Get-Module Microsoft.Graph.Mail -ListAvailable | Select-Object Name, Version

Then the query. Read-only, but I’m building the output as objects so you can review before you do anything destructive with the results — never pipe a live query straight into a delete:

audit.ps1PowerShell
Connect-MgGraph -Scopes 'Mail.Read' -NoWelcome
​
$user           = '[email protected]'
$thresholdBytes = 10MB          # PowerShell understands 10MB natively
$propId         = 'Integer 0x0E08'   # PR_MESSAGE_SIZE
​
# Server-side predicate: only items larger than the threshold come back
$filter = "singleValueExtendedProperties/any(ep: ep/id eq '$propId' " +
          "and cast(ep/value, Edm.Int32) gt $($thresholdBytes -as [int]))"
​
# Ask Graph to return the size property alongside each hit
$expand = "singleValueExtendedProperties(`$filter=id eq '$propId')"
​
try {
    $messages = Get-MgUserMessage -UserId $user `
        -Filter $filter `
        -ExpandProperty $expand `
        -Property 'subject,receivedDateTime,id' `
        -PageSize 100 `
        -All `
        -ErrorAction Stop
}
catch {
    Write-Error "Query failed for $user : $($_.Exception.Message)"
    return
}
​
$results = foreach ($msg in $messages) {
    $sizeProp = $msg.SingleValueExtendedProperties |
                Where-Object { $_.Id -eq $propId }
​
    [pscustomobject]@{
        Subject  = $msg.Subject
        Received = $msg.ReceivedDateTime
        SizeMB   = if ($sizeProp) { [math]::Round(($sizeProp.Value / 1MB), 2) } else { $null }
        Id       = $msg.Id
    }
}
​
$results | Sort-Object SizeMB -Descending | Format-Table -AutoSize
Write-Host "Found $($results.Count) item(s) over $($thresholdBytes / 1MB) MB in $user"

A few things earn their keep here. -All handles pagination for you — it follows the @odata.nextLink until the mailbox runs out, so you’re not hand-rolling a do/while loop around a skip token. -PageSize 100 pulls larger pages than the default (which is small — the messages endpoint hands out 10 at a time if you let it), cutting the number of round trips and the throttling exposure that comes with them. And the try/catch is not decoration: extended-property filters are exactly the kind of query that returns a 400 the moment your escaping is off, and Graph’s error text is genuinely useful when you stop swallowing it.

Note the backtick in the $expand string. That’s escaping the literal $filter inside the OData expand so PowerShell doesn’t try to interpolate a variable that doesn’t exist. Miss it and you’ll spend ten minutes wondering why the expand comes back empty.

Where this stops being clever

The gains are real, but they’re the gains of moving the predicate, not magic. You still page through results. You still get throttled if you fire this at 500 mailboxes in a tight loop — add a small delay and honour any Retry-After header Graph sends back. What you avoid is the far worse pattern of transferring every item’s metadata across the wire just to throw 99% of it away locally.

Three limits worth writing on the whiteboard before someone inherits this script:

  • It’s a 32-bit integer. PR_MESSAGE_SIZE tops out around 2 GB. That’s above any per-item limit you’ll hit in Exchange Online, so it’s academic — but if you ever see a nonsensical size, that’s why.
  • You can’t $orderby the extended property. Filter on the server, yes; sort on the server, no. Do the sort locally after the (already filtered, already small) set comes back, which is what the snippet does.
  • Extended-property filtering is the exception, not the rule. Most Graph properties don’t support $filter at all, and the ones that do vary by resource. When there’s no filterable property and no MAPI tag to lean on, local Where-Object is not a failure — it’s the only tool left. Know which situation you’re in before you write the query, not after.

The honest read: this is a workaround dressed up as a feature. Microsoft could expose size as filterable and retire the party trick, and the fact that we’re reaching back to a MAPI tag older than Graph itself to answer “which items are big” tells you something about how the mailbox API grew up. It works today, on the current Microsoft.Graph.Mail module, and it’ll keep working as long as extended-property filtering does.

Which is the next beat to watch. Extended-property support has been stable for years, but it’s precisely the sort of undocumented-corner behaviour that shifts without a deprecation notice. So the calendar entry isn’t a date Microsoft published — it’s the next time you upgrade the module. Re-run it against a known mailbox and confirm the count before you trust the automation that depends on it. That five-minute check is a lot cheaper than the 2 a.m. one.