Last active
July 15, 2025 13:19
-
-
Save karbomusic/74e2500588e5b9ceb675a600b5a2bb37 to your computer and use it in GitHub Desktop.
Test EWS Push Subscriptions on Exchange Server 2010-2019
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ------------------------------------------------------------------------------------- | |
| # 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