Back to script library
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 not
If ([Environment]::UserInteractive) {
# We're running interactively...
Clear-Host
Write-Host "Script running interactively... connecting to the Graph" -ForegroundColor Yellow
Connect-MgGraph -NoWelcome -Scopes $RequiredScopes
$Interactive = $true
} Else {
# We're not, so likely in Azure Automation
Write-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 permissions
If ($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 Red
Break
}
}
Write-Host "Fetching message trace data to analyze..."
# Message trace date is kept for a maximum of 10 days
Write-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 = $Null
Try {
# 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.Value
Write-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 messages
Try {
[array]$MessagePage = Invoke-MgGraphRequest -Method GET -Uri $UriNextLink -ErrorAction Stop
If ($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.Received
Sender = $M.SenderAddress
Recipient = $M.RecipientAddress
Subject = $M.Subject
Status = $M.Status
Direction = $Direction
SenderDomain = $SenderDomain
RecipientDomain = $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 messages
Write-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 -AutoSize
Write-Host ""
# And the same for inbound messages
Write-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 utf8
Write-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