I recently faced a common IT challenge: monitoring Exchange Online mailbox usage across our organization. While Microsoft provides tools for this, I needed something more proactive - a system that would track usage trends, predict when mailboxes would fill up, and alert our team before users hit their limits.
The requirements were specific:
- Monitor multiple mailboxes including archives
- Track historical usage trends with visual indicators
- Calculate "days to full" based on growth patterns
- Send color-coded email alerts at 70% (warning) and 85% (critical)
- Generate an auto-refreshing HTML dashboard
- Run continuously without relying on Task Scheduler
Visual results
This is the dashboard which will update every time the script runs which for this example if every 2 hours, which will then show the trending of the size of the mailbox and the estimated days until full:
Building the Solution
This covers how the script as built set by step...
Connecting to Exchange Online
The first step was establishing a reliable connection to Exchange Online. I wanted the script to connect, retrieve data, and disconnect cleanly each cycle to avoid session issues:
# Connect to Exchange Online
Connect-ExchangeOnline -UserPrincipalName $UPN -ShowBanner:$false -ErrorAction Stop
# Disconnect cleanly
Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
Retrieving Mailbox Statistics
I needed to handle both primary and archive mailboxes, converting the Exchange data into a format suitable for trending analysis. The tricky part was parsing the size values from Exchange:
# Get main mailbox statistics
$mailbox = Get-Mailbox -Identity $EmailAddress -ErrorAction Stop
$stats = Get-MailboxStatistics -Identity $EmailAddress -ErrorAction Stop
if ($stats) {
$usedBytes = [int64]($stats.TotalItemSize.Value -replace '.*\(| bytes\)', '')
$quotaBytes = if ($mailbox.ProhibitSendReceiveQuota -ne 'Unlimited') {
[int64]($mailbox.ProhibitSendReceiveQuota -replace '.*\(| bytes\)', '')
} else {
100GB # Default to 100GB if unlimited
}
$result.MainMailbox.UsedGB = [Math]::Round($usedBytes / 1GB, 2)
$result.MainMailbox.TotalGB = [Math]::Round($quotaBytes / 1GB, 2)
$result.MainMailbox.UsedPercent = [Math]::Round(($usedBytes / $quotaBytes) * 100, 2)
}
Tracking Historical Data and Trends
One of the key requirements was tracking trends over time. I implemented a JSON-based storage system that maintains the last 6 data points for each mailbox. The challenge was ensuring the data persisted correctly and handled type conversions:
# Add current data point
$dataPoint = @{
Timestamp = $CurrentData.Timestamp
MainUsedPercent = $CurrentData.MainMailbox.UsedPercent
MainUsedGB = $CurrentData.MainMailbox.UsedGB
ArchiveUsedPercent = $CurrentData.ArchiveMailbox.UsedPercent
ArchiveUsedGB = $CurrentData.ArchiveMailbox.UsedGB
}
$historicalData[$email] += $dataPoint
# Keep only last N values
if ($historicalData[$email].Count -gt $MaxValues) {
$historicalData[$email] = $historicalData[$email][-$MaxValues..-1]
}
Calculating Growth Trends and Days to Full
The trend calculation compares the current reading with the last historical value to determine if usage is increasing, decreasing, or stable:
# Calculate trends by comparing current with last historical value
if ($history.Count -ge 1) {
$lastMain = [double]($history[-1].MainUsedPercent)
$currentMain = [double]($mailbox.MainMailbox.UsedPercent)
if ($currentMain -gt $lastMain) {
$mainTrend = "up"
} elseif ($currentMain -lt $lastMain) {
$mainTrend = "down"
} else {
$mainTrend = "stable"
}
}
For the "days to full" calculation, I analyze the growth rate across historical data points:
# Calculate average daily growth rate
$growthRates = @()
for ($i = 1; $i -lt $HistoricalData.Count; $i++) {
$daysDiff = (([datetime]$HistoricalData[$i].Timestamp) -
([datetime]$HistoricalData[$i-1].Timestamp)).TotalDays
if ($daysDiff -gt 0) {
$percentDiff = $HistoricalData[$i].MainUsedPercent -
$HistoricalData[$i-1].MainUsedPercent
$dailyRate = $percentDiff / $daysDiff
if ($dailyRate -gt 0) {
$growthRates += $dailyRate
}
}
}
$avgDailyGrowth = ($growthRates | Measure-Object -Average).Average
$remainingPercent = 100 - $CurrentPercent
$daysToFull = [Math]::Round($remainingPercent / $avgDailyGrowth, 0)
Generating the HTML Dashboard
The HTML report needed to be clean, professional, and auto-refreshing. I used a grid layout with color-coded progress bars and trend indicators. The key was the meta refresh tag and gradient progress bars:
$html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="1800">
<title>Mailbox Usage Report - $(Get-Date -Format 'yyyy-MM-dd HH:mm')</title>
<style>
.progress-bar {
width: 100%;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-normal { background: linear-gradient(90deg, #4CAF50, #45a049); }
.progress-warning { background: linear-gradient(90deg, #FF9800, #F57C00); }
.progress-critical { background: linear-gradient(90deg, #f44336, #d32f2f); }
</style>
</head>
"@
Each mailbox gets its own card with visual indicators:
# Determine progress bar color based on usage
$progressClass = "progress-normal"
if ($mailbox.MainMailbox.UsedPercent -ge $Config.CriticalThreshold) {
$progressClass = "progress-critical"
} elseif ($mailbox.MainMailbox.UsedPercent -ge $Config.WarningThreshold) {
$progressClass = "progress-warning"
}
$html += @"
<div class="progress-bar">
<div class="progress-fill $progressClass" style="width: $($mailbox.MainMailbox.UsedPercent)%"></div>
</div>
"@
Handling Email Alerts
The email notification system sends color-coded alerts when thresholds are exceeded. I used inline CSS for maximum compatibility:
$color = if ($AlertType -eq "Critical") { "#d32f2f" } else { "#F57C00" }
$body = @"
<html>
<body style="font-family: Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: $color;">Mailbox Usage $AlertType Alert</h2>
<table style="width: 100%; border-collapse: collapse; margin-top: 20px;">
<tr style="background: #f0f0f0;">
<th style="padding: 10px; text-align: left;">Mailbox</th>
<th style="padding: 10px; text-align: left;">Usage</th>
</tr>
<!-- Table rows added dynamically -->
</table>
</div>
</body>
</html>
"@
# Send via SMTP without TLS/authentication
Send-MailMessage @mailParams -UseSsl:$false
The Main Monitoring Loop
Rather than relying on Task Scheduler, I implemented a continuous monitoring loop that runs every 2 hours:
while ($true) {
try {
# Connect to Exchange Online
if (!(Connect-ExchangeOnlineSession -UPN $Config.UPN)) {
Start-Sleep -Seconds ($Config.CheckIntervalHours * 3600)
continue
}
# Process each mailbox
foreach ($email in $Config.MailboxesToMonitor) {
$data = Get-MailboxUsageData -EmailAddress $email
# Check thresholds and update historical data
}
# Disconnect from Exchange Online
Disconnect-ExchangeOnline -Confirm:$false
# Generate HTML report and send alerts if needed
} catch {
Write-Log "Error in monitoring cycle: $_" -Level Error
}
# Wait for next cycle
Start-Sleep -Seconds ($Config.CheckIntervalHours * 3600)
}
Historical Data Persistence
Initially, the trend calculations weren't working because the JSON deserialization wasn't properly converting the data back into the expected format. I had to ensure numeric values were properly cast:
# When loading from JSON, ensure proper type conversion
foreach ($item in $prop.Value) {
$historicalData[$prop.Name] += @{
Timestamp = $item.Timestamp
MainUsedPercent = [double]$item.MainUsedPercent
MainUsedGB = [double]$item.MainUsedGB
ArchiveUsedPercent = [double]$item.ArchiveUsedPercent
ArchiveUsedGB = [double]$item.ArchiveUsedGB
}
}
Handling Edge Cases
The script needed to handle several edge cases gracefully:
# Handle unlimited quotas
$quotaBytes = if ($mailbox.ProhibitSendReceiveQuota -ne 'Unlimited') {
[int64]($mailbox.ProhibitSendReceiveQuota -replace '.*\(| bytes\)', '')
} else {
100GB # Default to 100GB if unlimited
}
# Handle zero or negative growth
if ($avgDailyGrowth -le 0) { return "N/A" }
# Handle slow growth
if ($daysToFull -gt 365) { return ">1 year" }
# Handle mailboxes without archives
if ($mailbox.ArchiveStatus -ne 'Active') {
$result.ArchiveMailbox.Available = $false
}
Configuration Made Simple
I centralized all configuration in a single hashtable at the top of the script:
$Config = @{
# Exchange Online Connection
UPN = "admin@company.co.uk"
# Mailboxes to Monitor
MailboxesToMonitor = @(
"lee@bythepowerofgreyskull.com",
"lee.admin@bythepowerofgreyskull.com" )
# Thresholds
WarningThreshold = 70 # Percentage
CriticalThreshold = 85 # Percentage
# File Paths (supports SMB shares)
DataFilePath = "C:\MonitoringData\MailboxUsageData.json"
HtmlOutputPath = "\\wwwsrv1.bear.local\webshare$\MailboxReport.html"
# Email Configuration
SmtpServer = "smtp.bear.local"
EmailFrom = "mailbox-monitor@company.co.uk"
EmailToWarning = @("alert.manager@bythepowerofgreyskull.com")
EmailToCritical = @("alert.manager@bythepowerofgreyskull.com", "alert@bear.local")
# Monitoring Settings
CheckIntervalHours = 2
MaxHistoricalValues = 6
}