Skip to content

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.

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.
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

  1. Save the script below to a file, for example trace-recipient-counts.ps1.
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
  1. Run it:
./trace-recipient-counts.ps1 -Sender "BriefConnect-Admin@engageau.onmicrosoft.com" -DaysBack 30

Top recipients (optional):

./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 -ShowTopRecipients is 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.