Monitoring Exchange Online send limits
Exchange Online enforces a rolling 24-hour limit per sending mailbox. The limit is based on the number of recipients a mailbox sends to (not the number of messages).
Key points:
- Each recipient on a message counts (To/Cc/Bcc).
- Sending three separate emails to the same person counts as three recipients.
- The limit is rolling (previous 24 hours), not a daily reset.
This guide shows two ways to check how close a Brief Connect email sender account is to the limit.
Check for sendMail failures
This query shows Graph sendMail dependency failures by day.
kusto
dependencies
| where timestamp >= ago(30d)
| where target has "graph.microsoft.com"
| where name has "microsoft.graph.sendMail"
| where success == false or toint(resultCode) >= 400
| summarize failures=count() by bin(timestamp, 1d), resultCode
| order by timestamp asc
The results of this query will only count the number of failures, not the number of times the sendMail operation was attempted and failed due to hitting the 10,000 recipient limit.
Option A: App Insights estimate (fast, approximate)
This estimates usage from Graph sendMail dependency calls. It counts envelopes (send attempts), not recipients, so you must multiply by an average recipients-per-envelope for the environment.
Estimate rolling 24-hour recipients
Set these values to match your environment:
avgRecipientsPerEnvelope: your observed average (per environment). By default, we assume 3 recipients per envelope.limitPerDay: the Exchange rolling 24-hour recipient limit you want to compare against. By default, we use the Exchange Online default limit of 10,000 recipients per day.
kusto
let avgRecipients = 3;
let limitPerDay = 10000;
dependencies
| where timestamp >= ago(30d)
| where target has "graph.microsoft.com"
| where name has "microsoft.graph.sendMail"
| make-series envelopes = count() default=0 on timestamp from ago(30d) to now() step 1d
| mv-expand timestamp to typeof(datetime), envelopes to typeof(long)
| extend estRecipients = envelopes * avgRecipients
| extend limitLine = limitPerDay
| order by timestamp asc
| render timechart
Limitations:
- This is an estimate. It cannot see actual To/Cc/Bcc counts.
- If your environment uses SMTP sending (instead of Graph), the dependency data may not represent sends.
Option B: Exchange PowerShell (precise)
This method uses message trace from Exchange to calculate the rolling 24-hour recipient count.
Requirements
- Exchange admin permissions with message trace access.
- PowerShell 7.
- Exchange Online PowerShell module (the script installs it if missing).
Run the script
- Save the script below to a file, for example
trace-recipient-counts.ps1.
```powershell param( [Parameter(Mandatory = $true)] [string]$Sender,
[Parameter(Mandatory = $false)]
[int]$DaysBack = 7,
[Parameter(Mandatory = $false)]
[int]$ResultSize = 5000,
[Parameter(Mandatory = $false)]
[int]$RecipientLimit = 10000,
[Parameter(Mandatory = $false)]
[switch]$ShowTopRecipients
)
$ErrorActionPreference = "Stop"
if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { Write-Host "Installing ExchangeOnlineManagement module..." Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force }
Import-Module ExchangeOnlineManagement
Write-Host "Connecting to Exchange Online..." Connect-ExchangeOnline -ShowBanner:$false
$endTime = Get-Date $startTime = $endTime.AddDays(-$DaysBack)
Write-Host "Fetching message trace for sender $Sender from $($startTime.ToString('u')) to $($endTime.ToString('u'))..."
Get-MessageTraceV2 supports max 10-day windows and up to 5000 results per call.
Pagination requires passing both -StartingRecipientAddress and -EndDate from the
last record of the previous result set (per Microsoft docs).
$traceMap = @{} $windowStart = $startTime while ($windowStart -lt $endTime) { $windowEnd = $windowStart.AddDays(10) if ($windowEnd -gt $endTime) { $windowEnd = $endTime }
Write-Host " - Window $($windowStart.ToString('u')) to $($windowEnd.ToString('u'))"
$pageEndDate = $windowEnd
$startingRecipient = $null
$pageNum = 0
do {
$pageNum++
$params = @{
SenderAddress = $Sender
StartDate = $windowStart
EndDate = $pageEndDate
ResultSize = $ResultSize
}
if ($null -ne $startingRecipient) {
$params['StartingRecipientAddress'] = $startingRecipient
}
$page = @(Get-MessageTraceV2 @params)
if ($page.Count -eq 0) {
break
}
Write-Host " Page $pageNum : $($page.Count) results"
foreach ($item in $page) {
$key = "$($item.MessageTraceId)|$($item.RecipientAddress)"
$traceMap[$key] = $item
}
if ($page.Count -lt $ResultSize) {
break
}
# Advance cursor: use last record's Received time and RecipientAddress
$lastRecord = $page[-1]
$newRecipient = $lastRecord.RecipientAddress
$newEndDate = $lastRecord.Received
if ($newRecipient -eq $startingRecipient -and $newEndDate -eq $pageEndDate) {
Write-Warning "Pagination cursor did not advance. Stopping."
break
}
$startingRecipient = $newRecipient
$pageEndDate = $newEndDate
} while ($true)
$windowStart = $windowEnd
}
$traces = $traceMap.Values
Write-Host "Total trace records: $($traceMap.Count)"
if (-not $traces) { Write-Host "No message trace results found." Disconnect-ExchangeOnline -Confirm:$false return }
$results = foreach ($trace in $traces) { $received = $trace.Received [pscustomobject]@{ Received = $received Date = $received.ToString("yyyy-MM-dd") Sender = $trace.SenderAddress Recipient = $trace.RecipientAddress Subject = $trace.Subject Status = $trace.Status MessageId = $trace.MessageId TraceId = $trace.MessageTraceId } }
$subjectCounts = $results | Where-Object { $.Received -ge (Get-Date).AddDays(-$DaysBack) } | Select-Object , @{ Name = 'SubjectPrefix'; Expression = { $_.Subject -replace '\s+-\s+.$', '' } } | Group-Object SubjectPrefix | ForEach-Object { [pscustomobject]@{ Subject = $.Name Recipients = $_.Count } } | Sort-Object Recipients -Descending
$dailyCounts = $results | Group-Object Date | ForEach-Object { $count = $.Count [pscustomobject]@{ Date = $.Name Recipients = $count PercentOfLimit = [math]::Round(100 * ($count / $RecipientLimit), 2) } } | Sort-Object Date
$recipientCounts = $results | Group-Object Recipient | ForEach-Object { [pscustomobject]@{ Recipient = $.Name Recipients = $.Count } } | Sort-Object Recipients -Descending
$subjectCountsByRecipient = $results | Group-Object Recipient | ForEach-Object { $recipient = $.Name $subjectCounts = $.Group | Select-Object , @{ Name = 'SubjectPrefix'; Expression = { $_.Subject -replace '\s+-\s+.$', '' } } | Group-Object SubjectPrefix | ForEach-Object { [pscustomobject]@{ Subject = $.Name Recipients = $.Count } } | Sort-Object Recipients -Descending
[pscustomobject]@{
Recipient = $recipient
SubjectCounts = $subjectCounts
}
}
$hourlyCounts = $results | Where-Object { $.Received -ge (Get-Date).AddDays(-2) } | Group-Object { $received = $.Received [datetime]::new($received.Year, $received.Month, $received.Day, $received.Hour, 0, 0, $received.Kind) } | ForEach-Object { $groupReceived = $.Group[0].Received [pscustomobject]@{ Hour = [datetime]::new($groupReceived.Year, $groupReceived.Month, $groupReceived.Day, $groupReceived.Hour, 0, 0, $groupReceived.Kind) Recipients = $.Count } } | Sort-Object Hour
$rollingCounts = New-Object System.Collections.Generic.List[object] $window = New-Object System.Collections.Generic.List[object] $windowTotal = 0
foreach ($hour in $hourlyCounts) { $window.Add($hour) $windowTotal += $hour.Recipients
$cutoff = $hour.Hour.AddHours(-24)
while ($window.Count -gt 0 -and $window[0].Hour -le $cutoff) {
$windowTotal -= $window[0].Recipients
$window.RemoveAt(0)
}
$rollingCounts.Add([pscustomobject]@{
Hour = $hour.Hour
RollingRecipients = $windowTotal
PercentOfLimit = [math]::Round(100 * ($windowTotal / $RecipientLimit), 2)
})
}
Write-Host "" if ($rollingCounts.Count -gt 0) { $latestRolling = $rollingCounts[-1] Write-Host "Rolling 24h recipients as of $($latestRolling.Hour.ToString('u')): $($latestRolling.RollingRecipients) ($($latestRolling.PercentOfLimit)%)" }
Write-Host "" Write-Host "Rolling 24h recipients by hour (last 48h):" $rollingCounts | Format-Table -AutoSize
Write-Host "" Write-Host "Recipients by subject (last $DaysBack days):" $subjectCounts | Format-Table -AutoSize
if ($ShowTopRecipients) { Write-Host "" Write-Host "Top recipients (last $DaysBack days):" $topRecipients = $recipientCounts | Select-Object -First 50 $topRecipients | Format-Table -AutoSize
Write-Host ""
Write-Host "Top recipient subjects (last $DaysBack days):"
foreach ($topRecipient in $topRecipients) {
Write-Host ""
Write-Host $topRecipient.Recipient
$subjectCounts = $subjectCountsByRecipient |
Where-Object { $_.Recipient -eq $topRecipient.Recipient } |
ForEach-Object { $_.SubjectCounts }
$topSubjects = $subjectCounts | Select-Object -First 3
$topTotal = ($topSubjects | Measure-Object -Property Recipients -Sum).Sum
$otherTotal = [int]($topRecipient.Recipients - $topTotal)
$subjectRows = New-Object System.Collections.Generic.List[object]
foreach ($subject in $topSubjects) {
$subjectRows.Add([pscustomobject]@{
Subject = $subject.Subject
Recipients = $subject.Recipients
})
}
if ($otherTotal -gt 0) {
$subjectRows.Add([pscustomobject]@{
Subject = 'Other emails'
Recipients = $otherTotal
})
}
$subjectRows | Format-Table -AutoSize
}
}
Write-Host "" Write-Host "Daily recipient counts (limit: $RecipientLimit per 24h):" $dailyCounts | Format-Table -AutoSize
Write-Host "Done." Disconnect-ExchangeOnline -Confirm:$false ```
- Run it:
powershell
./trace-recipient-counts.ps1 -Sender "BriefConnect-Admin@engageau.onmicrosoft.com" -DaysBack 30
Top recipients (optional):
powershell
./trace-recipient-counts.ps1 -Sender "BriefConnect-Admin@engageau.onmicrosoft.com" -DaysBack 30 -ShowTopRecipients
What you get:
- Latest rolling 24-hour recipients + percent of limit.
- Rolling 24-hour recipients by hour (last 48 hours).
- Recipients by subject line (last N days).
- Top 50 recipients (last N days) and subject breakdowns when
-ShowTopRecipientsis used. - Daily recipient counts (useful for trend context, but not the limit).
Example output:
``` pwsh ./scripts/exchange/trace-recipient-counts.ps1 -Sender "BriefConnect-Admin@engageau.onmicrosoft.com" -DaysBack 30 Connecting to Exchange Online... Fetching message trace for sender BriefConnect-Admin@engageau.onmicrosoft.com from 2026-01-13 11:06:11Z to 2026-02-12 11:06:11Z... - Window 2026-01-13 11:06:11Z to 2026-01-23 11:06:11Z - Window 2026-01-23 11:06:11Z to 2026-02-02 11:06:11Z - Window 2026-02-02 11:06:11Z to 2026-02-12 11:06:11Z
Rolling 24h recipients as of 2026-02-11 23:00:00Z: 27 (0.27%)
Rolling 24h recipients by hour (last 48h):
Hour RollingRecipients PercentOfLimit ---- ----------------- -------------- 1/20/2026 5:00:00 PM 3 0.030 1/21/2026 5:00:00 AM 8 0.080 1/22/2026 3:00:00 AM 7 0.070 1/22/2026 5:00:00 AM 78 0.780 1/22/2026 6:00:00 AM 84 0.840 1/22/2026 9:00:00 AM 135 1.350 1/22/2026 7:00:00 PM 137 1.370 1/22/2026 11:00:00 PM 138 1.380 1/23/2026 2:00:00 AM 160 1.600 1/26/2026 10:00:00 PM 1 0.010 1/26/2026 11:00:00 PM 21 0.210 1/28/2026 4:00:00 AM 2 0.020 1/28/2026 6:00:00 AM 23 0.230 1/28/2026 7:00:00 AM 39 0.390 1/28/2026 8:00:00 AM 68 0.680 1/28/2026 9:00:00 AM 76 0.760 1/28/2026 10:00:00 AM 92 0.920 1/28/2026 12:00:00 PM 112 1.120 1/29/2026 2:00:00 AM 146 1.460 1/29/2026 4:00:00 AM 147 1.470 1/30/2026 7:00:00 AM 31 0.310 2/3/2026 12:00:00 AM 4 0.040 2/4/2026 7:00:00 AM 1 0.010 2/4/2026 8:00:00 AM 5 0.050 2/5/2026 12:00:00 AM 10 0.100 2/5/2026 1:00:00 AM 11 0.110 2/5/2026 2:00:00 AM 27 0.270 2/5/2026 4:00:00 AM 60 0.600 2/5/2026 5:00:00 AM 61 0.610 2/5/2026 10:00:00 PM 68 0.680 2/6/2026 3:00:00 AM 48 0.480 2/6/2026 7:00:00 AM 104 1.040 2/6/2026 9:00:00 AM 136 1.360 2/6/2026 4:00:00 PM 137 1.370 2/8/2026 9:00:00 PM 2 0.020 2/8/2026 10:00:00 PM 20 0.200 2/9/2026 2:00:00 AM 21 0.210 2/9/2026 3:00:00 AM 22 0.220 2/9/2026 4:00:00 AM 50 0.500 2/9/2026 10:00:00 AM 58 0.580 2/9/2026 11:00:00 AM 218 2.180 2/9/2026 5:00:00 PM 220 2.200 2/9/2026 11:00:00 PM 212 2.120 2/10/2026 7:00:00 AM 229 2.290 2/11/2026 1:00:00 AM 56 0.560 2/11/2026 7:00:00 AM 58 0.580 2/11/2026 8:00:00 AM 19 0.190 2/11/2026 11:00:00 PM 27 0.270
Daily recipient counts (limit: 10000 per 24h):
Date Recipients PercentOfLimit ---- ---------- -------------- 2026-01-13 1 0.010 2026-01-14 58 0.580 2026-01-15 26 0.260 2026-01-16 29 0.290 2026-01-20 3 0.030 2026-01-21 5 0.050 2026-01-22 138 1.380 2026-01-23 22 0.220 2026-01-26 21 0.210 2026-01-28 112 1.120 2026-01-29 35 0.350 2026-01-30 31 0.310 2026-02-03 4 0.040 2026-02-04 5 0.050 2026-02-05 68 0.680 2026-02-06 125 1.250 2026-02-08 20 0.200 2026-02-09 212 2.120 2026-02-10 47 0.470 2026-02-11 27 0.270 ```
Choosing a method
Use App Insights when you need a quick directional view and are comfortable with an estimate.
Use Exchange PowerShell when you need the actual rolling 24-hour recipient usage for a mailbox.