-
Notifications
You must be signed in to change notification settings - Fork 0
MailboxAudit
A PowerShell supplement to GraphRunner that probes user mailboxes and Microsoft 365 group inboxes for read access, then reads or searches messages from those that are accessible. Designed to be chained with Resume-GroupAudit.ps1 output for group inbox discovery.
GraphRunner's built-in mailbox functions have three problems:
-
Invoke-GraphOpenInboxFinderfails silently on every error. The catch block sets$err = $_.Exception.Response.StatusCode.Value__but never references$erranywhere. Every HTTP 401, 403, 404, and 429 response produces no output. The caller has no way to know whether a mailbox was inaccessible, the token expired, or the run was being rate-limited. - No token refresh. A long user list will hit token expiry mid-run with no recovery.
- No group inbox support. GraphRunner's mailbox functions only target user mailboxes. Microsoft 365 groups have a separate conversations inbox endpoint that existing functions do not cover.
Invoke-MailboxAudit.ps1 fixes the first two issues and adds group inbox support. It splits the work into three independent functions: a probe phase that identifies accessible mailboxes and records them to a CSV, a read phase that ingests that CSV and retrieves or searches messages (with optional body and attachment saving), and an offline search phase that searches previously saved bodies without making any further API calls.
- Windows PowerShell 5.1 or PowerShell 7+
-
GraphRunner dot-sourced in the same session (provides
Invoke-RefreshGraphTokens) - A valid Graph access token obtained via GraphRunner's
Get-GraphTokensorInvoke-RefreshGraphTokens -
Mail.Read.SharedorMail.ReadWrite.Sharedscope to read other users' mailboxes -
Group.Read.Allor membership in the group to read group conversations
# Dot-source GraphRunner first, then this file
. .\GraphRunner\GraphRunner.ps1
. .\Invoke-MailboxAudit.ps1Encoding note: The file is pure ASCII. If you edit it, save as ASCII or UTF-8 with BOM. Windows PowerShell 5.1 reads files without a BOM as Windows-1252 by default, which silently corrupts multi-byte Unicode characters (em-dashes, smart quotes, etc.) and causes parse errors.
Step 1 - Test-MailboxAccess
Probe user mailboxes and M365 group inboxes -> AccessibleMailboxes.csv
-> AccessibleMailboxes_user_ids.txt
-> AccessibleMailboxes_group_ids.txt
|
v
Step 2 - Get-MailboxMessages
Read or search messages from accessible mailboxes
-> console preview + optional OutFile CSV (appended per mailbox)
-> optional saved body files + attachments (-SaveTo, -SaveAttachments)
|
v (optional, after a -SaveTo run)
Step 3 - Search-MailboxCache
Search previously saved bodies offline -- zero API calls
-> console preview + optional OutFile CSV
The three steps are independent. If Test-MailboxAccess is interrupted, the fallback _ids.txt files contain whatever was found accessible up to that point. Get-MailboxMessages only requires a valid AccessibleMailboxes.csv and can be re-run against the same file with different parameters (different search terms, different -Type filters, etc.) without re-probing. Search-MailboxCache only requires either the CSV index or the saved body folder and generates no API traffic, making it preferable for repeated searches once bodies have been downloaded.
Authenticate using GraphRunner before running either function. The ClientID used matters — the refresh token is bound to the client it was issued for, and refreshing with a different ClientID returns HTTP 400.
# Example using the Azure Portal client
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
-ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
-RefreshToken "0.A..."Both functions auto-detect the ClientID from the appid claim in the access token JWT, so you do not need to pass -ClientID explicitly unless you want to override it.
Probes user mailboxes via GET /users/{id}/mailFolders/Inbox/messages?$top=1 and Microsoft 365 group inboxes via GET /groups/{id}/conversations?$top=1 to determine which are readable by the current token. Both types are probed in a single run by default; use -CheckUsersOnly or -CheckGroupsOnly to narrow the scope.
For users, a readable inbox is a notable finding — it indicates a misconfigured shared mailbox. For groups, inbox access may be expected if the token belongs to a group member, but the content is still worth reviewing.
Token handling mirrors Resume-GroupAudit.ps1: the ClientID is auto-detected from the JWT appid claim, proactive refreshes run on a configurable interval, 401 responses trigger an immediate refresh-and-retry (up to 3 attempts), and 429 responses respect the Retry-After header without consuming the retry budget.
Group type note: Only Microsoft 365 (Unified) groups have a conversations inbox. Security groups, Distribution groups, and Mail-enabled security groups will return HTTP 400 or 404. These are logged explicitly as
Not a mail-enabled M365 grouprather than silently skipped. If your group input comes fromResume-GroupAudit.ps1, thegroupTypecolumn inupdatable_details.csvlets you pre-filter toMicrosoft 365groups before passing to this function.
User inputs (compat with GraphRunner's users.txt format):
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-UserList |
One of | Text file with one UPN or email per line | |
-UserIds |
One of | String array of UPNs or email addresses |
Group inputs (compat with Resume-GroupAudit.ps1 output files):
| Parameter | Required | Default | Description |
|---|---|---|---|
-InputCsv |
One of | CSV with at minimum an id column. Accepts updatable.csv, updatable_details.csv, or any CSV from Test-GraphGroupMemberAccess
|
|
-InputFile |
One of | Text file in DisplayName:GUID format, e.g. updatable_ids.txt
|
|
-GroupIds |
One of | String array of group GUIDs |
Scope:
| Parameter | Required | Default | Description |
|---|---|---|---|
-CheckUsersOnly |
No | Probe only user mailboxes; skip group inputs | |
-CheckGroupsOnly |
No | Probe only group inboxes; skip user inputs |
Resume — user list (use one or the other, not both):
| Parameter | Required | Default | Description |
|---|---|---|---|
-SkipUsers |
No | 0 |
Skip the first N users (resume by position) |
-StartFromUser |
No | Skip all users before this UPN (resume by ID) |
Resume — group list (use one or the other, not both):
| Parameter | Required | Default | Description |
|---|---|---|---|
-SkipGroups |
No | 0 |
Skip the first N groups (resume by position) |
-StartFromGroup |
No | Skip all groups before this GUID (resume by ID) |
Output and token handling:
| Parameter | Required | Default | Description |
|---|---|---|---|
-OutputFile |
No | AccessibleMailboxes.csv |
Path for the unified results CSV |
-RefreshInterval |
No | 300 |
Seconds between proactive token refreshes |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
| File | Description |
|---|---|
<OutputFile> |
Unified CSV for all probed mailboxes, written at the end. Contains both accessible and inaccessible entries. |
<stem>_user_ids.txt |
Appended incrementally: DisplayName:UPN per accessible user mailbox as results come in. Survives a crash mid-run. |
<stem>_group_ids.txt |
Appended incrementally: DisplayName:GUID per accessible group inbox as results come in. Survives a crash mid-run. |
The unified CSV uses these columns:
| Column | Description |
|---|---|
Type |
User or Group
|
Id |
Object ID (UPN for users, GUID for groups) |
DisplayName |
Display name |
MailAddress |
UPN (users) or group mail address if known (groups) |
Accessible |
True or False
|
HttpStatus |
HTTP response code |
Result |
Accessible, Access denied, Not found or no mailbox, Not a mail-enabled M365 group, Authentication failed, Request failed (HTTP N), or Max retries exceeded
|
LatestSubject |
Subject of the most recent message (accessible mailboxes) or (empty inbox) / (no conversations)
|
LatestSender |
Sender address (users) or sender display name from uniqueSenders (groups) |
LatestDate |
receivedDateTime (users) or lastDeliveredDateTime (groups) |
When a mailbox exhausts all retries, the console prints a resume hint:
[!] Giving up on 'user@contoso.com' after 3 attempts.
Resume tip: -StartFromUser 'user@contoso.com'
[!] Giving up on 'Finance Team' (xxxxxxxx-...) after 3 attempts.
Resume tip: -StartFromGroup 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
User and group resume controls are independent — a failure partway through the group list does not affect the user counter, and vice versa.
# Resume user probe from a known address
Test-MailboxAccess -Tokens $tokens -UserList .\users.txt -StartFromUser "jsmith@contoso.com"
# Resume group probe from a known GUID
Test-MailboxAccess -Tokens $tokens -InputCsv .\updatable_details.csv -StartFromGroup "xxxxxxxx-..."
# Resume both at once
Test-MailboxAccess -Tokens $tokens `
-UserList .\users.txt `
-InputCsv .\updatable_details.csv `
-StartFromUser "jsmith@contoso.com" `
-StartFromGroup "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-OutputFile .\AccessibleMailboxes_resumed.csvIf the script crashes before writing the final CSV, the _user_ids.txt and _group_ids.txt fallback files contain whatever was found accessible up to the crash. Pass them directly as -InputFile and -UserList (after stripping to just UPNs for users) to Get-MailboxMessages, or re-run Test-MailboxAccess using the resume flags to finish the probe.
# Probe user mailboxes from GraphRunner users.txt and M365 group inboxes from GroupAudit
Test-MailboxAccess -Tokens $tokens -UserList .\users.txt -InputCsv .\updatable_details.csv
# Probe groups only, using the summary CSV
Test-MailboxAccess -Tokens $tokens -InputCsv .\updatable.csv -CheckGroupsOnly
# Probe groups only, pre-filtered to M365 type (avoids expected 400s from security groups)
Import-Csv .\updatable_details.csv |
Where-Object { $_.groupType -eq "Microsoft 365" } |
Export-Csv .\m365_groups.csv -NoTypeInformation
Test-MailboxAccess -Tokens $tokens -InputCsv .\m365_groups.csv -CheckGroupsOnly
# Probe a specific set of groups by GUID
Test-MailboxAccess -Tokens $tokens -GroupIds @("guid1","guid2","guid3") -CheckGroupsOnly
# Resume user probe from position 250
Test-MailboxAccess -Tokens $tokens -UserList .\users.txt -SkipUsers 250Reads or searches messages from the accessible mailboxes identified by Test-MailboxAccess. Ingests AccessibleMailboxes.csv and filters to rows where Accessible = True.
Without -SearchTerm, retrieves the top N most recent items per mailbox:
-
Users:
GET /users/{id}/mailFolders/Inbox/messages?$top=N -
Groups:
GET /groups/{id}/conversations?$top=N
With -SearchTerm, performs a search:
-
Users:
GET /users/{id}/messages?$search="term"&$top=N— server-side OData$search, applied to subject and body. Requires the sameMail.Read.Sharedpermission. Supports plain keyword terms and phrases; does not support KQL managed properties (filetype:,path:, etc.). -
Groups:
GET /groups/{id}/conversations?$top=Nthen client-side filter on thetopicandpreviewfields. The Graph REST API does not expose full-text search for group conversations; this is a known limitation of the endpoint. Use-PageResultsto fetch all conversation pages before filtering for the most complete results.
Comparison with
Invoke-SearchMailbox: GraphRunner'sInvoke-SearchMailboxuses thePOST /search/queryGraph Search API with KQL, which supports richer managed property syntax but is limited to the current user's own mailbox.Get-MailboxMessageswith-SearchTermuses OData$searchon the target user's messages endpoint, which works across other users' accessible mailboxes but with simpler query syntax.
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-InputCsv |
No | AccessibleMailboxes.csv |
Accessible mailboxes CSV from Test-MailboxAccess
|
-Type |
No | Both |
User, Group, or Both — filter which mailbox types to read |
-MessageCount |
No | 25 |
Maximum messages to retrieve per mailbox (applies per page when -PageResults is set) |
-SearchTerm |
No | Keyword or phrase to search for. Triggers search mode. | |
-DetectorName |
No | Custom |
Label written to the Detector Name column in the output CSV |
-OutFile |
No | CSV file to append results to. Results are appended per mailbox as they arrive. | |
-ReportOnly |
No | switch | Suppress console message display |
-PageResults |
No | switch | Page through all available results via @odata.nextLink
|
-GraphRun |
No | switch | Suppress per-mailbox status output; only print when hits are found. Use in detector loops. |
-RefreshInterval |
No | 300 |
Seconds between proactive token refreshes |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
| Column | Description |
|---|---|
Detector Name |
Value of -DetectorName
|
Mailbox Type |
User or Group
|
Mailbox ID |
UPN (users) or GUID (groups) |
Mailbox Display |
Display name |
Mailbox Address |
UPN or group mail address |
Subject |
Message subject (users) or conversation topic (groups) |
Sender |
Sender email address (users) or sender display names from uniqueSenders joined by , (groups) |
Receivers |
toRecipients addresses joined by , (users only; empty for groups) |
Date |
receivedDateTime (users) or lastDeliveredDateTime (groups) |
Preview |
bodyPreview (users) or preview field (groups) |
# Read top 25 messages from all accessible mailboxes (AccessibleMailboxes.csv in current dir)
Get-MailboxMessages -Tokens $tokens
# Explicit CSV path
Get-MailboxMessages -Tokens $tokens -InputCsv '.\audit\AccessibleMailboxes.csv'
# Read top 10, write to file, no console output
Get-MailboxMessages -Tokens $tokens -MessageCount 10 -ReportOnly -OutFile .\messages.csv
# Explicit CSV path, read top 10
Get-MailboxMessages -Tokens $tokens `
-InputCsv '.\audit\AccessibleMailboxes.csv' `
-MessageCount 10 `
-ReportOnly -OutFile .\messages.csv
# Read only from group inboxes
Get-MailboxMessages -Tokens $tokens -Type Group -ReportOnly -OutFile .\group_messages.csv
# Search all accessible mailboxes for a term
Get-MailboxMessages -Tokens $tokens -SearchTerm "password" -ReportOnly -OutFile .\hits.csv
# Search with explicit CSV path
Get-MailboxMessages -Tokens $tokens `
-InputCsv '.\audit\AccessibleMailboxes.csv' `
-SearchTerm "password" `
-ReportOnly -OutFile .\hits.csv
# Search with full pagination (slower but complete results, especially for groups)
Get-MailboxMessages -Tokens $tokens `
-SearchTerm "confidential" `
-PageResults -MessageCount 500 `
-ReportOnly -OutFile .\hits.csvSearches body files saved by a previous Get-MailboxMessages -SaveTo run. Makes zero API calls and generates no authentication log entries, making it the preferred approach for repeated keyword searches once mail has been downloaded.
Two modes:
-
CSV mode (
-InputCsv): Uses the CSV written byGet-MailboxMessages -OutFileas an index. Searches Subject, full body content (via theBodyFilepath in the CSV), and theAttachmentNamescolumn. All original metadata — Sender, Receivers, Date, HasAttachments — is preserved in output. This is the richer mode and should be preferred when available. -
Folder-walk mode (
-CachePathonly): Walks theSaveTofolder structure directly when no CSV index exists. Searches filename-derived subject and body content. Sender, Receivers, and AttachmentNames are not available in this mode.
When both -InputCsv and -CachePath are supplied, CSV mode takes precedence.
Coverage note: Search-MailboxCache can only find content that was previously downloaded. Unlike
Get-MailboxMessages, it does not reach out to Graph API and will miss emails that arrived after the last download run or that were not downloaded because-SaveTowas not used.
| Parameter | Required | Default | Description |
|---|---|---|---|
-InputCsv |
One of | CSV written by Get-MailboxMessages -OutFile. Used as the search index. |
|
-CachePath |
One of | Folder written by Get-MailboxMessages -SaveTo. Used for folder-walk mode when no CSV is available. |
|
-SearchTerm |
Yes | String to search for. Checked against Subject, body content, and attachment names. | |
-OutFile |
No | Path to write matching results as CSV (same schema as Get-MailboxMessages output). |
|
-DetectorName |
No | "Custom" |
Label for the Detector Name column in output. |
-ReportOnly |
No | Suppress per-hit console output. | |
-GraphRun |
No | Suppress all non-error console output for use in detector loops. |
# CSV mode (preferred): search full body content using the OutFile CSV as index
Search-MailboxCache -InputCsv .\messages.csv `
-SearchTerm "invoice" `
-OutFile .\hits.csv
# Folder-walk mode: no CSV available, scan saved body files directly
Search-MailboxCache -CachePath .\MailBodies `
-SearchTerm "invoice" `
-OutFile .\hits.csv
# Silent mode for detector loops
Search-MailboxCache -InputCsv .\messages.csv `
-SearchTerm "password" `
-DetectorName "Credential Search" `
-ReportOnly -OutFile .\hits.csv -GraphRun# Load dependencies
. .\GraphRunner\GraphRunner.ps1
. .\Resume-GroupAudit.ps1
. .\Invoke-MailboxAudit.ps1
# Authenticate
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
-ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
-RefreshToken "0.A..."
# [Optional] Enumerate groups first (if not already done)
Get-GraphGroups -Tokens $tokens -OutputFile .\all_groups.csv
Test-GraphGroupMemberAccess -Tokens $tokens -InputCsv .\all_groups.csv
Get-MemberAccessGroupDetails -Tokens $tokens -InputFile .\updatable_ids.txt `
-OutputFile .\updatable_details.csv
# Step 1: Probe user and group inboxes
# updatable_details.csv has groupType, so we can pre-filter to M365 groups
Import-Csv .\updatable_details.csv |
Where-Object { $_.groupType -eq "Microsoft 365" } |
Export-Csv .\m365_groups.csv -NoTypeInformation
Test-MailboxAccess -Tokens $tokens `
-UserList .\users.txt `
-InputCsv .\m365_groups.csv `
-OutputFile .\AccessibleMailboxes.csv
# Step 2: Download all messages and bodies (one-time API run)
Get-MailboxMessages -Tokens $tokens `
-MessageCount 500 -PageResults `
-DeepGroupSearch `
-SaveTo .\MailBodies `
-OutFile .\messages.csv `
-ReportOnly
# Step 3: Search the cache repeatedly -- zero API calls from here on
Search-MailboxCache -InputCsv .\messages.csv -SearchTerm "password" -OutFile .\hits.csv
Search-MailboxCache -InputCsv .\messages.csv -SearchTerm "invoice" -OutFile .\hits.csv
Search-MailboxCache -InputCsv .\messages.csv -SearchTerm "vpn" -OutFile .\hits.csvThe detector loop pattern from the GraphRunner wiki works with Get-MailboxMessages in place of Invoke-SearchSharePointAndOneDrive. Results from all detectors are appended to a single CSV.
$folderName = "MailboxSearch-" + (Get-Date -Format 'yyyyMMddHHmmss')
New-Item -Path $folderName -ItemType Directory | Out-Null
$outFile = "$folderName\interesting-mail.csv"
$detectors = (Get-Content '.\default_detectors.json' | ConvertFrom-Json).Detectors
# Default: looks for AccessibleMailboxes.csv in the current directory
foreach ($detect in $detectors) {
Get-MailboxMessages -Tokens $tokens `
-SearchTerm $detect.SearchQuery `
-DetectorName $detect.DetectorName `
-PageResults -MessageCount 500 `
-ReportOnly -OutFile $outFile -GraphRun
}
# Explicit path: use -InputCsv if the CSV is elsewhere
foreach ($detect in $detectors) {
Get-MailboxMessages -Tokens $tokens `
-InputCsv '.\audit\AccessibleMailboxes.csv' `
-SearchTerm $detect.SearchQuery `
-DetectorName $detect.DetectorName `
-PageResults -MessageCount 500 `
-ReportOnly -OutFile $outFile -GraphRun
}The -GraphRun switch suppresses per-mailbox output unless hits are found, keeping the terminal clean during long runs. Results across all detectors accumulate in $outFile.
Note: Because group conversation search is client-side (topic and preview only), some detectors using body-content search terms may produce fewer group hits than equivalent user mailbox hits. For user mailboxes the OData
$searchis server-side and searches both subject and body. Use-DeepGroupSearchwith-SaveTofollowed by aSearch-MailboxCacheloop to get full body coverage for groups.
If a prior Get-MailboxMessages -SaveTo -OutFile run has already downloaded mail bodies, run the detector loop against the cache instead. This generates zero API calls and no authentication log entries for each subsequent search.
# One-time download (run once per engagement or periodically to refresh)
Get-MailboxMessages -Tokens $tokens `
-PageResults -MessageCount 500 `
-DeepGroupSearch `
-SaveTo .\MailBodies `
-OutFile .\messages.csv `
-ReportOnly
# Detector loop against the cache -- no further API calls
$folderName = "MailboxSearch-" + (Get-Date -Format 'yyyyMMddHHmmss')
New-Item -Path $folderName -ItemType Directory | Out-Null
$outFile = "$folderName\interesting-mail.csv"
$detectors = (Get-Content '.\default_detectors.json' | ConvertFrom-Json).Detectors
foreach ($detect in $detectors) {
Search-MailboxCache `
-InputCsv .\messages.csv `
-SearchTerm $detect.SearchQuery `
-DetectorName $detect.DetectorName `
-ReportOnly -OutFile $outFile -GraphRun
}This file supplements, rather than replaces, GraphRunner's mailbox functions. It depends on GraphRunner being loaded and uses Invoke-RefreshGraphTokens from it.
Test-MailboxAccess replaces Invoke-GraphOpenInboxFinder for the user mailbox probe case. The core check is identical — the same GET /users/{id}/mailFolders/Inbox/messages endpoint — but with explicit per-status error reporting, token refresh and retry, group inbox support, resume capability, and a structured CSV output file.
Get-MailboxMessages without -SearchTerm covers the same read case as Get-Inbox, but across multiple accessible mailboxes in bulk. Get-Inbox remains preferable for a targeted single-user read with full body export.
Get-MailboxMessages with -SearchTerm for user mailboxes overlaps in purpose with Invoke-SearchMailbox but differs in scope: Invoke-SearchMailbox uses the Graph Search API (KQL, richer syntax) but only for the current user's own mailbox. Get-MailboxMessages uses OData $search (simpler syntax) but can search across any accessible user or group mailbox.
Search-MailboxCache has no GraphRunner equivalent. It operates entirely offline against the body files and CSV index produced by Get-MailboxMessages -SaveTo -OutFile, generating no API calls or authentication log entries. It is the preferred approach for repeated keyword searches once mail has been downloaded, and provides full body content coverage for group mailboxes where live search is limited to topic and preview.
GraphRunner is authored by Beau Bullock (@dafthack) and licensed under MIT.