############################################################################### #### windows-exchange-smoke-tests.ps1 — Verify Exchange Server health #### #### Checks Exchange services, OWA, ECP, Autodiscover, ActiveSync, EWS, #### #### MAPI/HTTP, mail flow, queues, DAG, databases, certs, and event logs. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: .\windows-exchange-smoke-tests.ps1 #### #### .\windows-exchange-smoke-tests.ps1 -ExchangeServer mail01 #### #### .\windows-exchange-smoke-tests.ps1 -OutputFormat tap #### #### #### #### See -Help for all options. #### ############################################################################### [CmdletBinding()] param( [string]$ExchangeServer = "localhost", [int]$CertExpiryDays = 30, [int]$MaxQueueLength = 100, [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" # ============================================================================ # HELP # ============================================================================ if ($Help) { @" Usage: .\windows-exchange-smoke-tests.ps1 [OPTIONS] Smoke-test Exchange Server infrastructure. PowerShell 5.1+. Designed for Exchange servers or machines with Exchange Management Shell. Parameters: -ExchangeServer HOST Target Exchange server hostname or IP (default: localhost) -CertExpiryDays N Certificate expiry warning threshold in days (default: 30) -MaxQueueLength N Max messages per queue before failure (default: 100) -OutputFormat FORMAT Output: text (default), tap -NoColor Disable coloured output -Verbose Show debug output -Help Show this help Examples: .\windows-exchange-smoke-tests.ps1 .\windows-exchange-smoke-tests.ps1 -ExchangeServer mail01.corp.local .\windows-exchange-smoke-tests.ps1 -CertExpiryDays 60 -MaxQueueLength 50 .\windows-exchange-smoke-tests.ps1 -OutputFormat tap -NoColor "@ exit 0 } # ============================================================================ # STATE # ============================================================================ $script:Pass = 0 $script:Fail = 0 $script:Skip = 0 $script:Total = 0 $script:Results = @() $script:StartTime = Get-Date $script:EMSLoaded = $false # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } function Write-Log { param([string]$Msg) Write-Color "[INFO] $Msg" "Cyan" } function Write-Warn { param([string]$Msg) Write-Color "[WARN] $Msg" "Yellow" } function Write-Err { param([string]$Msg) Write-Color "[ERROR] $Msg" "Red" } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name$(if($Detail){" ($Detail)"})" } else { $mark = if ($NoColor) { "[PASS]" } else { [char]0x2713 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $mark = if ($NoColor) { "[FAIL]" } else { [char]0x2717 } $msg = " $mark $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++ $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $mark = if ($NoColor) { "[SKIP]" } else { [char]0x2298 } $msg = " $mark $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Test-CommandExists { param([string]$Command) $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) } function Write-Section { param([string]$Name) if ($OutputFormat -eq "text") { Write-Host "" Write-Color $Name "White" } } function Test-HttpEndpoint { param([string]$Url, [string]$Name) try { # Ignore certificate errors for self-signed Exchange certs try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } } catch {} $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop Record-Pass $Name "$Url - HTTP $($response.StatusCode)" } catch { $ex = $_.Exception if ($ex.Response) { $statusCode = [int]$ex.Response.StatusCode # 401/403 means the endpoint is alive but requires auth — that's fine if ($statusCode -eq 401 -or $statusCode -eq 403) { Record-Pass $Name "$Url - HTTP $statusCode (auth required)" } else { Record-Fail $Name "$Url - HTTP $statusCode" } } else { Record-Fail $Name "$Url - $($ex.Message)" } } finally { try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null } catch {} } } # ============================================================================ # EXCHANGE MANAGEMENT SHELL # ============================================================================ function Initialize-ExchangeShell { # Check if Exchange cmdlets are already available if (Test-CommandExists "Get-ExchangeServer") { $script:EMSLoaded = $true Write-Verbose "Exchange Management Shell already loaded" return } # Try loading the Exchange snap-in try { Add-PSSnapin Microsoft.Exchange.Management.PowerShell.SnapIn -ErrorAction Stop $script:EMSLoaded = $true Write-Verbose "Exchange Management Shell loaded via snap-in" return } catch { Write-Verbose "Snap-in load failed: $($_.Exception.Message)" } # Try the Exchange module path $exchangeShell = "$env:ExchangeInstallPath\bin\RemoteExchange.ps1" if ($exchangeShell -and (Test-Path $exchangeShell -ErrorAction SilentlyContinue)) { try { . $exchangeShell Connect-ExchangeServer -auto -ClientApplication:ManagementShell $script:EMSLoaded = $true Write-Verbose "Exchange Management Shell loaded via RemoteExchange.ps1" return } catch { Write-Verbose "RemoteExchange.ps1 failed: $($_.Exception.Message)" } } Write-Verbose "Exchange Management Shell not available — falling back to connectivity tests" } # ============================================================================ # TESTS # ============================================================================ # -- 1. Exchange Core Services ----------------------------------------------- function Test-ExchangeServices { Write-Section "Services" $services = @( "MSExchangeADTopology", "MSExchangeIS", "MSExchangeTransport", "MSExchangeServiceHost", "MSExchangeMailboxAssistants", "MSExchangeDelivery", "MSExchangeSubmission", "MSExchangeRPC", "MSExchangeFastSearch", "MSExchangeRepl" ) $running = 0 $stopped = 0 $missing = 0 $stoppedNames = @() foreach ($svc in $services) { try { $service = Get-Service -Name $svc -ComputerName $ExchangeServer -ErrorAction Stop if ($service.Status -eq "Running") { $running++ } else { $stopped++ $stoppedNames += "$svc ($($service.Status))" } } catch { $missing++ Write-Verbose "Service $svc not found on $ExchangeServer" } } if ($missing -eq $services.Count) { Record-Skip "Exchange services running" "no Exchange services found on $ExchangeServer" } elseif ($stopped -gt 0) { Record-Fail "Exchange services running" "$stopped stopped: $($stoppedNames -join ', ')" } else { Record-Pass "Exchange services running" "$running of $($services.Count) services running" } } # -- 2. Exchange Management Shell ------------------------------------------- function Test-ExchangeManagementShell { if ($script:EMSLoaded) { try { $server = Get-ExchangeServer $ExchangeServer -ErrorAction Stop Record-Pass "Exchange Management Shell" "loaded — $($server.Name) ($($server.ServerRole))" } catch { Record-Pass "Exchange Management Shell" "loaded" } } else { Record-Skip "Exchange Management Shell" "snap-in not available" } } # -- 3. OWA ----------------------------------------------------------------- function Test-OWA { Write-Section "Client Access Endpoints" $url = "https://$ExchangeServer/owa/" Test-HttpEndpoint -Url $url -Name "OWA responding" } # -- 4. ECP ----------------------------------------------------------------- function Test-ECP { $url = "https://$ExchangeServer/ecp/" Test-HttpEndpoint -Url $url -Name "ECP responding" } # -- 5. Autodiscover --------------------------------------------------------- function Test-Autodiscover { $url = "https://$ExchangeServer/Autodiscover/Autodiscover.xml" Test-HttpEndpoint -Url $url -Name "Autodiscover endpoint" } # -- 6. ActiveSync ----------------------------------------------------------- function Test-ActiveSync { $url = "https://$ExchangeServer/Microsoft-Server-ActiveSync" Test-HttpEndpoint -Url $url -Name "ActiveSync endpoint" } # -- 7. EWS ------------------------------------------------------------------ function Test-EWS { $url = "https://$ExchangeServer/EWS/Exchange.asmx" Test-HttpEndpoint -Url $url -Name "EWS endpoint" } # -- 8. MAPI/HTTP ------------------------------------------------------------ function Test-MAPIHTTP { $url = "https://$ExchangeServer/mapi/" Test-HttpEndpoint -Url $url -Name "MAPI/HTTP endpoint" } # -- 9. Mail Flow (Transport Queues) ----------------------------------------- function Test-MailFlow { Write-Section "Mail Flow" if (-not $script:EMSLoaded) { Record-Skip "Mail flow" "Exchange Management Shell not loaded" return } try { $queues = Get-Queue -Server $ExchangeServer -ErrorAction Stop if (-not $queues) { Record-Pass "Mail flow" "no queues returned (transport idle)" return } $deferred = $queues | Where-Object { $_.Status -eq "Retry" -or $_.DeliveryType -eq "Unreachable" } if ($deferred) { $count = ($deferred | Measure-Object).Count Record-Fail "Mail flow" "$count queue(s) in retry or unreachable state" } else { $queueCount = ($queues | Measure-Object).Count Record-Pass "Mail flow" "$queueCount queue(s), none in retry/unreachable" } } catch { Record-Fail "Mail flow" "Get-Queue error - $($_.Exception.Message)" } } # -- 10. Mail Queue Length --------------------------------------------------- function Test-MailQueueLength { if (-not $script:EMSLoaded) { Record-Skip "Mail queue length" "Exchange Management Shell not loaded" return } try { $queues = Get-Queue -Server $ExchangeServer -ErrorAction Stop if (-not $queues) { Record-Pass "Mail queue length" "no queues (transport idle)" return } $overThreshold = $queues | Where-Object { $_.MessageCount -gt $MaxQueueLength } if ($overThreshold) { $worst = ($overThreshold | Sort-Object MessageCount -Descending | Select-Object -First 1) Record-Fail "Mail queue length" "$($worst.Identity) has $($worst.MessageCount) messages (threshold $MaxQueueLength)" } else { $totalMessages = ($queues | Measure-Object -Property MessageCount -Sum).Sum Record-Pass "Mail queue length" "$totalMessages total messages, threshold $MaxQueueLength" } } catch { Record-Fail "Mail queue length" "Get-Queue error - $($_.Exception.Message)" } } # -- 11. DAG Health ---------------------------------------------------------- function Test-DAGHealth { Write-Section "Database Availability Group" if (-not $script:EMSLoaded) { Record-Skip "DAG health" "Exchange Management Shell not loaded" return } try { $server = Get-ExchangeServer $ExchangeServer -ErrorAction Stop $dagMembership = Get-DatabaseAvailabilityGroup -ErrorAction Stop | Where-Object { $_.Servers -contains $server.Name -or $_.Servers -contains $server.Identity } if (-not $dagMembership) { Record-Skip "DAG health" "not a DAG member" return } $dagName = $dagMembership.Name $memberCount = ($dagMembership.Servers | Measure-Object).Count # Check replication health try { $replHealth = Test-ReplicationHealth -Server $ExchangeServer -ErrorAction Stop $failures = $replHealth | Where-Object { $_.Result -ne "Passed" } if ($failures) { $failCount = ($failures | Measure-Object).Count $failNames = ($failures | Select-Object -ExpandProperty Check) -join ", " Record-Fail "DAG health" "$dagName - $failCount check(s) failed: $failNames" } else { Record-Pass "DAG health" "$dagName, $memberCount members, all checks passed" } } catch { Record-Pass "DAG health" "$dagName, $memberCount members" } } catch { Record-Fail "DAG health" "error - $($_.Exception.Message)" } } # -- 12. Database Copy Status ------------------------------------------------ function Test-DatabaseCopyStatus { if (-not $script:EMSLoaded) { Record-Skip "Database copy status" "Exchange Management Shell not loaded" return } try { $copies = Get-MailboxDatabaseCopyStatus -Server $ExchangeServer -ErrorAction Stop if (-not $copies) { Record-Skip "Database copy status" "no database copies found" return } $unhealthy = $copies | Where-Object { $_.Status -notin @("Healthy", "Mounted") } if ($unhealthy) { $count = ($unhealthy | Measure-Object).Count $details = ($unhealthy | ForEach-Object { "$($_.DatabaseName): $($_.Status)" }) -join ", " Record-Fail "Database copy status" "$count unhealthy: $details" } else { $copyCount = ($copies | Measure-Object).Count Record-Pass "Database copy status" "$copyCount copies, all healthy/mounted" } } catch { Record-Fail "Database copy status" "error - $($_.Exception.Message)" } } # -- 13. Database Mount Status ----------------------------------------------- function Test-DatabaseMountStatus { Write-Section "Databases" if (-not $script:EMSLoaded) { Record-Skip "Database mount status" "Exchange Management Shell not loaded" return } try { $databases = Get-MailboxDatabase -Server $ExchangeServer -Status -ErrorAction Stop if (-not $databases) { Record-Skip "Database mount status" "no databases found on $ExchangeServer" return } $dismounted = $databases | Where-Object { -not $_.Mounted } if ($dismounted) { $count = ($dismounted | Measure-Object).Count $names = ($dismounted | Select-Object -ExpandProperty Name) -join ", " Record-Fail "Database mount status" "$count dismounted: $names" } else { $dbCount = ($databases | Measure-Object).Count Record-Pass "Database mount status" "$dbCount database(s), all mounted" } } catch { Record-Fail "Database mount status" "error - $($_.Exception.Message)" } } # -- 14. Certificate Status -------------------------------------------------- function Test-CertificateStatus { Write-Section "Certificates" if (-not $script:EMSLoaded) { Record-Skip "Certificate expiry" "Exchange Management Shell not loaded" return } try { $certs = Get-ExchangeCertificate -Server $ExchangeServer -ErrorAction Stop if (-not $certs) { Record-Skip "Certificate expiry" "no Exchange certificates found" return } $now = Get-Date $threshold = $now.AddDays($CertExpiryDays) $expired = @() $expiring = @() foreach ($cert in $certs) { if ($cert.NotAfter -lt $now) { $expired += $cert } elseif ($cert.NotAfter -lt $threshold) { $expiring += $cert } } if ($expired.Count -gt 0) { $names = ($expired | ForEach-Object { "$($_.Subject) (expired $($_.NotAfter.ToString('yyyy-MM-dd')))" }) -join ", " Record-Fail "Certificate expiry" "$($expired.Count) expired: $names" } elseif ($expiring.Count -gt 0) { $nearest = ($expiring | Sort-Object NotAfter | Select-Object -First 1) $daysLeft = [math]::Floor(($nearest.NotAfter - $now).TotalDays) Record-Fail "Certificate expiry" "$($expiring.Count) expiring within $CertExpiryDays days (nearest: $daysLeft days)" } else { $certCount = ($certs | Measure-Object).Count $nearest = ($certs | Sort-Object NotAfter | Select-Object -First 1) $daysLeft = [math]::Floor(($nearest.NotAfter - $now).TotalDays) Record-Pass "Certificate expiry" "$certCount cert(s), nearest expires in $daysLeft days" } } catch { Record-Fail "Certificate expiry" "error - $($_.Exception.Message)" } } # -- 15. Outlook Anywhere --------------------------------------------------- function Test-OutlookAnywhere { Write-Section "Outlook Anywhere" if (-not $script:EMSLoaded) { Record-Skip "Outlook Anywhere" "Exchange Management Shell not loaded" return } try { $oa = Get-OutlookAnywhere -Server $ExchangeServer -ErrorAction Stop if (-not $oa) { Record-Skip "Outlook Anywhere" "not configured on $ExchangeServer" return } $external = $oa.ExternalHostname $authMethod = $oa.ExternalClientAuthenticationMethod if ($external) { Record-Pass "Outlook Anywhere" "external: $external ($authMethod)" } else { Record-Pass "Outlook Anywhere" "configured (no external hostname)" } } catch { Record-Fail "Outlook Anywhere" "error - $($_.Exception.Message)" } } # -- 16. Exchange Event Log -------------------------------------------------- function Test-EventLog { Write-Section "Event Log" try { $since = (Get-Date).AddHours(-24) $events = Get-WinEvent -FilterHashtable @{ LogName = "Application" ProviderName = "MSExchange*" Level = 2 # Error StartTime = $since } -ComputerName $ExchangeServer -MaxEvents 50 -ErrorAction Stop if ($events) { $count = ($events | Measure-Object).Count $sources = ($events | Group-Object ProviderName | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { "$($_.Name) ($($_.Count))" }) -join ", " Record-Fail "Exchange event log" "$count error(s) in last 24h: $sources" } else { Record-Pass "Exchange event log" "no errors in last 24h" } } catch { if ($_.Exception.Message -match "No events were found") { Record-Pass "Exchange event log" "no errors in last 24h" } else { Record-Skip "Exchange event log" "cannot read event log - $($_.Exception.Message)" } } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Header { if ($OutputFormat -eq "tap") { Write-Host "TAP version 13" } else { Write-Host "" Write-Color "Windows Exchange Smoke Tests" "White" Write-Host "Server: $ExchangeServer" Write-Host "EMS: $(if ($script:EMSLoaded) { 'loaded' } else { 'not available' })" Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } } function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) if ($OutputFormat -eq "tap") { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } else { Write-Host "" $separator = [string]::new([char]0x2500, 40) Write-Color $separator "White" Write-Color "Summary $ExchangeServer" "White" $summaryLine = " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Host $summaryLine Write-Color $separator "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } } # ============================================================================ # MAIN # ============================================================================ # Load Exchange Management Shell if available Initialize-ExchangeShell Write-Header # Run all tests Test-ExchangeServices Test-ExchangeManagementShell Test-OWA Test-ECP Test-Autodiscover Test-ActiveSync Test-EWS Test-MAPIHTTP Test-MailFlow Test-MailQueueLength Test-DAGHealth Test-DatabaseCopyStatus Test-DatabaseMountStatus Test-CertificateStatus Test-OutlookAnywhere Test-EventLog Write-Summary if ($script:Fail -eq 0) { exit 0 } else { exit 1 }