#Requires -Version 5.1 <# .SYNOPSIS Collects Microsoft 365 email quarantine metadata and builds a review queue. .DESCRIPTION Read-only tenant operations. Does not release, deny, delete, submit samples, create allow entries, open links, download attachments, or change policies. Requires ExchangeOnlineManagement and tenant-wide quarantine read permissions. The report is a triage aid, not a completed security review. Default collection: 30 days of still-retained email, with an 8-day recent window (7 days plus a 24-hour overlap). Older pending items remain in the queue. Expired/deleted records cannot be reconstructed. Store output in a restricted folder; metadata and optional headers can contain confidential information. .EXAMPLE .\Review-EmailQuarantine.ps1 -ExpectedTenantId 'YOUR-TENANT-GUID' -UserPrincipalName 'admin@example.com' .EXAMPLE .\Review-EmailQuarantine.ps1 -ExpectedTenantId 'YOUR-TENANT-GUID' -UseExistingConnection -IncludeHeaders -OutputDirectory 'D:\Restricted\Quarantine' .NOTES Install prerequisite yourself if needed: Install-Module ExchangeOnlineManagement -Scope CurrentUser For scheduled use, establish an approved app-only connection first, then use -UseExistingConnection. This script does not install modules or schedule itself. Documentation: https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessageheader #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][guid]$ExpectedTenantId, [string]$UserPrincipalName, [switch]$UseExistingConnection, [ValidateRange(1, 29)][int]$ReviewDays = 7, [ValidateRange(0, 24)][int]$OverlapHours = 24, [ValidateRange(1, 30)][int]$LookbackDays = 30, [ValidateRange(1, 168)][int]$ExpirationWarningHours = 48, [ValidateRange(1, 1000)][int]$PageSize = 1000, [switch]$IncludeHeaders, [string]$OutputDirectory = (Join-Path $PSScriptRoot 'QuarantineReports') ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (($ReviewDays * 24 + $OverlapHours) -gt ($LookbackDays * 24)) { throw 'LookbackDays must cover ReviewDays plus OverlapHours.' } $endUtc = [datetime]::UtcNow $startUtc = $endUtc.AddDays(-$LookbackDays) $recentUtc = $endUtc.AddDays(-$ReviewDays).AddHours(-$OverlapHours) $runDirectory = Join-Path $OutputDirectory ($endUtc.ToString('yyyyMMddTHHmmssZ') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) $null = New-Item -ItemType Directory -Path $runDirectory -Force $issues = [System.Collections.Generic.List[string]]::new() $connectedHere = $false $status = [ordered]@{ Status = 'Incomplete'; TenantId = "$ExpectedTenantId"; CollectionStartUtc = $startUtc.ToString('o') CollectionEndUtc = $endUtc.ToString('o'); RecentWindowStartUtc = $recentUtc.ToString('o') ReviewStatus = 'Human review pending'; Issues = @() } $statusPath = Join-Path $runDirectory 'RunStatus.json' $status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8 function Get-Field($Item, [string[]]$Names) { foreach ($name in $Names) { $property = $Item.PSObject.Properties[$name] if ($null -ne $property -and $null -ne $property.Value -and [string]$property.Value -ne '') { return $property.Value } } return $null } function ConvertTo-Utc($Value) { if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null } if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime } if ($Value -is [datetime]) { if ($Value.Kind -eq [DateTimeKind]::Unspecified) { # Exchange service timestamps without an offset are interpreted as UTC. return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc) } return $Value.ToUniversalTime() } return [datetimeoffset]::Parse([string]$Value, [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime } function Invoke-ReadWithRetry([scriptblock]$Operation) { for ($attempt = 1; $attempt -le 3; $attempt++) { try { return (& $Operation) } catch { if ($attempt -eq 3) { throw } Start-Sleep -Seconds ([math]::Pow(2, $attempt)) } } } function Protect-CsvText($Value) { $text = [string]$Value # Email-controlled strings must not become spreadsheet formulas. if ($text -match '^[\s]*[=+@-]' -or $text -match '^[\t\r\n]') { return "'" + $text } return $text } function Export-ReviewCsv([object[]]$Items, [string]$Path, [string[]]$Columns) { if ($Items.Count -eq 0) { (($Columns | ForEach-Object { '"' + $_ + '"' }) -join ',') | Set-Content -LiteralPath $Path -Encoding UTF8 return } $Items | ForEach-Object { $record = [ordered]@{} foreach ($column in $Columns) { $record[$column] = Protect-CsvText $_.$column } [pscustomobject]$record } | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8 } try { if (-not $UseExistingConnection) { Import-Module ExchangeOnlineManagement -ErrorAction Stop $existingConnections = @(Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.State -eq 'Connected' }) if ($existingConnections.Count) { throw 'An Exchange connection already exists. Use -UseExistingConnection or start a fresh PowerShell session.' } $connectParameters = @{ ShowBanner = $false; ErrorAction = 'Stop' } if ($UserPrincipalName) { $connectParameters.UserPrincipalName = $UserPrincipalName } Connect-ExchangeOnline @connectParameters $connectedHere = $true } # Avoid ambiguity about which tenant the imported Exchange commands target. $connections = @(Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.State -eq 'Connected' }) if ($connections.Count -ne 1) { throw 'Use a PowerShell session with exactly one connected Exchange Online tenant.' } if ([string]$connections[0].TenantID -ne [string]$ExpectedTenantId) { throw 'Connected tenant does not match ExpectedTenantId. Collection stopped.' } $null = Get-Command Get-QuarantineMessage -ErrorAction Stop if ($IncludeHeaders) { $null = Get-Command Get-QuarantineMessageHeader -ErrorAction Stop } $seen = @{} $summaries = [System.Collections.Generic.List[object]]::new() # Bounded daily slices reduce page-limit risk. Boundaries are deduplicated by identity. for ($sliceStart = $startUtc; $sliceStart -lt $endUtc; $sliceStart = $sliceEnd) { $sliceSeen = @{} $sliceEnd = $sliceStart.AddDays(1) if ($sliceEnd -gt $endUtc) { $sliceEnd = $endUtc } for ($page = 1; $page -le 1000; $page++) { $query = @{ EntityType = 'Email'; StartReceivedDate = $sliceStart; EndReceivedDate = $sliceEnd IncludeMessagesFromBlockedSenderAddress = $true; Page = $page; PageSize = $PageSize ErrorAction = 'Stop' } $batch = @(Invoke-ReadWithRetry { Get-QuarantineMessage @query }) $newRecords = 0 foreach ($message in $batch) { $identity = [string](Get-Field $message @('Identity')) if (-not $identity) { throw 'A quarantine result has no identity; cannot reliably deduplicate records.' } if (-not $sliceSeen.ContainsKey($identity)) { $sliceSeen[$identity] = $true $newRecords++ } if (-not $seen.ContainsKey($identity)) { $seen[$identity] = $true $summaries.Add($message) } } if ($batch.Count -lt $PageSize) { break } if ($page -eq 1000) { throw 'Reached the service page limit. Coverage is incomplete.' } if ($page -gt 1 -and $newRecords -eq 0) { throw 'Pagination repeated a full page. Coverage cannot be confirmed.' } } } $raw = [System.Collections.Generic.List[object]]::new() $records = [System.Collections.Generic.List[object]]::new() $index = 0 foreach ($summary in $summaries) { $index++ $identity = [string](Get-Field $summary @('Identity')) Write-Progress -Activity 'Collecting quarantine details' -Status "$index of $($summaries.Count)" -PercentComplete (100 * $index / [math]::Max(1, $summaries.Count)) $message = $summary $detailStatus = 'Collected' try { $detail = @(Invoke-ReadWithRetry { Get-QuarantineMessage -Identity $identity -EntityType Email -ErrorAction Stop }) if ($detail.Count -ne 1 -or [string](Get-Field $detail[0] @('Identity')) -ne $identity) { throw 'Detail lookup did not return the single requested identity.' } $message = $detail[0] } catch { $detailStatus = 'Unavailable; summary retained' $issues.Add("Details unavailable for ${identity}: $($_.Exception.Message)") } $raw.Add($message) $received = $null; $expires = $null try { $received = ConvertTo-Utc (Get-Field $message @('ReceivedTime', 'Received')) } catch { $issues.Add("Unrecognized received timestamp for $identity") } try { $expires = ConvertTo-Utc (Get-Field $message @('Expires', 'ExpirationTime', 'ExpiresTime')) } catch { $issues.Add("Unrecognized expiration timestamp for $identity") } if ($null -eq $received) { $issues.Add("Received timestamp unavailable for $identity") } if ($null -eq $expires) { $issues.Add("Expiration unavailable for $identity") } $reason = [string](Get-Field $message @('QuarantineTypes', 'Type', 'QuarantineType')) $policy = [string](Get-Field $message @('PolicyName')) $policyType = [string](Get-Field $message @('PolicyTypes', 'PolicyType')) $releaseStatus = [string](Get-Field $message @('ReleaseStatus')) $pending = $releaseStatus -notin @('Released', 'Denied') $recent = $null -ne $received -and $received -ge $recentUtc -and $received -le $endUtc $soon = $pending -and $null -ne $expires -and $expires -gt $endUtc -and $expires -le $endUtc.AddHours($ExpirationWarningHours) $priority = 5; $triage = 'Routine trend review' if ("$reason $policyType" -match 'HighConf.*Phish|Malware|SafeAttachment') { $priority = 1; $triage = 'High-risk detection; investigate' } elseif ("$reason $policyType" -match 'Phish|Spoof|Imperson') { $priority = 2; $triage = 'Phishing or anti-phishing policy; inspect detection details' } elseif ($releaseStatus -eq 'Requested') { $priority = 3; $triage = 'User release request; validate individually' } elseif ($soon) { $priority = 4; $triage = 'Pending item nearing expiration' } if (-not $reason -or -not $releaseStatus -or $detailStatus -ne 'Collected') { $priority = [math]::Min($priority, 3); $triage += '; incomplete metadata needs review' } $recipients = @(Get-Field $message @('RecipientAddress', 'QuarantineUser')) $records.Add([pscustomobject][ordered]@{ Priority = $priority; Triage = $triage; Identity = $identity InternetMessageId = [string](Get-Field $message @('MessageId')) NetworkMessageId = [string](Get-Field $message @('NetworkMessageId')) ReceivedUtc = $(if ($null -ne $received) { $received.ToString('o') } else { '' }) ExpiresUtc = $(if ($null -ne $expires) { $expires.ToString('o') } else { '' }) Sender = [string](Get-Field $message @('SenderAddress')) Recipients = ($recipients -join '; '); Subject = [string](Get-Field $message @('Subject')) Reason = $reason; PolicyName = $policy; PolicyType = $policyType; ReleaseStatus = $releaseStatus ReleasedRecipients = (@(Get-Field $message @('ReleasedUser')) -join '; ') RecentWindow = $recent; Pending = $pending; ExpiringSoon = $soon DetailStatus = $detailStatus; HeaderStatus = 'Not requested' }) } Write-Progress -Activity 'Collecting quarantine details' -Completed $queue = @($records | Where-Object { $_.RecentWindow -or $_.Pending -or $_.DetailStatus -ne 'Collected' } | Sort-Object Priority, ExpiresUtc, ReceivedUtc) if ($IncludeHeaders) { $headerDirectory = Join-Path $runDirectory 'Headers' $null = New-Item -ItemType Directory -Path $headerDirectory $headerIndex = 0 foreach ($record in $queue) { $headerIndex++ try { $header = Invoke-ReadWithRetry { Get-QuarantineMessageHeader -Identity $record.Identity -ErrorAction Stop } # CLIXML retains the returned header object without rendering HTML. $header | Export-Clixml -LiteralPath (Join-Path $headerDirectory ("header-{0:D6}.clixml" -f $headerIndex)) -Encoding UTF8 $record.HeaderStatus = "Headers/header-$('{0:D6}' -f $headerIndex).clixml" } catch { $record.HeaderStatus = 'Unavailable'; $issues.Add("Header unavailable for $($record.Identity): $($_.Exception.Message)") } } } $columns = @('Priority','Triage','Identity','InternetMessageId','NetworkMessageId','ReceivedUtc','ExpiresUtc','Sender','Recipients','Subject','Reason','PolicyName','PolicyType','ReleaseStatus','ReleasedRecipients','RecentWindow','Pending','ExpiringSoon','DetailStatus','HeaderStatus') Export-ReviewCsv @($records) (Join-Path $runDirectory 'AllRetained.csv') $columns Export-ReviewCsv $queue (Join-Path $runDirectory 'ReviewQueue.csv') $columns ConvertTo-Json -InputObject @($raw) -Depth 12 | Set-Content -LiteralPath (Join-Path $runDirectory 'RawMessages.json') -Encoding UTF8 $recentRecords = @($records | Where-Object { $_.RecentWindow }) $counts = @($recentRecords | Group-Object Reason | Sort-Object Count -Descending | Select-Object @{n='Detection';e={$_.Name}}, Count) $limitText = 'Collection covers records still retained by Microsoft, not all historical arrivals. Deleted or expired items are absent. Pagination is not a transactional snapshot. Detection categories provide triage only; impersonation and campaign analysis need additional investigation. No content, URL, attachment, sender-authenticity, incident, or delivery validation was performed. Counts are distinct quarantine identities, not unique Internet Message-IDs or message-recipient instances. Service timestamps without an offset are interpreted as UTC. CSV text beginning with spreadsheet formula characters is prefixed with an apostrophe; raw values are preserved in JSON.' $status.Status = $(if ($issues.Count) { 'Collected with gaps' } else { 'Collection completed' }) $status['RetainedIdentityCount'] = $records.Count $status['RecentIdentityCount'] = $recentRecords.Count $status['QueueIdentityCount'] = $queue.Count $status['ExpiringPendingCount'] = @($records | Where-Object { $_.ExpiringSoon }).Count $status['Limitations'] = $limitText $status.Issues = @($issues) $encode = { param($Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } $issueHtml = if ($issues.Count) { '' } else { '

No collection errors recorded. Retention and visibility limitations still apply.

' } $countsHtml = if ($counts.Count) { $counts | ConvertTo-Html -Fragment | Out-String } else { '

No retained records returned in the recent window.

' } $queueHtml = if ($queue.Count) { $queue | Select-Object Priority, Triage, ReceivedUtc, ExpiresUtc, Sender, Recipients, Subject, Reason, ReleaseStatus, ExpiringSoon, Identity | ConvertTo-Html -Fragment | Out-String } else { '

No queue entries returned.

' } $html = @" Email quarantine review

Email quarantine review

$(& $encode $status.Status). Human review pending.

Tenant: $ExpectedTenantId
Collection UTC: $($startUtc.ToString('o')) through $($endUtc.ToString('o'))
Recent window UTC: $($recentUtc.ToString('o')) through $($endUtc.ToString('o'))

Retained identities: $($records.Count) | Recent: $($recentRecords.Count) | Review queue: $($queue.Count) | Pending and expiring within $ExpirationWarningHours hours: $($status.ExpiringPendingCount)

Coverage and limitations

$(& $encode $limitText)

Collection issues

$issueHtml

Recent detections

$countsHtml

Review queue

Includes recent records and older pending items. Priority is a starting point for review, not a release verdict.

$queueHtml "@ $html | Set-Content -LiteralPath (Join-Path $runDirectory 'Report.html') -Encoding UTF8 $status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8 Write-Host "Reports saved to $runDirectory" if ($issues.Count) { Write-Warning "$($issues.Count) collection issues; inspect RunStatus.json before using the report." } [pscustomobject]@{ Status = $status.Status; ReportDirectory = $runDirectory; QueueCount = $queue.Count; Issues = $issues.Count } } catch { $issues.Add($_.Exception.Message) $status.Status = 'Incomplete'; $status.Issues = @($issues) $status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8 throw } finally { if ($connectedHere) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } }