Entra / Microsoft 365 · Exchange Online
Analyze mail traffic with Graph
Using the Exchange Online message trace log to analyze inbound and outbound traffic with the Graph API.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -NoWelcome -Scopes $RequiredScopes
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).toString("s") + "Z,[string] $EndDate = (Get-Date).ToString("s") + "Z,[int] $BatchSize = 2000)[array]$RequiredScopes = "ExchangeMessageTrace.Read.All", "Domain.Read.All"$Interactive = $false# Determine if we're interactive or notIf ([Environment]::UserInteractive) {# We're running interactively...Clear-HostWrite-Host "Script running interactively... connecting to the Graph" -ForegroundColor YellowConnect-MgGraph -NoWelcome -Scopes $RequiredScopes$Interactive = $true} Else {# We're not, so likely in Azure AutomationWrite-Output "Executing the runbook to analyze mail traffic with Graph API..."Connect-MgGraph -Identity -NoWelcome}# Check that we have the right permissions - in Azure Automation, we assume that the automation account has the right permissionsIf ($Interactive) {[int]$RequiredScopesCount = $RequiredScopes.Count[string[]]$CurrentScopes = (Get-MgContext).Scopes[string[]]$RequiredScopes = $RequiredScopes$CheckScopes =[object[]][Linq.Enumerable]::Intersect($RequiredScopes,$CurrentScopes)If ($CheckScopes.Count -ne $RequiredScopesCount ) {Write-Host ("To run this script, you need to connect to Microsoft Graph with the following scopes: {0}" -f $RequiredScopes) -ForegroundColor RedBreak}}Write-Host "Fetching message trace data to analyze..."# 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'))[int]$BatchSizeForMessages = 2000$Uri = "https://graph.microsoft.com/beta/admin/exchange/tracing/messageTraces?`$filter=receivedDateTime ge {0} and receivedDateTime le {1} and status eq 'Delivered'&`$top={2}" -f `$StartDate, $EndDate, $BatchSizeForMessages[array]$Messages = $NullTry {# The warning action is suppressed here because we don't want to see warnings when more data is available[array]$MessagePage = Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop$Messages += $MessagePage.ValueWrite-Host ("Fetched {0} messages in first batch" -f $Messages.count)} Catch {Write-Host ("An error occured fetching message trace data: {0}" -f $_.Exception.Message)Break}$UriNextLink = $MessagePage.'@odata.nextLink'If ($UriNextLink){Do {Start-Sleep -Seconds 2# Fetch the next page of messagesTry {[array]$MessagePage = Invoke-MgGraphRequest -Method GET -Uri $UriNextLink -ErrorAction StopIf ($MessagePage) {$Messages += $MessagePage.Value$UriNextLink = $MessagePage.'@odata.nextLink'Write-Host ("Fetched {0} messages so far" -f $Messages.count)}} Catch {Write-Host ("Error fetching message trace data: {0}" -f $_.Exception.Message)Break}} While ($UriNextLink)}Write-Host ("A total of {0} messages fetched for analysis" -f $Messages.count)# 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-MgDomain | Select-Object -ExpandProperty Id$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 = ((New-Object -ComObject Shell.Application).Namespace('shell:Downloads').Self.Path) + "\Message Trace Analysis.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).toString("s") + "ZStart of the reporting window.-EndDate(Get-Date).ToString("s") + "ZEnd of the reporting window.-BatchSize2000Maximum number of records to fetch per query batch.Attribution
Author
Office365itpros