~/2025/09/03/powershell-exchange-online-report-mailboxes-approaching-their-quota.md

PowerShell: Exchange Online – Report Mailboxes Approaching Their Quota

---
author: 
date: 
read: 5 min
in:   [ps, scripts]
tags: [powershell, exchange-online, reporting]
---

$ grep -n '^#' post.md

Nobody notices a mailbox creeping up on its quota until it stops sending mail and a help desk ticket lands with "I can't send email" and no other context. Exchange Online does warn the user, but on the big plans the warning comes late: for Microsoft 365 E3/E5 and Business plans the defaults are a warning at 98 GB, prohibit send at 99 GB and prohibit send/receive at 100 GB, so the user gets one email with 1 GB of headroom. This script reports every mailbox above a percentage of its ProhibitSendQuota well before that, so you can nudge people, enable an archive, or fix a retention policy ahead of the bounce. It also reports the Recoverable Items folder, which has its own quota (30 GB, or 100 GB on hold) and fills up silently on mailboxes under litigation hold.

Requirements

  • PowerShell 7, or Windows PowerShell 5.1.
  • The ExchangeOnlineManagement module, version 3.0.0 or later (the REST-based EXO V3 module): Install-Module -Name ExchangeOnlineManagement.
  • A connected session: Connect-ExchangeOnline interactively, or app-only certificate authentication for a scheduled task.
  • A role that can read mailboxes and mailbox statistics tenant-wide, for example Global Reader or Exchange Administrator.

Parameters

NameTypeRequiredDescription
WarningPercentIntNoPercentage of ProhibitSendQuota used that puts a mailbox in the report. Defaults to 80.
RecipientTypeDetailsStringNoMailbox types to check. Defaults to UserMailbox and SharedMailbox.
OutputPathStringNoCSV path to export the report to. If omitted, the report only prints to the console.

Usage

Connect, then run with the default 80 percent threshold:

powershell
Connect-ExchangeOnline -ShowBanner:$false
.\Get-MailboxQuotaReport.ps1

Report anything over 90 percent, user mailboxes only, and save it for a scheduled task to email:

powershell
.\Get-MailboxQuotaReport.ps1 -WarningPercent 90 -RecipientTypeDetails UserMailbox -OutputPath 'C:\Reports\mailbox-quota.csv'

Unattended, with an app registration and a certificate in the machine store (see Microsoft's app-only authentication article for the one-time setup of the app, the Exchange.ManageAsApp permission and a directory role):

powershell
Connect-ExchangeOnline -CertificateThumbprint '<thumbprint>' -AppId '<application-id>' -Organization '<tenant>.onmicrosoft.com' -ShowBanner:$false
.\Get-MailboxQuotaReport.ps1 -OutputPath 'C:\Reports\mailbox-quota.csv'
Disconnect-ExchangeOnline -Confirm:$false

Sample console output, most urgent first:

text
Checked 1,184 mailbox(es); 4 at or above 80% of ProhibitSendQuota.

DisplayName        Type          UsedGB QuotaGB PercentUsed RecoverableGB RecoverablePercent ArchiveEnabled
-----------        ----          ------ ------- ----------- ------------- ------------------ --------------
<display-name>     UserMailbox     48.9    49.5        98.8           2.1                7.0 False
<display-name>     UserMailbox     91.3    99.0        92.2          88.6               88.6 True
<display-name>     UserMailbox     84.0    99.0        84.8          11.4               38.0 False
<display-name>     UserMailbox     79.6    99.0        80.4           0.9                3.0 True

Script

powershell
<#
.SYNOPSIS
    Reports Exchange Online mailboxes approaching their send quota.
.DESCRIPTION
    Enumerates mailboxes with Get-EXOMailbox (Quota and Archive property sets), reads each mailbox's
    TotalItemSize and TotalDeletedItemSize with Get-EXOMailboxStatistics, and returns the mailboxes
    at or above a percentage of ProhibitSendQuota, with Recoverable Items usage against
    RecoverableItemsQuota alongside. Sorted with the most urgent mailboxes first. Optionally exports
    the report to CSV.
.PARAMETER WarningPercent
    Percentage of ProhibitSendQuota used that puts a mailbox in the report. Defaults to 80.
.PARAMETER RecipientTypeDetails
    Mailbox types to check. Defaults to UserMailbox and SharedMailbox.
.PARAMETER OutputPath
    CSV path to export the report to. If omitted, the report only prints to the console.
.EXAMPLE
    .\Get-MailboxQuotaReport.ps1 -WarningPercent 90 -OutputPath 'C:\Reports\mailbox-quota.csv'
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-09-03)
    Requires: ExchangeOnlineManagement 3.0.0 or later, an active Connect-ExchangeOnline session
#>
[CmdletBinding()]
param (
    [Parameter(Mandatory = $false)]
    [ValidateRange(1, 100)]
    [int]$WarningPercent = 80,

    [Parameter(Mandatory = $false)]
    [string[]]$RecipientTypeDetails = @('UserMailbox', 'SharedMailbox'),

    [Parameter(Mandatory = $false)]
    [string]$OutputPath
)

function ConvertTo-ByteCount {
    param ($Size)

    # Quotas and sizes arrive either as a ByteQuantifiedSize (with ToBytes()) or as its
    # string form, for example "4.92 GB (5,283,165,983 bytes)". "Unlimited" returns $null.
    if ($null -eq $Size) {
        return $null
    }
    if ($Size.PSObject.Properties['Value'] -and $Size.Value -and $Size.Value.PSObject.Methods['ToBytes']) {
        return [double]$Size.Value.ToBytes()
    }
    $text = $Size.ToString()
    if ($text -match '\(([\d,.\s]+) bytes\)') {
        return [double]($Matches[1] -replace '[^\d]', '')
    }
    return $null
}

if (-not (Get-ConnectionInformation)) {
    throw 'No Exchange Online session. Run Connect-ExchangeOnline first.'
}

Write-Verbose 'Retrieving mailboxes'
$mailboxes = Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails $RecipientTypeDetails -PropertySets Quota, Archive

$checked = 0
$report = foreach ($mailbox in $mailboxes) {
    $quotaBytes = ConvertTo-ByteCount -Size $mailbox.ProhibitSendQuota
    if (-not $quotaBytes) {
        # Unlimited quota: nothing to measure against.
        continue
    }

    # Never pass an empty identity: a $null Identity returns statistics for every mailbox.
    if ([string]::IsNullOrWhiteSpace($mailbox.UserPrincipalName)) {
        continue
    }

    try {
        $stats = Get-EXOMailboxStatistics -UserPrincipalName $mailbox.UserPrincipalName -ErrorAction Stop
    } catch {
        Write-Warning "Could not read statistics for $($mailbox.UserPrincipalName): $($_.Exception.Message)"
        continue
    }
    $checked++

    $usedBytes = ConvertTo-ByteCount -Size $stats.TotalItemSize
    if ($null -eq $usedBytes) {
        continue
    }

    $percentUsed = [math]::Round(($usedBytes / $quotaBytes) * 100, 1)
    if ($percentUsed -lt $WarningPercent) {
        continue
    }

    $deletedBytes = ConvertTo-ByteCount -Size $stats.TotalDeletedItemSize
    $recoverableQuotaBytes = ConvertTo-ByteCount -Size $mailbox.RecoverableItemsQuota
    $recoverablePercent = $null
    if ($deletedBytes -and $recoverableQuotaBytes) {
        $recoverablePercent = [math]::Round(($deletedBytes / $recoverableQuotaBytes) * 100, 1)
    }

    [PSCustomObject]@{
        DisplayName        = $mailbox.DisplayName
        Mailbox            = $mailbox.PrimarySmtpAddress
        Type               = $mailbox.RecipientTypeDetails
        UsedGB             = [math]::Round($usedBytes / 1GB, 1)
        QuotaGB            = [math]::Round($quotaBytes / 1GB, 1)
        PercentUsed        = $percentUsed
        RecoverableGB      = if ($deletedBytes) { [math]::Round($deletedBytes / 1GB, 1) } else { 0 }
        RecoverablePercent = $recoverablePercent
        ArchiveEnabled     = $mailbox.ArchiveStatus -eq 'Active'
    }
}

$sortedReport = @($report | Sort-Object -Property PercentUsed -Descending)

Write-Host ("Checked {0:N0} mailbox(es); {1} at or above {2}% of ProhibitSendQuota." -f $checked, $sortedReport.Count, $WarningPercent)
$sortedReport | Format-Table -Property DisplayName, Type, UsedGB, QuotaGB, PercentUsed, RecoverableGB, RecoverablePercent, ArchiveEnabled -AutoSize

if ($OutputPath) {
    $sortedReport | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Host "Report exported to $OutputPath"
}

Notes

  • Why the Get-EXO* cmdlets. The original draft used Get-Mailbox and Get-MailboxStatistics. Microsoft's guidance for the V3 module is to use the REST-backed Get-EXO* cmdlets with property sets: without -PropertySets or -Properties you get only the Minimum set, and a Get-Mailbox call returns at least 230 properties per mailbox where Get-EXOMailbox returns a handful. The Quota property set carries ProhibitSendQuota, IssueWarningQuota, ProhibitSendReceiveQuota, RecoverableItemsQuota and UseDatabaseQuotaDefaults, and the Minimum set of Get-EXOMailboxStatistics already includes TotalItemSize and TotalDeletedItemSize, so no extra properties are requested for statistics.
  • Size parsing. The old draft called .Value.ToBytes() directly on TotalItemSize. That works when the object is a live ByteQuantifiedSize, and fails when it arrives as its string form ("4.92 GB (5,283,165,983 bytes)"), which is what you often get over the REST module. ConvertTo-ByteCount handles both and returns $null for Unlimited.
  • The $null identity trap. Microsoft's reference for Get-EXOMailboxStatistics warns that $null or a non-existent value for -Identity returns every object, as if you had run the command with no identity at all. In a loop that turns one bad row into a full-tenant query, which is why the script skips blank UPNs.
  • Quotas come from licensing. In Exchange Online the quota values are set by the subscription and license assigned to the mailbox. You can lower them with Set-Mailbox -ProhibitSendQuota, but you can't raise them past what the license allows. For example, Exchange Online Plan 1 and Office 365 E1 mailboxes prohibit send at 49.5 GB, while unlicensed shared mailboxes are limited to 50 GB until an Exchange Online Plan 2 license raises them to 100 GB.
  • TotalItemSize doesn't include Recoverable Items. Deleted items that are still recoverable count against RecoverableItemsQuota (30 GB, or 100 GB when the mailbox is on hold), reported here as RecoverableGB. A mailbox on litigation hold can run out of Recoverable Items space while its main quota looks healthy, which is why that column is in the report.
  • Archives. Archive mailboxes aren't measured; add -Archive to a second Get-EXOMailboxStatistics call if you need them. ArchiveEnabled tells you whether moving mail to the archive with a retention policy is an option. Microsoft notes that retention policies only move mail to the archive automatically once the primary mailbox is larger than 10 MB, which isn't a constraint for anything this report flags.
  • On a tenant with thousands of mailboxes this is one statistics call per mailbox with a quota, so it takes a while. I run it as a nightly scheduled task rather than on demand.

Source