Entra / Microsoft 365 · Exchange Online
Analyze mail traffic
Using the Exchange Online message trace log to analyze inbound and outbound traffic.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-ExchangeOnline -ShowBanner:$False -ErrorAction Stop
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([int] $LookbackDays = 10,[string] $StartDate = (Get-Date).AddDays(-$LookbackDays),[string] $EndDate = (Get-Date),[int] $BatchSize = 2000)If ($Null -eq (Get-ConnectionInformation)) {Connect-ExchangeOnline -ShowBanner:$False -ErrorAction Stop}# Message trace date is kept for a maximum of 10 daysWrite-Host ("Message trace data will be analyzed between {0} and {1}" -f (Get-Date $StartDate -format 'dd-MMM-yyyy HH:mm'), (Get-Date $EndDate -format 'dd-MMM-yyyy HH:mm'))Write-Host "Fetching message trace data to analyze"[array]$Messages = $Null[int]$BatchSizeForMessages = 2000# original code [array]$MessagePage = Get-MessageTrace -StartDate $StartDate -EndDate $EndDate -PageSize 1000 -Page $i -Status "Delivered"Try {# The warning action is suppressed here because we don't want to see warnings when more data is available[array]$MessagePage = Get-MessageTraceV2 -StartDate $StartDate -EndDate $EndDate `-ResultSize $BatchSizeForMessages -Status "Delivered" -ErrorAction Stop -WarningAction SilentlyContinue$Messages += $MessagePage} Catch {Write-Host ("Error fetching message trace data: {0}" -f $_.Exception.Message)Break}If ($MessagePage.count -eq $BatchSizeForMessages) {Do {Write-Host ("Fetched {0} messages so far" -f $Messages.count)$LastMessageFetched = $MessagePage[-1]$LastMessageFetchedDate = $LastMessageFetched.Received.ToString("O")$LastMessageFetchedRecipient = $LastMessageFetched.RecipientAddress# Fetch the next page of messages[array]$MessagePage = Get-MessageTraceV2 -StartDate $StartDate -EndDate $LastMessageFetchedDate `-StartingRecipientAddress $LastMessageFetchedRecipient -ResultSize $BatchSizeForMessages -Status "Delivered" -ErrorAction Stop -WarningAction SilentlyContinueIf ($MessagePage) {$Messages += $MessagePage}} While ($MessagePage.count -eq $BatchSizeForMessages)}# Remove Exchange Online public folder hierarchy synchronization messages$Messages = $Messages | Where-Object {$_.Subject -NotLike "*HierarchySync*"}# Now, do we have any messsages to process?If ($Messages.count -eq 0) {Write-Host "No messages found for analysis"Break} Else {Write-Host ("After excluding system messages, there are {0} messages for analysis" -f $Messages.count)}[array]$Domains = Get-AcceptedDomain | Select-Object -ExpandProperty DomainName$Report = [System.Collections.Generic.List[Object]]::new()ForEach ($M in $Messages) {$Direction = "Inbound"$SenderDomain = $M.SenderAddress.Split("@")[1]$RecipientDomain = $M.RecipientAddress.Split("@")[1]If ($SenderDomain -in $Domains) {$Direction = "Outbound"}$ReportLine = [PSCustomObject]@{TimeStamp = $M.ReceivedSender = $M.SenderAddressRecipient = $M.RecipientAddressSubject = $M.SubjectStatus = $M.StatusDirection = $DirectionSenderDomain = $SenderDomainRecipientDomain = $RecipientDomain}$Report.Add($ReportLine)}# Extract the inbound and outbound messages[array]$OutboundMessages = $Report | Where-Object {$_.Direction -eq "Outbound"}[array]$InboundMessages = $Report | Where-Object {$_.Direction -eq "Inbound"}Write-Host ""# Report the top 10 domains for outbound messagesWrite-Host ("Top 10 domains for outbound messages between {0} and {1}" -f (Get-Date $StartDate -format 'dd-MMM-yyyy HH:mm'), (Get-Date $EndDate -format 'dd-MMM-yyyy HH:mm'))Write-Host "------------------------------------------------------------------------------------"$OutboundMessages | Group-Object RecipientDomain -NoElement | Sort-Object Count -Descending | Select-Object -First 10 | Format-Table Name, Count -AutoSizeWrite-Host ""# And the same for inbound messagesWrite-Host "Top 10 domains for inbound messages"Write-Host "-----------------------------------"$InboundMessages | Group-Object SenderDomain -NoElement | Sort-Object Count -Descending | Select-Object -First 10 | Format-Table Name, Count -AutoSize# Generate sortable HTML table with type-aware sorting - use number as the type for numeric values, date for dates, and string for text$HtmlHeader = @"<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Detailed Message Statistics</title><style>body { font-family: Segoe UI, Arial, sans-serif; background: #f4f6f8; color: #222; }h1 { background: #0078d4; color: #fff; padding: 16px; border-radius: 6px 6px 0 0; margin-bottom: 20px; }table { width: 100%; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-collapse: collapse; }th, td { padding: 12px; text-align: left; }th { background: #e5eaf1; cursor: pointer; position: relative; }th:hover { background: #d0e7fa; }th::after { content: '↕'; position: absolute; right: 8px; opacity: 0.5; }tr:nth-child(even) { background: #f0f4fa; }tr:hover { background: #d0e7fa; }</style><script>function parseValue(val, type) {if(type === 'number') return parseFloat(val.replace(/,/g,'')) || 0;if(type === 'date') return new Date(val);return val.toLowerCase();}function sortTable(n, type) {var table = document.getElementById('msgstats');var rows = Array.from(table.rows).slice(1);var dir = table.getAttribute('data-sortdir'+n) === 'asc' ? 'desc' : 'asc';rows.sort(function(a, b) {var x = parseValue(a.cells[n].innerText, type);var y = parseValue(b.cells[n].innerText, type);if(x < y) return dir === 'asc' ? -1 : 1;if(x > y) return dir === 'asc' ? 1 : -1;return 0;});rows.forEach(function(row) { table.tBodies[0].appendChild(row); });table.setAttribute('data-sortdir'+n, dir);}</script></head><body><h1>Detailed Message Statistics</h1><table id="msgstats"><thead><tr><th onclick="sortTable(0,'date')">Timestamp</th><th onclick="sortTable(1,'string')">Sender</th><th onclick="sortTable(2,'string')">Recipient</th><th onclick="sortTable(3,'string')">Subject</th><th onclick="sortTable(4,'string')">Direction</th><th onclick="sortTable(5,'string')">SenderDomain</th><th onclick="sortTable(6,'string')">RecipientDomain</th></tr></thead><tbody>"@$Report = $Report | Sort-Object {$_.Timestamp -as [datetime]}, {$_.Recipient} -Descending$HtmlRows = foreach ($Row in $Report ) {"<tr><td>$($row.Timestamp)</td><td>$($row.Sender)</td><td>$($row.Recipient)</td><td>$($row.Subject)</td><td>$($row.Direction)</td><td>$($row.SenderDomain)</td><td>$($row.RecipientDomain)</td></tr>"}$HtmlFooter = @"</tbody></table></body></html>"@$ReportFile = "c:\temp\MessageStats.html"#Generate the full HTML content and save it to a file$HtmlFile = $HtmlHeader + ($HtmlRows -join "`n") + $HtmlFooter$HtmlFile | Out-File -FilePath $ReportFile -Encoding utf8Write-Host "Detailed message statistics saved to $ReportFile" -ForegroundColor Green
Parameters
ParameterDefaultNotes
-LookbackDays10Number of days to include in the message trace search window.-StartDate(Get-Date).AddDays(-10)Start of the reporting window.-EndDate(Get-Date)End of the reporting window.-BatchSize2000Maximum number of records to fetch per query batch.Attribution
Author
Office365itpros