Skip to content

Instantly share code, notes, and snippets.

@karbomusic
Last active July 15, 2025 13:19
Show Gist options
  • Save karbomusic/74e2500588e5b9ceb675a600b5a2bb37 to your computer and use it in GitHub Desktop.
Save karbomusic/74e2500588e5b9ceb675a600b5a2bb37 to your computer and use it in GitHub Desktop.
Test EWS Push Subscriptions on Exchange Server 2010-2019
# -------------------------------------------------------------------------------------
# EWS PUSH SUBSCRIPTION TOOL 1.0
# This sample script is not supported in any way and is
# provided AS IS without warranty of any kind.
# -------------------------------------------------------------------------------------
#
# INFO:
# The goal of this script is to test basic functionality of an EWS Push Notifications.
# There are no dependencies on EWS Managed API.
#
# ** You must run PowerShell as Admininstrator in order to recieve notifications.**
# ** You must ensure the port for -ListenerEndpoint is open on the FIREWALL of this machine in order to receive notifications. **
# ** If you do not supply -ListenerEndpoint it will default to http://machninename:40001 **
#
# USAGE:
# .\PushNotificationToolPublic.ps1 -<EWSUri> -[ListenerEndpoint] -[FolderNames[]] -[Events[]] -[StatusFrequency] -[UseDefaultCredentials]
# -[Version] -[ImpersonatedUser] -[IncludeTranscript] -[NotificationLimit] -[IgnoreCertErrors]
#
# -<EWSUri> EWS URL of the front-end Exchange server
# -[ListenerEndpoint] The HTTP endpoint where notifications should be sent; usually the same machine as this script. Default: http://localmachinename:40001
# -[FolderNames[]] Array of comma-delimited well-known folder names. Default: Inbox
# -[Events[]] Array of comma-delimited events to be notified of when they occur. Default: NewMailEvent
# -[StatusFrequency] How often in minutes the Exchange server should send status events - similar to a keep alive. Default: 5
# -[ExchangeVersion] EWS schema version. Recommended to keep at the default since push subscriptions schema has not changed. Default: Exchange2010_SP1
# -[UseDefaultCredentials] If $true then send the credentials of the logged on user.
# -[Version] Prints powershell and .net version information.
# -[ImpersonatedUser] The user being impersonated if using impersonation. Default: Empty
# -[IncludeTranscript] Log each test to a powershell transcript file. Default: $true
# -[NotificationLimit] The total number of notifications, including status events, before the test is considered complete. Default: 3
# -[IgnoreCertErrors] When $true any certificate mismatches/validity issues are ignored. Default: $true
#
# EXAMPLES:
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -ListenerEndpoint None
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -ImpersonatedUser [email protected] -FolderNames Inbox -Events MovedEvent, CopiedEvent
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -ListenerEndpoint http://ClientMachine:1234/ -IncludeTranscript $true -NotificationLimit 20
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -UseDefaultCredentials $true -ListenerEndpoint http://ClientMachine:1234/
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -ListenerEndpoint http://ClientMachine:1234/ -FolderNames Inbox, SentItems -Events MovedEvent, NewMailEvent
# PushNotificationToolPublic.ps1 -EWSUri https://exchangeserver/ews/exchange.asmx -ListenerEndpoint http://ClientMachine:1234/ -StatusFrequency 2
#
# Includes potential linux support but this requires *temporarily* enabling Basic authentication on the exchange front-end/Cafe website.
# You also must manually add the ListenerEndpoint as linux doesn't seem to understand env:machinename via powershell core - powershell core is required:
# https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-linux?view=powershell-7
# -------------------------------------------------------------------------------------
#Parameters
Param(
[Parameter()]
[string]$EWSUri,
[Parameter()]
[ValidateSet("Inbox", "SentItems", "VoiceMail", "Outbox", "DeletedItems", "Calendar", "Contacts")]
[string[]]$FolderNames = "Inbox",
[Parameter()]
[ValidateSet("NewMailEvent", "CopiedEvent", "DeletedEvent", "MovedEvent", "CreatedEvent", "ModifiedEvent")]
[string[]]$Events = "NewMailEvent",
[Parameter()]
[string]$ListenerEndpoint = "http://" + $env:computername + ":40001/",
[Parameter()]
[string]$StatusFrequency = "5",
[Parameter()]
[ValidateSet("Exchange2010", "Exchange2010_SP1", "Exchange2010_SP2", "Exchange2013", "Exchange2013_SP1", "Exchange2015", "Exchange2016")]
[string]$ExchangeVersion = "Exchange2010_SP1",
[Parameter()]
[boolean]$UseDefaultCredentials = $false,
[Parameter()]
[int]$NotificationLimit = 3,
[Parameter()]
[string]$ImpersonatedUser,
[Parameter()]
[boolean]$IncludeTranscript = $true,
[Parameter()]
[boolean]$Version = $false,
[Parameter()]
[boolean]$IgnoreCertErrors = $true
)
if ($Version) {
Write-Host Version Information -ForegroundColor Cyan -BackgroundColor Black
$PSVersionTable
""
}
# Log the full session to the script directory.
if ($IncludeTranscript) { Start-Transcript -OutputDirectory $PSScriptRoot }
# -ListenerEndpoint requires trailing slash so we'll fixup as needed.
# In some scenarios, such as not being able to open the firewall for the listener,
# setting ListenerEndpoint to None will bypass the listener. If so, we pass a fake
# notification URL because EWS still requires that we pass 'some' well-formed URL.
# This will also prevent receiving notifications but we can just check for EWS 5/6/7 events in app log.
$FakeEndpoint = "http://0.0.0.0/"
if (!$ListenerEndpoint.EndsWith("/") -And $ListenerEndpoint.ToLower() -ne "none") {
$ListenerEndpoint += "/"
}
elseif ($ListenerEndpoint.ToLower() -eq "none") {
$ListenerEndpoint = $FakeEndpoint
}
#Make our XML pretty
function Format-XML([xml]$xml) {
$StringWriter = New-Object System.IO.StringWriter
$XmlWriter = New-Object System.XMl.XmlTextWriter $StringWriter
$xmlWriter.Formatting = 1
$xmlWriter.Indentation = 4
$xml.WriteContentTo($XmlWriter)
$XmlWriter.Flush()
$StringWriter.Flush()
return $StringWriter.ToString()
}
# Create list of monitored Events
function Get-EventsList {
$sb = New-Object -TypeName System.Text.StringBuilder
foreach ($EventType in $Events) {
[void]$sb.Append("<t:EventType>")
[void]$sb.Append($EventType)
[void]$sb.Append("</t:EventType>")
}
return $sb.ToString()
}
# Create list of monitored Folders
function Get-FolderList {
$sb = New-Object -TypeName System.Text.StringBuilder
foreach ($Folder in $FolderNames) {
[void]$sb.Append("<t:DistinguishedFolderId Id='")
[void]$sb.Append($Folder.ToLower())
[void]$sb.Append("' />")
}
return $sb.ToString()
}
# Ignore cert errors
add-type @"
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
public static class CertificateUtils {
public static bool TrustAllCertsCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) {
return true;
}
public static void TrustAllCerts() {
ServicePointManager.ServerCertificateValidationCallback = CertificateUtils.TrustAllCertsCallback;
}
}
"@
# Ignore cert errors if set
if ($IgnoreCertErrors) { [CertificateUtils]::TrustAllCerts() }
# Parse events and folders to build XML
$FolderString = Get-FolderList
$EventString = Get-EventsList
# Impersonation support. If $ImpersonatedUser is populated this will inject the necessary XML
# for impersonation into the request. Be aware that impersonation must be setup properly in Exchange.
$ImpersonationTemplate = "<t:ExchangeImpersonation><t:ConnectingSID><t:SmtpAddress>" + $ImpersonatedUser + "</t:SmtpAddress></t:ConnectingSID></t:ExchangeImpersonation>"
$ImpersontationXML = [string]::Empty;
if ($ImpersonatedUser) {
$ImpersontationXML = $ImpersonationTemplate
}
# Push Subcscription XML - PushSubscriptionRequest
$subscribeRequest = "<?xml version='1.0' encoding='utf-8'?>
<soap:Envelope xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
xmlns:m='http://schemas.microsoft.com/exchange/services/2006/messages'
xmlns:t='http://schemas.microsoft.com/exchange/services/2006/types'
xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'>
<soap:Header>
<t:RequestServerVersion Version='" + $ExchangeVersion + "' />" + $ImpersontationXML + "
</soap:Header>
<soap:Body>
<m:Subscribe>
<m:PushSubscriptionRequest>
<t:FolderIds>
" + $FolderString + "
</t:FolderIds>
<t:EventTypes>
" + $EventString + "
</t:EventTypes>
<t:StatusFrequency>" + $StatusFrequency + "</t:StatusFrequency>
<t:URL>" + $ListenerEndpoint + "</t:URL>
</m:PushSubscriptionRequest>
</m:Subscribe>
</soap:Body>
</soap:Envelope>"
# Push Notification XML - SendNotificationResult
# This is the response the client must respond with in order to renew (OK)
# or cancel (UNSUBSCRIBE) the subscription.
$notificationResult = "<?xml version='1.0'?>" +
"<s:Envelope xmlns:s='http://schemas.xmlsoap.org/soap/envelope/'>" +
"<s:Body>" +
"<SendNotificationResult xmlns='http://schemas.microsoft.com/exchange/services/2006/messages'>" +
"<SubscriptionStatus>OK</SubscriptionStatus>" +
"</SendNotificationResult>" +
"</s:Body>" +
"</s:Envelope>";
# Print relevant into to the console
[System.Environment]::NewLine
"------------------------------------------------------------"
Write-Host Pre-Subscription details -ForegroundColor Cyan -BackgroundColor Black
"Folders: " + $FolderNames
"EventTypes: " + $Events
"EWS Url: " + $EWSUri
"Send notifications to: " + $ListenerEndpoint
"Status Frequency: " + $StatusFrequency
"ImpersonatedUser: " + $ImpersonatedUser
"------------------------------------------------------------"
$subscribeRequest
# Create a web request and send the PushSubscriptionRequest to EWS
$request = [System.Net.WebRequest]::Create($EWSUri);
$request.UserAgent = "PushNotificationTool"
$request.Method = "POST"
$request.ContentLength = $subscribeRequest.Length
$request.ContentType = "text/xml; charset=utf-8"
$request.Headers.Add("SOAPAction", "http://schemas.microsoft.com/exchange/services/2006/messages/Subscribe")
# Gets the user credentials. Provides basic linux support.
# Be aware however that this requires *temporarily* enabling Basic auth on the FE EWS Vdir if use pscore!
if ($UseDefaultCredentials) {
$request.UseDefaultCredentials = $true
}
else {
$creds = Get-Credential
if ($PSEdition -eq 'Core') {
$bytes = [System.Text.Encoding]::UTF8.GetBytes(
('{0}:{1}' -f $creds.UserName, $creds.GetNetworkCredential().Password)
)
$Authorization = 'Basic {0}' -f ([Convert]::ToBase64String($bytes))
$request.Headers.Add("Authorization", $Authorization)
}
else {
$request.Credentials = $creds
}
}
# Convert the XML into a byte array and pack it into the request stream
$encoder = new-object System.Text.UTF8Encoding
$buffer = $encoder.Getbytes($subscribeRequest)
$dataStream = $request.GetRequestStream()
$dataStream.Write($buffer, 0, $buffer.Length)
$dataStream.Close()
try {
# POST the request and print response headers
$timestamp = Get-Date
Write-Host "Sending Request... " $timestamp -ForegroundColor White
$response = $request.GetResponse()
$timestamp = Get-Date
Write-Host Request completed successfully $timestamp -ForegroundColor Green
$response
# Validate and print full response
$hasError = $false;
$responseStream = $response.GetResponseStream()
$reader = New-Object -TypeName System.IO.StreamReader -ArgumentList $responseStream
$responseText = $reader.ReadToEnd()
if ($responseText.Contains("NoError")) {
Write-Host Subscription Created Successfully -ForegroundColor Green
# Log XML to a file
Format-XML ($responseText) | Out-File -FilePath .\Subscription.txt
}
else {
Write-Host Request was successful but subscription failed, check the response below for errors -ForegroundColor Red
$hasError = $true
}
Format-XML($responseText)
# Since the subscription XML has an error, bailout here.
if ($hasError) { return }
}
catch {
Write-Host There was a problem creating the subscription. -ForegroundColor Red
Write-Host $_ -ForegroundColor Red
return
}
# Check if user is forgoing listener endpoint and cancel if needed.
if ($ListenerEndpoint.ToLower() -eq $FakeEndpoint) {
Write-Host Skipping listener since ListenerEndpoint was set to None. -ForegroundColor Yellow
Write-Host Subscription Test Complete -ForegroundColor White -BackgroundColor DarkGray
return
}
# If we made it this far, we must have an active subscription so setup and start the Notification Listener.
# We should receive an initial empty notification or "StatusEvent" at the 30 second mark. Firewall?? ;)
# If the Exchange Server cannot properly talk to this listener, it will log Event IDs 5/6/7 for EWS. Firewall?? ;)
# We will acknowledge and continue to listen until -NotificationLimit = 3 which is a configurable limit.
# Then we'll consider the test complete, close the listener and complete this script.
# Warning: If you force kill this script, you may hang open the listened to port indefinitely. If you need to cancel,
# copy and paste the endpoint URL into your browser which will be seen as a blank notification and unblock.
$NotificationCount = 1;
$listener = New-Object -TypeName System.Net.HttpListener
$listener.Prefixes.Add($ListenerEndpoint)
$listening = $true;
[System.Environment]::NewLine
Write-Host "Listening for notifications @" $ListenerEndpoint ... -ForegroundColor Cyan
try {
$listener.Start()
}
catch {
Write-Host Could not start Listener
Write-Host $_ -ForegroundColor Red
return
}
$stopwatch = [system.diagnostics.stopwatch]::StartNew()
#GetContext() blocks until a request arrives aka we are listening
while ($listening) {
$context = $listener.GetContext()
$request = $context.Request
$listenerResponse = $context.Response
$listenerResponse.ContentType = "text/xml";
$timestamp = Get-Date
Write-Host Notification $NotificationCount of $NotificationLimit received: $timestamp - Elapsed: $stopwatch.Elapsed -ForegroundColor Green
$body = $request.InputStream
$encoding = $request.ContentEncoding
$reader = New-Object System.IO.StreamReader -ArgumentList $body, $encoding
$requestXML = Format-XML($reader.ReadToEnd())
if ($requestXML.Contains("StatusEvent>")) {
# This is a StatusEvent Notification, interval is controlled by StatusFrequency.
# One of these also always arrives (and requires responding with NotificationResult OK)
# 30 seconds after the initial subscription.
Write-Host Status Event -ForegroundColor White -BackgroundColor DarkGray
Write-Host $requestXML -ForegroundColor Yellow
# Log XML to a file
$requestXML | Out-File -FilePath .\LastStatusEvent.txt
}
else {
# This is a real incoming notification, not a status/keep-alive event.
Write-Host Notification Event -ForegroundColor White -BackgroundColor DarkGray
Write-Host $requestXML -ForegroundColor Green
# Log XML to a file
$requestXML | Out-File -FilePath .\LastNotificationEvent.txt
}
# Keep the subscription alive until we reach the Notification Limit, once we reach the limit, unsubscribe and complete the test.
if ($NotificationCount -ge $NotificationLimit) {
Write-Host Notification limit of $NotificationLimit reached, completing test...
$listening = $false;
$notificationResult = $notificationResult.Replace("OK", "UNSUBSCRIBE")
}
else {
$NotificationCount += 1
}
# Respond to server and keep subscription alive or unsubscribe
Write-Host Responding to server with: -ForegroundColor White -BackgroundColor DarkGray
Format-XML($notificationResult)
if ($listening)
{ Write-Host Listening... -ForegroundColor White -BackgroundColor DarkGray }
$encoder = new-object System.Text.UTF8Encoding
$buffer = $encoder.Getbytes($notificationResult)
$listenerResponse.StatusCode = 200;
$listenerResponse.StatusDescription = "OK"
$listenerResponse.ContentLength64 = $buffer.Length;
$output = $listenerResponse.OutputStream;
$output.Write($buffer, 0, $buffer.Length);
# Clean up
$output.Close()
$body.Close()
$reader.Close()
}
$listener.Stop()
Write-Host Notification Test Complete -ForegroundColor White -BackgroundColor DarkGray
if ($IncludeTranscript) { Stop-Transcript }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment