Disclaimer: I do not accept responsibility for any issues arising from scripts being run without adequate understanding. It is the user's responsibility to review and assess any code before execution. More information

Building an Exchange Online Mailbox Monitoring System with PowerShell

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
}
Previous Post Next Post

نموذج الاتصال