Guide

Server Monitoring Alerts with PowerShell Email

Gartner reports the average cost of IT downtime is $5,600 per minute. You don't need Datadog or PagerDuty for basic server monitoring. PowerShell can check disk space, service health, Event Log errors, and scheduled task status -- then email you the moment something breaks. This guide covers each pattern with Nylas CLI. Works with Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP.

By Qasim Muhammad

One-time setup

Install Nylas CLI and authenticate once. See the PowerShell email guide for full install options.

# Install and authenticate (one time)
irm https://cli.nylas.com/install.ps1 | iex
nylas auth config
# Paste your API key from dashboard-v3.nylas.com

Disk space alerts

According to Microsoft's Windows Server documentation, running below 10% free disk space causes performance degradation, and below 1% can corrupt the Event Log. This script checks every drive and alerts when free space drops below a threshold.

# disk-alert.ps1 -- Email alert when disk space is low
param(
    [int]$ThresholdPercent = 10,
    [string]$AlertTo = "ops@company.com"
)

$drives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
    Select-Object DeviceID,
        @{N='SizeGB'; E={[math]::Round($_.Size/1GB, 1)}},
        @{N='FreeGB'; E={[math]::Round($_.FreeSpace/1GB, 1)}},
        @{N='FreePct'; E={[math]::Round(($_.FreeSpace/$_.Size)*100, 1)}}

$lowDrives = $drives | Where-Object { $_.FreePct -lt $ThresholdPercent }

if ($lowDrives) {
    $hostname = $env:COMPUTERNAME
    $details = $lowDrives | ForEach-Object {
        "$($_.DeviceID) $($_.FreeGB) GB free of $($_.SizeGB) GB ($($_.FreePct)%)"
    }

    $body = @"
Disk space alert on $hostname

The following drives are below $ThresholdPercent% free:

$($details -join "`n")

All drives:
$($drives | ForEach-Object { "$($_.DeviceID) $($_.FreeGB)/$($_.SizeGB) GB ($($_.FreePct)%)" } | Out-String)

Checked at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@

    nylas email send --to $AlertTo `
        --subject "DISK ALERT: $hostname - $($lowDrives.Count) drive(s) low" `
        --body $body --yes

    Write-Host "Alert sent for $($lowDrives.Count) low drive(s)"
} else {
    Write-Host "All drives OK (above $ThresholdPercent% free)"
}

Service health checks

Windows servers run dozens of services. If SQL Server, IIS, or your application service stops, you want to know immediately -- not when a customer reports it 20 minutes later.

# service-monitor.ps1 -- Check critical services and alert on failures
param(
    [string[]]$CriticalServices = @(
        "W3SVC",           # IIS
        "MSSQLSERVER",     # SQL Server
        "wuauserv",        # Windows Update
        "Spooler",         # Print Spooler
        "WinRM"            # Windows Remote Management
    ),
    [string]$AlertTo = "ops@company.com",
    [switch]$AttemptRestart
)

$hostname = $env:COMPUTERNAME
$stoppedServices = @()

foreach ($svcName in $CriticalServices) {
    $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue

    if (-not $svc) {
        Write-Host "Service not found: $svcName" -ForegroundColor DarkGray
        continue
    }

    if ($svc.Status -ne 'Running') {
        Write-Host "DOWN: $svcName ($($svc.Status))" -ForegroundColor Red

        if ($AttemptRestart) {
            Write-Host "  Attempting restart..."
            try {
                Restart-Service -Name $svcName -Force -ErrorAction Stop
                Write-Host "  Restarted successfully" -ForegroundColor Green
                $stoppedServices += "$svcName - was $($svc.Status), RESTARTED"
            } catch {
                Write-Host "  Restart failed: $_" -ForegroundColor Red
                $stoppedServices += "$svcName - $($svc.Status), restart FAILED: $_"
            }
        } else {
            $stoppedServices += "$svcName - $($svc.Status)"
        }
    } else {
        Write-Host "OK: $svcName" -ForegroundColor Green
    }
}

if ($stoppedServices.Count -gt 0) {
    $body = @"
Service alert on $hostname

$($stoppedServices.Count) critical service(s) not running:

$($stoppedServices | ForEach-Object { "  - $_" } | Out-String)

Full service status:
$($CriticalServices | ForEach-Object {
    $s = Get-Service -Name $_ -ErrorAction SilentlyContinue
    if ($s) { "  $($_.PadRight(20)) $($s.Status)" } else { "  $($_.PadRight(20)) NOT FOUND" }
} | Out-String)

Checked at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@

    nylas email send --to $AlertTo `
        --subject "SERVICE ALERT: $hostname - $($stoppedServices.Count) service(s) down" `
        --body $body --yes
}

Windows Event Log alerts

The Windows Event Log captures errors, warnings, and critical events from the OS and applications. According to Microsoft, Event ID 6008 indicates an unexpected shutdown, and Event IDs 1000-1001 in the Application log signal application crashes. This script queries recent errors and emails a summary.

# eventlog-alert.ps1 -- Email recent Error and Critical events
param(
    [int]$HoursBack = 1,
    [string]$AlertTo = "ops@company.com"
)

$hostname = $env:COMPUTERNAME
$startTime = (Get-Date).AddHours(-$HoursBack)

# Query System and Application logs for Error and Critical events
$events = @()
foreach ($logName in @('System', 'Application')) {
    $events += Get-WinEvent -FilterHashtable @{
        LogName   = $logName
        Level     = 1, 2  # 1=Critical, 2=Error
        StartTime = $startTime
    } -ErrorAction SilentlyContinue
}

if ($events.Count -eq 0) {
    Write-Host "No errors in the last $HoursBack hour(s)"
    exit 0
}

# Group by source for a summary
$grouped = $events | Group-Object ProviderName |
    Sort-Object Count -Descending

$summary = $grouped | ForEach-Object {
    "$($_.Count) event(s) from $($_.Name)"
}

# Get the 5 most recent events with details
$recent = $events | Sort-Object TimeCreated -Descending |
    Select-Object -First 5 |
    ForEach-Object {
        @"
  [$($_.TimeCreated.ToString('HH:mm:ss'))] $($_.ProviderName)
  Event ID: $($_.Id) | Level: $($_.LevelDisplayName)
  $($_.Message.Substring(0, [Math]::Min(200, $_.Message.Length)))
"@
    }

$body = @"
Event Log alert on $hostname
$($events.Count) Error/Critical events in the last $HoursBack hour(s)

Summary by source:
$($summary | ForEach-Object { "  $_" } | Out-String)

Recent events:
$($recent -join "`n`n")

Checked at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@

nylas email send --to $AlertTo `
    --subject "EVENT LOG: $hostname - $($events.Count) error(s) in last $($HoursBack)h" `
    --body $body --yes

Write-Host "Alert sent: $($events.Count) events found"

Scheduled task failure monitoring

Windows Task Scheduler tasks can fail silently. This script checks the last run result of critical tasks and alerts on failures.

# task-monitor.ps1 -- Alert on failed scheduled tasks
param(
    [string[]]$TaskNames = @(
        "NylasInboxReport",
        "DatabaseBackup",
        "LogCleanup"
    ),
    [string]$AlertTo = "ops@company.com"
)

$hostname = $env:COMPUTERNAME
$failedTasks = @()

foreach ($taskName in $TaskNames) {
    $task = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue

    if (-not $task) {
        $failedTasks += "$taskName - TASK NOT FOUND"
        continue
    }

    # LastTaskResult 0 = success, anything else = failure
    if ($task.LastTaskResult -ne 0) {
        $hexResult = "0x{0:X}" -f $task.LastTaskResult
        $lastRun = $task.LastRunTime.ToString('yyyy-MM-dd HH:mm:ss')
        $failedTasks += "$taskName - Exit code $hexResult (last run: $lastRun)"
    }
}

if ($failedTasks.Count -gt 0) {
    $body = @"
Scheduled task alert on $hostname

$($failedTasks.Count) task(s) failed:

$($failedTasks | ForEach-Object { "  - $_" } | Out-String)

Checked at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@

    nylas email send --to $AlertTo `
        --subject "TASK ALERT: $hostname - $($failedTasks.Count) task(s) failed" `
        --body $body --yes
} else {
    Write-Host "All $($TaskNames.Count) tasks passed last run"
}

CPU and memory threshold alerts

Sustained high CPU or memory usage for extended periods usually signals a runaway process or memory leak. This script checks both and includes the top processes by resource usage in the alert.

# resource-alert.ps1 -- Alert on high CPU or memory usage
param(
    [int]$CpuThreshold = 90,
    [int]$MemoryThreshold = 90,
    [string]$AlertTo = "ops@company.com"
)

$hostname = $env:COMPUTERNAME
$alerts = @()

# CPU check (average over 5 samples, 2s apart)
$cpuAvg = (Get-Counter 'Processor(_Total)% Processor Time' `
    -SampleInterval 2 -MaxSamples 5 |
    ForEach-Object { $_.CounterSamples.CookedValue } |
    Measure-Object -Average).Average
$cpuPct = [math]::Round($cpuAvg, 1)

if ($cpuPct -ge $CpuThreshold) {
    $topCpu = Get-Process | Sort-Object CPU -Descending |
        Select-Object -First 5 Name, @{N='CPU_Sec';E={[math]::Round($_.CPU, 1)}}, Id
    $alerts += "CPU at $cpuPct% (threshold: $CpuThreshold%)"
    $alerts += "Top processes by CPU:"
    $topCpu | ForEach-Object { $alerts += "  $($_.Name) (PID $($_.Id)): $($_.CPU_Sec)s" }
}

# Memory check
$os = Get-CimInstance Win32_OperatingSystem
$totalMB = [math]::Round($os.TotalVisibleMemorySize / 1024)
$freeMB = [math]::Round($os.FreePhysicalMemory / 1024)
$usedPct = [math]::Round((1 - $freeMB / $totalMB) * 100, 1)

if ($usedPct -ge $MemoryThreshold) {
    $topMem = Get-Process | Sort-Object WorkingSet64 -Descending |
        Select-Object -First 5 Name, @{N='MemMB';E={[math]::Round($_.WorkingSet64/1MB)}}, Id
    $alerts += "Memory at $usedPct% ($freeMB MB free of $totalMB MB)"
    $alerts += "Top processes by memory:"
    $topMem | ForEach-Object { $alerts += "  $($_.Name) (PID $($_.Id)): $($_.MemMB) MB" }
}

if ($alerts.Count -gt 0) {
    $body = @"
Resource alert on $hostname

$($alerts -join "`n")

Checked at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@
    nylas email send --to $AlertTo `
        --subject "RESOURCE ALERT: $hostname - CPU:$cpuPct% MEM:$usedPct%" `
        --body $body --yes
} else {
    Write-Host "Resources OK: CPU $cpuPct%, Memory $usedPct%"
}

SSL certificate expiry alerts

According to the Netcraft Web Server Survey, 2.4% of HTTPS sites serve expired certificates at any given time. Catch yours before it expires.

# ssl-expiry-alert.ps1 -- Alert on certificates expiring within N days
param(
    [string[]]$Domains = @("app.company.com", "api.company.com"),
    [int]$WarningDays = 30,
    [string]$AlertTo = "ops@company.com"
)

$expiring = @()

foreach ($domain in $Domains) {
    try {
        $req = [System.Net.HttpWebRequest]::Create("https://$domain")
        $req.Timeout = 10000
        $req.AllowAutoRedirect = $false
        $resp = $req.GetResponse()
        $cert = $req.ServicePoint.Certificate
        $expiry = [DateTime]::Parse($cert.GetExpirationDateString())
        $daysLeft = ($expiry - (Get-Date)).Days
        $resp.Close()

        if ($daysLeft -le $WarningDays) {
            $expiring += "$domain expires $($expiry.ToString('yyyy-MM-dd')) ($daysLeft days)"
        }
        Write-Host "$domain - expires in $daysLeft days"
    } catch {
        $expiring += "$domain - FAILED TO CHECK: $_"
    }
}

if ($expiring.Count -gt 0) {
    $body = "SSL certificate expiry warning:`n`n$($expiring -join "`n")"
    nylas email send --to $AlertTo `
        --subject "SSL EXPIRY: $($expiring.Count) cert(s) within $WarningDays days" `
        --body $body --yes
}

Schedule all monitors with Task Scheduler

Register each monitoring script as a scheduled task. Run disk and service checks every 15 minutes, Event Log checks hourly, and SSL checks daily.

# Register monitoring scripts as scheduled tasks
$monitors = @(
    @{ Name = "DiskAlert"; Script = "disk-alert.ps1"; Interval = 15 }
    @{ Name = "ServiceMonitor"; Script = "service-monitor.ps1"; Interval = 15 }
    @{ Name = "EventLogAlert"; Script = "eventlog-alert.ps1"; Interval = 60 }
    @{ Name = "ResourceAlert"; Script = "resource-alert.ps1"; Interval = 5 }
    @{ Name = "TaskMonitor"; Script = "task-monitor.ps1"; Interval = 60 }
    @{ Name = "SSLExpiry"; Script = "ssl-expiry-alert.ps1"; Interval = 1440 }
)

foreach ($m in $monitors) {
    $action = New-ScheduledTaskAction `
        -Execute "pwsh.exe" `
        -Argument "-NoProfile -File C:Monitoring$($m.Script)"

    $trigger = New-ScheduledTaskTrigger `
        -Once -At (Get-Date) `
        -RepetitionInterval (New-TimeSpan -Minutes $m.Interval) `
        -RepetitionDuration (New-TimeSpan -Days 365)

    Register-ScheduledTask `
        -TaskName "Nylas-$($m.Name)" `
        -Action $action `
        -Trigger $trigger `
        -Description "$($m.Name) - runs every $($m.Interval) min"

    Write-Host "Registered: Nylas-$($m.Name) (every $($m.Interval) min)"
}

Frequently asked questions

How do I send disk space alerts from PowerShell?

Use Get-CimInstance Win32_LogicalDisk to check free space on each drive. Compare against a threshold (e.g., 10%). When a drive drops below, send an alert with nylas email send. Schedule the script with Task Scheduler to run every 15 minutes.

Can I monitor Windows services and get email alerts when they stop?

Yes. Use Get-Service to check status of critical services. When any service isn't running, the script can attempt a restart with Restart-Service and then email you the result. Add -AttemptRestart to enable auto-restart before alerting.

How do I email Windows Event Log errors from PowerShell?

Use Get-WinEvent -FilterHashtable to query Error and Critical events from the last hour. Group by source, format the top 5 events with details, and send via nylas email send. Schedule hourly to catch issues as they happen.


Next steps