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 across all major email providers.

Written by Qasim Muhammad Staff SRE

Reviewed by Nick Barraclough

VerifiedCLI 3.1.1 · Gmail, Outlook · last tested April 11, 2026

One-time setup

Every monitoring script on this page requires a working Nylas CLI installation authenticated with a valid email grant. Setup takes under 2 minutes: install via Homebrew or the shell script, then run nylas init to connect your email account. The getting started guide covers all 4 install methods for macOS, Linux, and Windows.

Pick an email account your operations team already monitors. Alert emails are sent through that authenticated grant, so every alert lands in a shared inbox rather than one person's mailbox. No SMTP credentials, app passwords, or relay servers are needed -- Nylas handles authentication across Gmail, Outlook, and other providers through OAuth 2.0.

Disk space alerts

Disk space monitoring uses PowerShell's Get-CimInstance Win32_LogicalDisk to check free space on every fixed drive and email an alert when any drive falls below a configurable threshold. According to Microsoft's Windows Server documentation, running below 10% free disk space causes performance degradation, and dropping below 1% can corrupt the Event Log.

The disk-alert.ps1 script queries all local fixed drives (DriveType 3), calculates free space as a percentage, and filters for drives under the threshold. When a match is found, it sends an email listing the affected drives plus a full status table of all drives. The default threshold is 10%, but you can override it with the -ThresholdPercent parameter.

# 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

Service health monitoring uses PowerShell's Get-Service cmdlet to poll critical Windows services by name and send an email alert when any service has a status other than "Running." A typical Windows Server 2022 installation runs over 200 services, and a stopped IIS or SQL Server instance can go unnoticed for 20+ minutes before a customer reports the outage.

The script iterates over a configurable list of service names, checks each status, and optionally attempts a restart with Restart-Service before alerting. The -AttemptRestart switch enables auto-recovery, so the alert email reports whether the restart succeeded or failed. The email body includes a full status table of all monitored services for quick triage.

# 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

Windows Event Log alerting uses Get-WinEvent to query Error and Critical events from the System and Application logs over a configurable time window, then emails a grouped summary with the 5 most recent entries. According to Microsoft's documentation, Event ID 6008 indicates an unexpected shutdown, and Event IDs 1000-1001 in the Application log signal application crashes.

The script filters by severity Level 1 (Critical) and Level 2 (Error), groups results by ProviderName so you see which sources generate the most noise, and truncates each event message to 200 characters to keep the email readable. Running this hourly via Task Scheduler catches error spikes within 60 minutes of occurrence.

# 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

Scheduled task monitoring uses Get-ScheduledTaskInfo to check the LastTaskResult of named tasks and email an alert when any task reports a non-zero exit code. Windows Task Scheduler stores exit codes as 32-bit HRESULTs -- a LastTaskResult of 0 means success, while 0x1 indicates an incorrect function call and 0x41301 means the task is still running.

The script converts each failure code to hexadecimal for easier lookup in Microsoft's documentation, includes the last run timestamp, and skips tasks that don't exist on the server with a clear "TASK NOT FOUND" message. Running this check hourly catches overnight backup failures or broken cleanup jobs before they cause downstream issues.

# 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

CPU and memory alerting uses PowerShell's Get-Counter and Get-CimInstance Win32_OperatingSystem to measure system resource usage and email an alert when either metric crosses a configurable threshold. Sustained CPU above 90% for 10+ seconds typically signals a runaway process, while memory above 90% indicates a leak or undersized server that risks out-of-memory kills.

The script samples CPU load 5 times at 2-second intervals (10 seconds total) and averages the readings to filter out momentary spikes. For memory, it calculates used percentage from total and free physical memory. When either threshold triggers, the alert email includes the top 5 processes sorted by CPU time or working set size, so an operator can identify the offending process immediately.

# 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

SSL certificate expiry monitoring connects to each domain over HTTPS, reads the server certificate's expiration date, and emails an alert when any certificate is within a configurable number of days of expiring. According to the Netcraft Web Server Survey, 2.4% of HTTPS sites serve expired certificates at any given time -- an expired cert triggers browser warnings that block users and erode trust.

The script uses .NET's HttpWebRequest class to establish a TLS handshake with a 10-second timeout, then reads the certificate expiry from the ServicePoint. The default warning window is 30 days, which gives enough lead time to renew through most certificate authorities. Running this check once daily (every 1440 minutes) is sufficient since certificates don't expire unexpectedly.

# 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

Windows Task Scheduler can register each monitoring script as a repeating task with a configurable interval, so all 6 monitors run unattended. The recommended intervals are: resource alerts every 5 minutes, disk and service checks every 15 minutes, Event Log and task failure checks every 60 minutes, and SSL expiry checks every 1440 minutes (once per day).

The registration script creates all 6 tasks in a single run using Register-ScheduledTask. Each task runs under the SYSTEM account (or whichever account has Nylas CLI configured) and uses pwsh.exe with -NoProfile to skip profile loading. The -RepetitionDuration is set to 365 days, after which the tasks stop repeating and need re-registration.

# 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

These answers cover the most common questions about building PowerShell-based server monitoring with email alerts. Each answer corresponds to a monitoring pattern using PowerShell cmdlets for health checks and Nylas CLI for email delivery across Gmail, Outlook, and other providers.

How do I send disk space alerts from PowerShell?

Use Get-CimInstance Win32_LogicalDisk with DriveType=3 to check free space on each fixed drive. Compare the calculated percentage against a threshold -- 10% is the standard since Microsoft's documentation warns that performance degrades below that point. When a drive drops below the threshold, pipe the drive details into nylas email send and 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 the status of named services like W3SVC (IIS) or MSSQLSERVER. When any service reports a status other than "Running," the script can attempt a restart with Restart-Service -Force and then email you whether the recovery succeeded or failed. Pass the -AttemptRestart switch to enable auto-recovery before alerting.

How do I email Windows Event Log errors from PowerShell?

Use Get-WinEvent -FilterHashtable with Level 1 (Critical) and Level 2 (Error) to query events from the System and Application logs over the last hour. Group results by ProviderName for a summary, format the 5 most recent events with timestamps and truncated messages, and send via nylas email send. Schedule hourly to catch error spikes within 60 minutes of occurrence.


Next steps

With 6 monitoring scripts running on a schedule, the next step is to add reporting and integrate email alerts into CI/CD pipelines. These resources extend the monitoring setup with HTML email reports, pipeline notifications, and the full command reference for additional flags and options.