Skip to content

MailboxAudit

vaarg edited this page Jun 17, 2026 · 8 revisions

Invoke-MailboxAudit.ps1

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.

Background

GraphRunner's built-in mailbox functions have three problems:

  • Invoke-GraphOpenInboxFinder fails silently on every error. The catch block sets $err = $_.Exception.Response.StatusCode.Value__ but never references $err anywhere. 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 two independent functions: a probe phase that identifies accessible mailboxes and records them to a CSV, and a read phase that ingests that CSV and retrieves or searches messages.

Requirements

  • 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-GraphTokens or Invoke-RefreshGraphTokens
  • Mail.Read.Shared or Mail.ReadWrite.Shared scope to read other users' mailboxes
  • Group.Read.All or membership in the group to read group conversations

Setup

# Dot-source GraphRunner first, then this file
. .\GraphRunner\GraphRunner.ps1
. .\Invoke-MailboxAudit.ps1

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


Workflow Overview

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)

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


Authentication

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.


Step 1 — Test-MailboxAccess

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 group rather than silently skipped. If your group input comes from Resume-GroupAudit.ps1, the groupType column in updatable_details.csv lets you pre-filter to Microsoft 365 groups before passing to this function.

Parameters

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

Output files

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)

Resuming after failure

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

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

Examples

# 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 250

Step 2 — Get-MailboxMessages

Reads 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 same Mail.Read.Shared permission. Supports plain keyword terms and phrases; does not support KQL managed properties (filetype:, path:, etc.).
  • Groups: GET /groups/{id}/conversations?$top=N then client-side filter on the topic and preview fields. The Graph REST API does not expose full-text search for group conversations; this is a known limitation of the endpoint. Use -PageResults to fetch all conversation pages before filtering for the most complete results.

Comparison with Invoke-SearchMailbox: GraphRunner's Invoke-SearchMailbox uses the POST /search/query Graph Search API with KQL, which supports richer managed property syntax but is limited to the current user's own mailbox. Get-MailboxMessages with -SearchTerm uses OData $search on the target user's messages endpoint, which works across other users' accessible mailboxes but with simpler query syntax.

Parameters

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

Output CSV columns

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)

Examples

# Read top 25 messages from all accessible mailboxes
Get-MailboxMessages -Tokens $tokens

# Read top 10, write to file, no interactive prompts
Get-MailboxMessages -Tokens $tokens -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 full pagination (slower but complete results, especially for groups)
Get-MailboxMessages -Tokens $tokens `
    -SearchTerm "confidential" `
    -PageResults -MessageCount 500 `
    -ReportOnly  -OutFile .\hits.csv

Full Workflow Example

# 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: Review messages from accessible mailboxes
Get-MailboxMessages -Tokens $tokens `
    -MessageCount 25 `
    -ReportOnly   -OutFile .\messages.csv

Detector Loop

The 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

foreach ($detect in $detectors) {
    Get-MailboxMessages -Tokens $tokens `
        -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 $search is server-side and searches both subject and body.


Relationship to GraphRunner

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.

GraphRunner is authored by Beau Bullock (@dafthack) and licensed under MIT.

Clone this wiki locally