Public/check-mailflow.ps1

# check-mailflow.ps1
# -----------------------------------------------------------------------------
# Traces message delivery for a sender/recipient pair within a date range.
# Useful for: "I didn't receive an email from X", "my email to Y bounced",
# investigating spam-filter blocks, verifying connector behaviour.
#
# V1 → V2 migration (see ADR-0019)
# --------------------------------
# This script previously called Get-MessageTrace and Get-MessageTraceDetail.
# Microsoft has deprecated both V1 cmdlets in favour of Get-MessageTraceV2 /
# Get-MessageTraceDetailV2 (shipped with ExchangeOnlineManagement 3.7.0+,
# announced for hard removal in subsequent module releases). The V2 cmdlets
# differ in three ways that matter to this script:
#
# 1. Pagination is now explicit. V1 returned everything in a single call
# capped at 5000 rows; V2 caps a single call at 5000 rows but expects
# callers to paginate using -StartingRecipientAddress as a continuation
# cursor. The result-accumulation loop below implements that pattern.
#
# 2. The drill-down cmdlet (Get-MessageTraceDetailV2) requires a recipient
# address alongside the MessageId — V1 accepted MessageId alone. We
# resolve the recipient from the matching row in $results rather than
# re-prompting the operator, falling back to the originally-entered
# recipient filter if the row lookup somehow fails.
#
# 3. Result-object property names are unchanged for the columns we display,
# so the on-screen and CSV output schema is preserved exactly. Engineer
# muscle memory (column order, column names) is unaffected.
#
# Requires: ExchangeOnlineManagement (3.7.0 or later for V2 cmdlets)

# Self-contained connection per ADR-0003 — every Public script ensures its
# own connection rather than relying on a shared bootstrap step.
if (-not (Get-ConnectionInformation)) { Connect-ExchangeOnline -ShowBanner:$false }

# --- Operator inputs ------------------------------------------------------
# Both filters are optional; at least one is recommended in practice but the
# cmdlet allows neither (it will then return all messages in the window).
$sender    = Read-Host "Sender address (leave blank to skip filter)"
$recipient = Read-Host "Recipient address (leave blank to skip filter)"
$hours     = Read-Host "How many hours back to search? (default 24, max 168)"
if (-not $hours) { $hours = 24 }

# Clamp to the documented V2 maximum window of 168 hours (10 days). Without
# the clamp the cmdlet rejects oversize windows server-side with a generic
# error; clamping client-side gives a clearer experience.
$hours = [Math]::Min([int]$hours, 168)

$start = (Get-Date).AddHours(-[int]$hours)
$end   = Get-Date

Write-Host "`nSearching message trace ($hours hours)..."

# --- Build the splat ------------------------------------------------------
# PageSize 1000 balances throughput against the chance of needing many
# round-trips. The V2 hard cap per call is 5000; 1000 is enough that most
# real queries complete in a single call without leaving a lot of unused
# capacity if pagination is needed.
$baseParams = @{
    StartDate = $start
    EndDate   = $end
    PageSize  = 1000
}
if ($sender)    { $baseParams.SenderAddress    = $sender }
if ($recipient) { $baseParams.RecipientAddress = $recipient }

# --- Paginated fetch ------------------------------------------------------
# V2 pagination model: the cursor is the recipient address of the last row
# returned, passed back as -StartingRecipientAddress on the next call. The
# loop terminates when a call returns fewer rows than PageSize (meaning we
# hit the end of the matching set) or when we cross the safety cap below.
#
# Safety cap: 10000 rows. An unfiltered query on a busy tenant could in
# principle paginate forever; the cap means a runaway is bounded and the
# operator gets a clear yellow warning rather than a hung session.
$maxRows  = 10000
$results  = New-Object System.Collections.Generic.List[object]
$cursor   = $null
$truncated = $false

while ($true) {
    $params = $baseParams.Clone()
    if ($cursor) { $params.StartingRecipientAddress = $cursor }

    $page = Get-MessageTraceV2 @params

    if (-not $page) { break }

    # Project to the V1 column shape so on-screen and CSV output is unchanged.
    foreach ($row in $page) {
        $results.Add(($row | Select-Object Received, SenderAddress, RecipientAddress, Subject,
                                            Status, ToIP, FromIP, Size, MessageId))
        if ($results.Count -ge $maxRows) { $truncated = $true; break }
    }
    if ($truncated) { break }

    # End-of-results detection: a partial page means there is no next page.
    if ($page.Count -lt $baseParams.PageSize) { break }

    # Advance the cursor to the recipient on the last row of this page.
    $cursor = $page[-1].RecipientAddress
    if (-not $cursor) { break }   # defensive — shouldn't happen, but bail rather than loop forever
}

if ($truncated) {
    Write-Host " [WARN] Result set truncated at $maxRows rows. Tighten the filter or shorten the window." -ForegroundColor Yellow
}

# --- Display + optional CSV export ----------------------------------------
if ($results.Count -eq 0) {
    Write-Host "No messages found matching those criteria." -ForegroundColor Yellow
    return
}

Write-Host "Found $($results.Count) message(s):`n"
$results | Format-Table -AutoSize

if ((Read-Host "Export to CSV? (y/n)") -eq "y") {
    $path = "$env:USERPROFILE\Desktop\MailTrace_$(Get-Date -Format 'yyyyMMdd_HHmm').csv"
    $results | Export-Csv -Path $path -NoTypeInformation
    Write-Host "Exported to $path"
}

# --- Drill-down on a single message ---------------------------------------
# V2 detail cmdlet requires -RecipientAddress in addition to -MessageId. We
# look up the recipient from the row in $results that matches the operator's
# pasted MessageId, which is more reliable than re-prompting them. If that
# lookup fails we fall back to the recipient filter they originally typed
# (if any), and only as a last resort bail with a red error.
if ((Read-Host "`nDrill into delivery detail for a specific message? (y/n)") -ne "y") { return }

$msgId = Read-Host "Paste the MessageId value"
if (-not $msgId) { Write-Host "No MessageId — aborted." -ForegroundColor DarkGray; return }

$matchedRow = $results | Where-Object { $_.MessageId -eq $msgId } | Select-Object -First 1
$drillRecipient = if ($matchedRow) { $matchedRow.RecipientAddress } else { $recipient }

if (-not $drillRecipient) {
    Write-Host " Cannot drill: V2 detail cmdlet requires a recipient and none could be inferred." -ForegroundColor Red
    Write-Host " Re-run the trace with a recipient filter, or paste a MessageId from the table above." -ForegroundColor DarkGray
    return
}

Get-MessageTraceDetailV2 -MessageId $msgId `
                          -RecipientAddress $drillRecipient `
                          -StartDate $start `
                          -EndDate $end |
    Select-Object Date, Event, Action, Detail | Format-Table -AutoSize