Short answer: run .\Get-EXOMessageTraceReport.ps1 -Hours 48 -RecipientAddress bob@contoso.com -CsvPath .\trace.csv. It uses Get-MessageTraceV2, the replacement for the deprecated Get-MessageTrace, and handles the new cmdlet’s rules for you: at most 10 days per query, 5,000 rows per query, no page numbers (it continues with -StartingRecipientAddress and -EndDate), and 100 queries per 5 minutes. Filter by sender, recipient, status, subject or Message-ID, up to 90 days back.
Commands checked against the official documentation (linked below) on 6 October 2026; not yet run on our lab servers. The script was syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 (no errors or warnings) but has not yet been run against a live Microsoft 365 tenant. Every cmdlet, endpoint, property and permission it uses was checked against Microsoft Learn. If something behaves differently for you, tell us and we will fix the script.
Table of Contents
What it does
Microsoft began deprecating the legacy Get-MessageTrace and Get-MessageTraceDetail cmdlets on 1 September 2025 (message center MC1092458), and the cmdlet page now says it is replaced by Get-MessageTraceV2. The new cmdlet searches 90 days instead of 10, but behaves differently, which breaks most older scripts:
| Get-MessageTraceV2 rule (Microsoft Learn) | What the script does about it |
|---|---|
| Searches the last 90 days, but only 10 days of data per query | Splits your window into 10-day slices |
Returns 1,000 rows by default, 5,000 at most (-ResultSize) | Always asks for 5,000 |
No pagination: repeat the query with -StartingRecipientAddress and -EndDate taken from the Recipient address and Received time of the last row | Continues each slice that way until a batch comes back short, and removes the duplicate rows that the overlap can produce |
| At most 100 queries in a running 5-minute window | Counts its own queries and pauses before it reaches 95 |
| Output times are UTC | Writes them in a column named ReceivedUtc |
- Filters:
-SenderAddress,-RecipientAddress,-Statusand-MessageIdaccept several values each;-Subjectwith-SubjectFilterType(StartsWith by default, because Microsoft recommends StartsWith or EndsWith over Contains). -MaxResults(default 50,000) stops a runaway query, for example a whole tenant over 90 days.- Prints a count per status at the end, so you see at a glance how many messages failed or were quarantined.
- Read-only.
Requirements
- Windows PowerShell 5.1 or PowerShell 7 with ExchangeOnlineManagement 3.7.0 or later; Microsoft states that version as the minimum for Get-MessageTraceV2. Check with
Get-Module ExchangeOnlineManagement -ListAvailableand update withUpdate-Module ExchangeOnlineManagement. - Permissions: Microsoft documents the Organization Management role group or the Exchange Administrator Entra role for message trace. For a narrower custom role, list the roles that include the cmdlet:
Get-ManagementRole -Cmdlet Get-MessageTraceV2.
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-EXOMessageTraceReport.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Get-EXOMessageTraceReport.ps1. - Install the module once, for your user:
Install-Module ExchangeOnlineManagement -Scope CurrentUser. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Get-EXOMessageTraceReport.ps1 -Full
.\Get-EXOMessageTraceReport.ps1 -Hours 24 -RecipientAddress bob@contoso.com
Messages usually take 5 to 10 minutes to appear in message trace, according to Microsoft’s message trace FAQ, so a message sent a minute ago may not show yet.
Options
| Parameter | What it does | Default |
|---|---|---|
-Hours | Look back this many hours (1 to 2160, which is 90 days) | 24 |
-StartDate / -EndDate | Exact window in local time (overrides -Hours); must be within the last 90 days | Now minus -Hours / now |
-SenderAddress | One or more sender addresses | Any |
-RecipientAddress | One or more recipient addresses | Any |
-Status | Delivered, Expanded, Failed, FilteredAsSpam, GettingStatus, Pending, Quarantined | Any |
-Subject, -SubjectFilterType | Subject text; StartsWith, EndsWith or Contains | StartsWith |
-MessageId | Message-ID header value(s), with angle brackets if the header has them | Any |
-MaxResults | Stop after this many rows | 50000 |
-CsvPath, -PassThru | CSV file / objects to the pipeline | Screen only |
-UserPrincipalName | Pre-fill the interactive sign-in | None |
-AppId, -Organization, -CertificateThumbprint, -Disconnect | App-only sign-in and closing the session | Interactive |
Usage examples
# "Did Bob get the email?" Last two days, one recipient
.\Get-EXOMessageTraceReport.ps1 -Hours 48 -RecipientAddress bob@contoso.com
# Everything that failed or was held in the last 24 hours
.\Get-EXOMessageTraceReport.ps1 -Status Failed,Quarantined,FilteredAsSpam -CsvPath C:\Reports\problems.csv
# 30 days of mail from one sender (three 10-day slices, handled for you)
.\Get-EXOMessageTraceReport.ps1 -StartDate (Get-Date).AddDays(-30) -SenderAddress invoices@contoso.com -CsvPath .\invoices-30d.csv
# Find one message by its Message-ID header
.\Get-EXOMessageTraceReport.ps1 -Hours 168 -MessageId '<abc123@mail.example.com>'
# Top 10 senders today
.\Get-EXOMessageTraceReport.ps1 -Hours 24 -PassThru | Group-Object SenderAddress | Sort-Object Count -Descending | Select-Object -First 10 Count, Name
For delivery-event details of a single message (where it was routed, why it failed), pass the MessageTraceId and recipient from the CSV to Get-MessageTraceDetailV2. To read the headers of a message you already have, paste them into our email header analyzer.
CSV columns
No sample output is shown because the script has not yet run against a live tenant. One row per message and recipient:
| Column | Meaning |
|---|---|
| ReceivedUtc | When Exchange Online received the message (UTC) |
| SenderAddress, RecipientAddress | Envelope sender and recipient |
| Subject | Message subject |
| Status | Delivered, Failed, FilteredAsSpam, Quarantined, Expanded (distribution group), Pending or GettingStatus |
| Size | Message size in bytes |
| FromIP, ToIP | Sending and receiving IP addresses where recorded |
| MessageId | Internet Message-ID header |
| MessageTraceId | Trace ID, used with Get-MessageTraceDetailV2 |
Schedule it
For unattended runs use Exchange Online app-only (certificate) authentication, documented in App-only authentication in Exchange Online PowerShell. In short:
- Create an app registration in Microsoft Entra ID and upload the public key of a certificate whose private key is installed in the user certificate store of the account that will run the task.
- Add the Exchange.ManageAsApp application permission from Office 365 Exchange Online and grant admin consent.
- Assign a Microsoft Entra role to the app’s service principal (Microsoft lists which roles are supported). Microsoft documents Exchange Administrator for message trace, so assign that role (or a custom role group through an Exchange service principal, as the same article describes).
- Pass
-AppId,-Organization(your.onmicrosoft.comdomain, as Microsoft requires) and-CertificateThumbprint.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-EXOMessageTraceReport.ps1 -Hours 24 -Status Failed,Quarantined -CsvPath C:\Reports\mail-problems.csv -AppId <app-id> -Organization contoso.onmicrosoft.com -CertificateThumbprint <thumbprint> -Disconnect'
$trigger = New-ScheduledTaskTrigger -Daily -At 6am
Register-ScheduledTask -TaskName 'EXO message trace' -Action $action -Trigger $trigger -User 'CONTOSO\svc-reports' -Password '<password>'
The CSV is overwritten on every run. Keep the certificate’s private key on the reporting server only.
How it works
- Converts your window to UTC, checks it lies within the last 90 days, and connects to Exchange Online (or reuses an open session found with
Get-ConnectionInformation). - Checks that
Get-MessageTraceV2exists in the session; if not, the module is too old or your role does not include it. - Walks backwards from the end of the window in 10-day slices. For each slice it calls
Get-MessageTraceV2 -StartDate -EndDate -ResultSize 5000plus your filters. - If a batch has 5,000 rows, it repeats the query with
-EndDateset to the last row’s Received time and-StartingRecipientAddressset to its recipient, as Microsoft describes, until a batch comes back short. - Before each query it checks how many queries it sent in the last five minutes and waits if it is near 100. Throttling errors are retried with a pause.
- Rows are de-duplicated on MessageTraceId, recipient, time and status, sorted newest first and exported.
Limitations
- 90 days. Microsoft keeps message trace data for 90 days. For anything older there is nothing to search.
- Big windows are slow on purpose. A busy tenant over 90 days can need many queries, and the script waits rather than get throttled. Narrow the filters when you can.
- Subject search with Contains is the slowest option; Microsoft recommends StartsWith or EndsWith.
- Delay: new messages take a few minutes to appear.
- Not yet run against a live tenant (see the note at the top).
Official documentation: Get-MessageTraceV2 · Message trace in the new EAC · Message trace FAQ · Get-MessageTrace (legacy)
Related: Microsoft 365 SPF DKIM DMARC: Secure Exchange Online Setup · Email Header Analyzer · Microsoft 365 shared mailbox vs distribution group vs Microsoft 365 Group · DMARC Checker
See also: Exchange Online Message Trace PowerShell: Get-MessageTraceV2 · Exchange Online Mailbox Permissions Report: FullAccess, SendAs · Exchange Server SE Upgrade from 2019 CU15: In-Place Steps
The script
# Exchange Online Message Trace to CSV: Get-MessageTraceV2 Script (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/exo-message-trace-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Exchange Online message trace to CSV with Get-MessageTraceV2: filter by sender, recipient, status,
subject and time window, with automatic 10-day splitting and continuation past 5,000 rows.
.DESCRIPTION
Read-only. Microsoft began deprecating Get-MessageTrace and Get-MessageTraceDetail on 1 September 2025
(Microsoft 365 message center MC1092458). This script uses the replacement, Get-MessageTraceV2
(ExchangeOnlineManagement 3.7.0 or later), which Microsoft documents as follows:
- searches up to the last 90 days, but at most 10 days per query;
- returns 1,000 rows by default and up to 5,000 with -ResultSize;
- has no page numbers: to get the next batch you repeat the query with -EndDate set to the Received
time and -StartingRecipientAddress set to the recipient of the last row of the previous batch;
- accepts at most 100 queries in a rolling 5-minute window;
- returns times in UTC.
The script handles all of that: it splits the window into 10-day slices, pages each slice, removes
duplicate rows, and pauses when it gets close to the query limit.
Permissions: Microsoft documents membership of the Organization Management role group, or the Exchange
Administrator Entra role. For a narrower custom role, list the roles that contain the cmdlet with
Get-ManagementRole -Cmdlet Get-MessageTraceV2. App-only: app with Exchange.ManageAsApp and an Entra role
assigned to its service principal, for example Exchange Administrator.
.PARAMETER Hours Look back this many hours (default 24, max 2160 = 90 days). Ignored with -StartDate.
.PARAMETER StartDate Start of the window (local time). Must be within the last 90 days.
.PARAMETER EndDate End of the window (local time, default now).
.PARAMETER SenderAddress One or more sender addresses.
.PARAMETER RecipientAddress One or more recipient addresses.
.PARAMETER Status Delivered, Expanded, Failed, FilteredAsSpam, GettingStatus, Pending, Quarantined.
.PARAMETER Subject Subject text to match.
.PARAMETER SubjectFilterType Contains, StartsWith or EndsWith (default StartsWith; Microsoft recommends it over Contains).
.PARAMETER MessageId Message-ID header value(s), including angle brackets if the header has them.
.PARAMETER MaxResults Stop after this many rows (default 50000) to protect against runaway queries.
.PARAMETER CsvPath Write the result to this CSV file.
.PARAMETER PassThru Output objects to the pipeline.
.PARAMETER UserPrincipalName Admin account for interactive sign-in (optional).
.PARAMETER AppId / Organization / CertificateThumbprint Certificate (app-only) sign-in.
.PARAMETER Disconnect Disconnect from Exchange Online when finished.
.EXAMPLE .\Get-EXOMessageTraceReport.ps1 -Hours 48 -RecipientAddress bob@contoso.com
.EXAMPLE .\Get-EXOMessageTraceReport.ps1 -Hours 24 -Status Failed,Quarantined,FilteredAsSpam -CsvPath .\problems.csv
.EXAMPLE .\Get-EXOMessageTraceReport.ps1 -StartDate (Get-Date).AddDays(-30) -SenderAddress invoices@contoso.com -CsvPath .\invoices-30d.csv
.EXAMPLE .\Get-EXOMessageTraceReport.ps1 -Hours 6 -Subject "Invoice" -SubjectFilterType StartsWith
.EXAMPLE .\Get-EXOMessageTraceReport.ps1 -AppId <app-id> -Organization contoso.onmicrosoft.com -CertificateThumbprint <thumbprint> -Hours 24 -CsvPath C:\Reports\trace.csv -Disconnect
.NOTES
Name: Get-EXOMessageTraceReport.ps1
Purpose: Exchange Online message trace export (Get-MessageTraceV2)
Source: https://srvscripts.com/scripts/exo-message-trace-report/
License: MIT
Version: 1.0.0
Requires: Windows PowerShell 5.1 or PowerShell 7, module ExchangeOnlineManagement 3.7.0 or later
(Get-MessageTraceV2; update with Update-Module ExchangeOnlineManagement).
#>
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
param(
[ValidateRange(1, 2160)]
[int]$Hours = 24,
[datetime]$StartDate,
[datetime]$EndDate = (Get-Date),
[string[]]$SenderAddress,
[string[]]$RecipientAddress,
[ValidateSet('Delivered', 'Expanded', 'Failed', 'FilteredAsSpam', 'GettingStatus', 'Pending', 'Quarantined')]
[string[]]$Status,
[string]$Subject,
[ValidateSet('Contains', 'StartsWith', 'EndsWith')]
[string]$SubjectFilterType = 'StartsWith',
[string[]]$MessageId,
[ValidateRange(1, 1000000)]
[int]$MaxResults = 50000,
[string]$CsvPath,
[switch]$PassThru,
[Parameter(ParameterSetName = 'Interactive')]
[string]$UserPrincipalName,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F-]{36}$')]
[string]$AppId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[string]$Organization,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$CertificateThumbprint,
[switch]$Disconnect
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
$PageSize = 5000 # Get-MessageTraceV2 maximum
$SliceDays = 10 # maximum window per query
$QueryLimit = 95 # stay under 100 queries per rolling 5 minutes
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
# Get-MessageTraceV2 returns UTC times; make sure the DateTime is marked as UTC before it is reused.
function ConvertTo-Utc([datetime]$Value) {
if ($Value.Kind -eq [DateTimeKind]::Local) { return $Value.ToUniversalTime() }
return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)
}
# ---- Window -------------------------------------------------------------------------------------------
$endUtc = $EndDate.ToUniversalTime()
$startUtc = if ($PSBoundParameters.ContainsKey('StartDate')) { $StartDate.ToUniversalTime() } else { $endUtc.AddHours(-$Hours) }
$nowUtc = (Get-Date).ToUniversalTime()
if ($startUtc -ge $endUtc) { throw '-StartDate must be earlier than -EndDate.' }
if ($endUtc -gt $nowUtc) { $endUtc = $nowUtc }
$oldest = $nowUtc.AddDays(-90).AddMinutes(1)
if ($startUtc -lt $oldest -and $startUtc -gt $oldest.AddHours(-1)) { $startUtc = $oldest } # -Hours 2160 rounding
if ($startUtc -lt $oldest) { throw 'Get-MessageTraceV2 only searches the last 90 days. Use a later -StartDate (or a historical search in the Exchange admin center).' }
# ---- Connect --------------------------------------------------------------------------------------------
if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
throw 'Module ExchangeOnlineManagement is not installed. Run: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}
Import-Module ExchangeOnlineManagement
$existing = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Connected' -and $_.Name -like 'ExchangeOnline_*' })
if (-not $existing) {
$c = @{ ShowBanner = $false }
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
$c.AppId = $AppId; $c.Organization = $Organization; $c.CertificateThumbprint = $CertificateThumbprint
} elseif ($UserPrincipalName) { $c.UserPrincipalName = $UserPrincipalName }
Connect-ExchangeOnline @c
}
if (-not (Get-Command Get-MessageTraceV2 -ErrorAction SilentlyContinue)) {
throw 'Get-MessageTraceV2 is not available in this session. Update ExchangeOnlineManagement to 3.7.0 or later (Update-Module ExchangeOnlineManagement) and check that your account has message trace permissions (Organization Management role group or Exchange Administrator).'
}
# ---- Query helper with rolling-window throttle -----------------------------------------------------------
$queryTimes = [System.Collections.Generic.Queue[datetime]]::new()
function Invoke-TraceQuery([hashtable]$Params) {
while ($queryTimes.Count -gt 0 -and $queryTimes.Peek() -lt (Get-Date).AddMinutes(-5)) { [void]$queryTimes.Dequeue() }
if ($queryTimes.Count -ge $QueryLimit) {
$wait = [int][math]::Ceiling(($queryTimes.Peek().AddMinutes(5) - (Get-Date)).TotalSeconds) + 1
Write-Status "Close to the 100 queries / 5 minutes limit, waiting $wait s..."
Start-Sleep -Seconds ([math]::Max($wait, 1))
}
$queryTimes.Enqueue((Get-Date))
$attempt = 0
while ($true) {
$attempt++
try { return @(Get-MessageTraceV2 @Params -ErrorAction Stop) }
catch {
if ($attempt -lt 4 -and $_.Exception.Message -match 'throttl|Too many|429|temporarily') {
Start-Sleep -Seconds (30 * $attempt); continue
}
throw
}
}
}
# ---- Run ------------------------------------------------------------------------------------------------
$filter = @{}
if ($SenderAddress) { $filter.SenderAddress = $SenderAddress }
if ($RecipientAddress) { $filter.RecipientAddress = $RecipientAddress }
if ($Status) { $filter.Status = $Status }
if ($Subject) { $filter.Subject = $Subject; $filter.SubjectFilterType = $SubjectFilterType }
if ($MessageId) { $filter.MessageId = $MessageId }
$seen = @{}
$rows = [System.Collections.Generic.List[object]]::new()
$sliceEnd = $endUtc
$capped = $false
while ($sliceEnd -gt $startUtc -and -not $capped) {
$sliceStart = $sliceEnd.AddDays(-$SliceDays)
if ($sliceStart -lt $startUtc) { $sliceStart = $startUtc }
Write-Status ("Tracing {0:yyyy-MM-dd HH:mm} to {1:yyyy-MM-dd HH:mm} UTC..." -f $sliceStart, $sliceEnd)
$queryEnd = $sliceEnd
$startingRecipient = $null
do {
$p = $filter.Clone()
$p.StartDate = $sliceStart; $p.EndDate = $queryEnd; $p.ResultSize = $PageSize
if ($startingRecipient) { $p.StartingRecipientAddress = $startingRecipient }
$batch = @(Invoke-TraceQuery $p)
foreach ($m in $batch) {
$key = '{0}|{1}|{2:o}|{3}' -f $m.MessageTraceId, $m.RecipientAddress, $m.Received, $m.Status
if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
$rows.Add([pscustomobject]@{
ReceivedUtc = (ConvertTo-Utc $m.Received)
SenderAddress = $m.SenderAddress
RecipientAddress = $m.RecipientAddress
Subject = $m.Subject
Status = $m.Status
Size = $m.Size
FromIP = $m.FromIP
ToIP = $m.ToIP
MessageId = $m.MessageId
MessageTraceId = $m.MessageTraceId
})
if ($rows.Count -ge $MaxResults) { $capped = $true; break }
}
$more = ($batch.Count -ge $PageSize) -and -not $capped
if ($more) {
$last = $batch[$batch.Count - 1]
$lastReceived = ConvertTo-Utc $last.Received
if ($lastReceived -eq $queryEnd -and [string]$last.RecipientAddress -eq $startingRecipient) {
Write-Warning 'Continuation did not advance; stopping this slice to avoid a loop.'
$more = $false
} else {
$queryEnd = $lastReceived
$startingRecipient = [string]$last.RecipientAddress
}
}
} while ($more)
$sliceEnd = $sliceStart
}
if ($capped) { Write-Warning "Stopped at -MaxResults $MaxResults rows. Narrow the filters or raise -MaxResults." }
$out = @($rows | Sort-Object ReceivedUtc -Descending)
if ($CsvPath) {
$out | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-Status ("CSV written: {0} ({1} rows)" -f $CsvPath, $out.Count)
}
$byStatus = $out | Group-Object Status | Sort-Object Count -Descending | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name }
Write-Status ("Messages: {0}. By status: {1}" -f $out.Count, $(if ($byStatus) { $byStatus -join ', ' } else { 'none' }))
if ($Disconnect) { Disconnect-ExchangeOnline -Confirm:$false }
if ($PassThru) { return $out }
if (-not $CsvPath) { $out | Select-Object ReceivedUtc, SenderAddress, RecipientAddress, Status, Subject | Format-Table -AutoSize }
332a59338634077c35f439041360e8136fef189be6e70a13439122b0ac6007fccurl -fsSL -o Get-EXOMessageTraceReport.ps1 https://scr.srvscripts.com/exo-message-trace-report/Get-EXOMessageTraceReport.ps1 && curl -fsSL https://scr.srvscripts.com/exo-message-trace-report/Get-EXOMessageTraceReport.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/exo-message-trace-report/Get-EXOMessageTraceReport.ps1' -OutFile 'Get-EXOMessageTraceReport.ps1'; if ((Get-FileHash 'Get-EXOMessageTraceReport.ps1' -Algorithm SHA256).Hash -eq '332A59338634077C35F439041360E8136FEF189BE6E70A13439122B0AC6007FC') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
Is Get-MessageTrace deprecated?
Yes. Microsoft began deprecating Get-MessageTrace and Get-MessageTraceDetail on 1 September 2025 and replaced them with Get-MessageTraceV2 and Get-MessageTraceDetailV2.
How far back can Get-MessageTraceV2 search?
90 days, but each query can cover at most 10 days. The script splits longer windows into 10-day slices.
How do I get more than 5,000 results?
Repeat the query with -EndDate set to the Received time and -StartingRecipientAddress set to the recipient of the last row. The script does this automatically.
Why does Get-MessageTraceV2 say the term is not recognized?
Your ExchangeOnlineManagement module is older than 3.7.0, or your account has no role that includes the cmdlet. Update the module and check your role.
Are message trace times in UTC?
Yes. Microsoft documents that the output time stamps are UTC, even if you passed local times for -StartDate and -EndDate.
What permissions do I need for message trace?
Microsoft documents the Organization Management role group or the Exchange Administrator role.