Get ActiveSync device statistics faster

Get-MobileDeviceStatistics is an “expensive” cmdlet, in that it requires Exchange to connect to the server where the mailbox is located and get information that is stored in the mailbox. And for whatever reason, it is more expensive than other cmdlets that do the same thing, like Get-MailboxFolderStatistics. If you have Exchange servers in multiple sites, running Get-MobileDeviceStatistics from a server in a different site than the target mailbox, it can take minutes to get data back.

A customer has about 25,000 mailboxes on servers in the US, Europe, and APAC. They wanted to find out which users’ mobile devices have synced in the last 30 days and information about them. Running something like Get-Mailbox -resultsize unlimited | %{Get-MobileDeviceStatistics -Mailbox $_.Identity} is not efficient in such an environment. You end up making expensive calls to remote servers for users that don’t even have a mobile device. The customer’s script would eventually just time out because of network issues with disconnected sessions.

I wrote a script that completes in far less time because it connects directly to a user’s mailbox server to execute the cmdlet. To be efficient processing tens of thousands of users, the script checks whether a user has any ActiveSync partnerships so it doesn’t waste time querying a mailbox that will return no data, keeps track of which server a mailbox database is mounted, establishes a connection to a server only as needed, and imports only the one cmdlet that will be executed (which completes much faster than importing every cmdlet the admin’s RBAC roles provide).

Getting mobile device statistics for this many users still takes several hours to complete, but it does complete. The script uses progress bars to indicate how far along it is in processing all mailboxes, as indicating when it is looking up the active server for a database and when connecting to a server (though the latter two only take a few seconds each).

#Requires -Version 3
$activeServer = @{}
function Get-DatabaseActiveServer ($db)
	{
	if (-not($activeServer[$db]))
		{
		Write-Progress -Activity "Getting active server for $db" -Status " " -Id 2
		$mounted = Get-MailboxDatabaseCopyStatus -Identity $db | Where-Object {$_.Status -eq 'Mounted'}
		$activeServer[$db] = $mounted.ActiveDatabaseCopy
		Write-Progress -Activity "Getting active server for $db" -Id 2 -Completed
		}
	$activeServer[$db]
	}

#Get list of mailboxes to query
Write-Host "Getting list of mailboxes..."
$mb = Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize unlimited

#Loop through mailboxes and get mobile devices
for ($i=0;$i -lt $mb.Count;$i++)
	{
	Write-Progress -Activity "Processing mailbox for $($mb[$i].DisplayName)" -PercentComplete ($i/$mb.Count*100) `
		-Id 1 -Status "Overall progress:"
	
	#Check if mailbox has any EAS partnerships; avoids needing to query mailbox that will return no data
	if ((Get-CasMailbox -Identity $mb[$i].Identity).HasActiveSyncDevicePartnership)
		{
		$dbServer = Get-DatabaseActiveServer -db $mb[$i].Database
		
		#Determine if connection to remote server already exists by checking for server-prefixed command
		if (-not(Get-Command -Name Get-$($dbServer)MobileDeviceStatistics -ErrorAction SilentlyContinue))
			{
			Write-Progress -Activity "Connecting to $dbServer" -Status " " -Id 2
			#Limit imported session to only the needed command; avoids local session maximum function count and is faster
			Import-PSSession -Session (New-PSSession -ConfigurationName Microsoft.Exchange `
				-ConnectionUri http://$dbServer/powershell -Name 'ExchangeMDStat') -Prefix $dbServer `
				-DisableNameChecking -CommandName Get-MobileDeviceStatistics | Out-Null
			Write-Progress -Activity "Connecting to $dbServer" -Id 2 -Completed
			}
		
		#Build remote command to run as a string because the cmdlet name includes a variable
		$command = "Get-$($dbServer)MobileDeviceStatistics -Mailbox `"$($mb[$i].Identity)`""
		Invoke-Expression -Command $command | Where-Object {$_.LastSuccessSync -gt (Get-Date).AddDays(-30)} |
			Select-Object Identity,DeviceType,DeviceUserAgent,LastSuccessSync | Export-Csv -NoTypeInformation -Append `
			-Path c:\temp\MobileDevices-$(Get-Date -Format yyyy-MM-dd).csv
		}
	}
#Cleanup
Get-PSSession -Name 'ExchangeMDStat' -ErrorAction SilentlyContinue | Remove-PSSession

For those that wonder why I didn’t use Invoke-Command and avoid wasting time importing the remote session, I originally wrote this in the customer’s VDI on a Windows 7 workstation with PowerShell 2, which wouldn’t let me execute the command directly in the remote session because of the Exchange WinRM configuration of restricted language mode when sending local variables to be used in a remote session. This only happened with Powershell 2. On a Windows 10 client with PowerShell 5.1, it works fine. I measured the run time of the script several times with a sample of users and compared it to doing the same with Invoke-Command, and the difference was negligible. However, if you prefer to do it that way, here is a version that uses it:

#Requires -Version 3
$activeServer = @{}
function Get-DatabaseActiveServer ($db)
	{
	if (-not($activeServer[$db]))
		{
		Write-Progress -Activity "Getting active server for $db" -Status " " -Id 2
		$mounted = Get-MailboxDatabaseCopyStatus -Identity $db | Where-Object {$_.Status -eq 'Mounted'}
		$activeServer[$db] = $mounted.ActiveDatabaseCopy
		Write-Progress -Activity "Getting active server for $db" -Id 2 -Completed
		}
	$activeServer[$db]
	}

#Get list of mailboxes to query
Write-Host "Getting list of mailboxes..."
$mb = Get-Mailbox -RecipientTypeDetails UserMailbox -ResultSize unlimited

#Loop through mailboxes and get mobile devices
for ($i=0;$i -lt $mb.Count;$i++)
	{
	Write-Progress -Activity "Processing mailbox for $($mb[$i].DisplayName)" -PercentComplete ($i/$mb.Count*100) `
		-Id 1 -Status "Overall progress:"
	
	#Check if mailbox has any EAS partnerships; avoids needing to query mailbox that will return no data
	if ((Get-CasMailbox -Identity $mb[$i].Identity).HasActiveSyncDevicePartnership)
		{
		$dbServer = Get-DatabaseActiveServer -db $mb[$i].Database
		
		#Determine if connection to remote server already exists
		$session = Get-PSSession -Name "MDStat-$dbServer" -ErrorAction SilentlyContinue
		if (-not($session))
			{
			Write-Progress -Activity "Connecting to $dbServer" -Status " " -Id 2
			$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri http://$dbServer/powershell `
				-Name "MDStat-$dbServer"
			Write-Progress -Activity "Connecting to $dbServer" -Id 2 -Completed
			}
		
		#Put mailbox identity in variable because you can't use expressions with the using: scope modifier
		$mbIdentity = $mb[$i].Identity
		Invoke-Command -Session $session -Command {Get-MobileDeviceStatistics -Mailbox $using:mbIdentity} |
			Select-Object Identity,DeviceType,DeviceUserAgent,LastSuccessSync | Export-Csv -NoTypeInformation -Append `
			-Path c:\temp\MobileDevices-$(Get-Date -Format yyyy-MM-dd).csv
		}
	}
#Cleanup
Get-PSSession  | Where-Object {$_.Name -like "MDStat*"} | Remove-PSSession

Meeting cancellation script updated for large result sets

Articles in the "Meeting cancellation script" series

  1. Cancel future meetings in a mailbox
  2. Meeting cancellation script updated
  3. Meeting cancellation script updated for large result sets [This article]

The default for the script is to get all appointment items for the next year.  This includes all occurrences of recurring appointments.  For the really busy person, or if you specify the end date beyond a year, that could mean more than the maximum of 1000 items.  To accommodate more than the search result maximum, you would normally use paging.  A calendar view, however, does not support paging (even though the search result has a property to indicate there are more items than what were returned).  To work around this I added pseudo-paging that performs successive loops with a start date that is set to the start of the last appointment in the previous search result.  This causes duplicate items in the collection of appointments, though, so I added a step to filter these.  These changes are transparent, so you don’t need to do anything to accommodate mailboxes that have more than 1000 appointments.

Another change is the addition of the AttendeeMeetingsOnly parameter.  This switch causes the script to only process meetings where the mailbox is an attendee.  Parameter sets have been defined so you cannot use this parameter at the same time as IncludeAttendeeMeetings.  This now means the options are to process meetings where the mailbox is the organizer (the default), to additionally process meetings where the mailbox is an attendee (use IncludeAttendeeMeetings), or to process only the latter (use AttendeeMeetingsOnly).

I changed the way the script identifies recurring meetings because of an issue that sometimes occurs, causing the IsRecurring property to incorrectly have a value of True on a single-instance meeting.  And, lastly, already canceled meetings are now excluded.  (An attendee mailbox that has received a meeting cancellation, but has not been “accepted” by the user, is already in a cancelled state in the mailbox and cannot be declined.)  Thank you to Zafer for identifying bugs recently so they can be fixed.

Here is the updated script:

  Cancel-MailboxMeetings.ps1 (12.7 KiB)

PowerShell module for searching and restoring items in Recoverable Items

A recent addition to the Exchange administrator’s arsenal in Exchange Online is the ability to search and restore items located in the Recoverable Items folder of a mailbox, via Get-RecoverableItems  and Restore-RecoverableItems .  (I don’t find that it is documented yet, so I can’t link to cmdlet references.)  It is available only for Exchange Online and has other constraints, so I wrote a module that adds support for Exchange on-premises and additional features:

Module Native cmdlets
Supports Exchange Online mailboxes Yes Yes
Supports Exchange on-premises mailboxes Yes No
Supports archive mailboxes Yes No
Search in Deleted Items folder No Yes
Search in Deletions folder Yes Yes
Search in Purges folder Yes No
Search filter can use retention tag Yes No
Restore to original folder Yes Yes
Restore all item types Yes No

To avoid naming conflicts, my cmdlets are Get-MailboxRecoverableItems  and Restore-MailboxRecoverableItems .  The EXO cmdlets use native access to the store and require you to have the Mailbox Import Export role assigned to use them.  My module uses EWS and requires either full access or the impersonation right.  (Include the UseImpersonation parameter when you want to use the latter.)  If using on-premises, you can omit the Credential  parameter to use your current credentials.  If using Exchange Online (or to use explicit credentials on-premises) you need to use the Credential parameter to provide a credential object.

My cmdlets replicate the behavior of the native cmdlets as much as possible, such as the property names and format of the output:

  • SourceFolder is where the item is currently located.
  • If the item is stamped with the MAPI property that contains information about the folder it was in when it was deleted, the “short” folder ID is displayed in LastParentFolderId.  (Otherwise, it will have no value.)  This is not the complete entry ID for the folder, but is what is stored in the property, hence why I am calling it short.  (To be restored to that folder, if it still exists, requires building the complete ID, which the cmdlet will take care of.)
  • LastParentPath is where the item will be put if you restore it.  (This is why I mentioned I am replicating the behavior of the native cmdlets, because I wouldn’t have named the property that.)  This path will be the original folder if the LastParentFolderId is populated and that folder still exists, as indicated by the value of OriginalFolderExists.  Otherwise, it will be the path to the default folder of the item’s class.

Because of a bug in EWS when translating folder entry IDs in the archive mailbox, items restored from the archive mailbox will always be put in the primary mailbox’s default folder for the item’s class.  OriginalFolderExists will never have a value for items in the archive mailbox because the translation bug makes it impossible to determine if the original folder still exists.

You can’t pipe the output of Get-MailboxRecoverableItems  to Restore-RecoverableItems  (nor can you do so with the native cmdlets).  The Restore cmdlet takes the same arguments as the Get cmdlet.  You can modify the search filter for the restore based on the output of Get-MailboxRecoverableItems , including specifying an item entry ID or folder entry ID, but the Get cmdlet is essentially the same as running the Restore cmdlet with the WhatIf  parameter.

The default is to search just the Deletions folder of Recoverable Items (named, confusingly, RecoverableItems), but you can optionally specify any or all of the Purges folder, the archive mailbox’s Deletions folder, and the archive mailbox’s Purges folder.  (The cmdlet will silently skip the request to search an archive mailbox if there isn’t one, allowing you to pipe multiple mailboxes to the cmdlet without regard for whether any or all have archives.)  There are no restrictions on which parameters you use for a search filter (aka, there are no parameter sets), but using some combinations won’t be of value.  For example, providing an entry ID negates any value of also specifying a subject.

You can then run Restore-MailboxRecoverableItems  with the parameters you want to restore any or all of the matching items:

  • RestoreToFolderId is the short folder ID where the item has been placed.
  • WasRestoredToOriginalFolder will be True if the original folder was known and still exists, False is the original folder was known but no longer exists or if the original folder was not known.
  • WasRestoredSuccessfully will be True if the item was actually moved to the folder listed in ReturnedToFilePath, or False if an error occurred.
  • ReturnedToFilePath is the folder path where the item can now be found.

You can set whether to use autodiscover and, if not, the EWS URL at the top of the module.  (You can also enable SCP lookup, if using autodiscover, by setting the property on line 41.)  There are comments throughout if you want to see what is being done and why.  Download the module below:

  RecoverableItems.psm1 (26.3 KiB)

Find mailbox folders with recently changed content

Mailbox settings can be stored in a number of places inside a mailbox.  A number of my scripts use Exchange Web Services to manipulate these settings, usually when there isn’t another way for an admin to manage them on behalf of a user.  An example is Office 365’s app launcher, which is currently being updated to v3 for tenants.  Because the method of managing the v2 launcher doesn’t work with v3, I had to try and find where the settings are stored.  The first step in doing that is changing a setting the way a user does (in this case, OWA) and then looking for changed folders as an indication that the setting is stored somewhere in one of them.  I decided to share how I do that.

The folder property PR_LOCAL_COMMIT_TIME_MAX (0x670A) stores the last time an item in the folder has changed.  This can be used in a search filter to quickly find all folders that have changed since a specific time.  First, I define all the MAPI properties I am going to be using in a search filter or in the results:

For efficiency, you usually only want to return as many properties as you will be using in some way.  (It is easier/simpler, though, to return the first class properties, when you will be using a lot of the common properties.)  So I define a property set to return only what I need:

After creating a folder view and adding the property set to it, I need to create a search filter.  This filter gets all folders (that aren’t search folders) where the last commit time is newer than a date and time:

Next, I connect to the mailbox’s root folder because I want to search all folders in the mailbox, not just the ones the user can see, and execute a search:

I then loop through the results.  First class properties, even if you manually add them to a property set, can be referenced by the object’s property name, e.g., $folder.DisplayName, but all other properties (known as extended properties) are collectively stored in another property.  You have to attempt to retrieve a defined extended property from this collection.  To do this, you have to define a variable in which the value of the extended property will be stored.  If you don’t, you will get an error.  Then you call the TryGetPropertymethod, specifying the defined property you want and, by reference, which variable you want to put its value.  For example, such as with the last commit time:

(Using the [void]  type accelerator suppresses the output of the object that is returned, which in this case is just True or False as to whether the extended property is in the collection.  It is the same as piping the command to Out-Null .)  While having the folder name is helpful, that alone doesn’t always make it clear where that folder is.  Therefore, I also get the path to the folder.  But the property stores it as a binary value, so I convert it to a string:

I put all the folders in an object, reverse sort by the last modified date, and output it to the screen:

To make it more consumable for others, I prettied it up and put it into a script.  You can download the script or copy the code below (after expanding).

  Get-FoldersWithContentChanges.ps1 (5.6 KiB)



 

Updated script that applies retention tag to items in a default folder

Articles in the "Retention tag on default folder items" series

  1. Use EWS to apply retention policy to items in a default folder
  2. Script to set retention tag on default folder items updated to v1.1.1
  3. Default folder retention tag script updated to 1.3
  4. Updated script that applies retention tag to items in a default folder [This article]

Edit 12/1/17: The issue with the retention tag GUID not being set by Exchange Online has been resolved.  Therefore, I have removed the code that distinguishes between online and on-premises so that the GUID is always applied.

My script that adds a personal tag to items in a default folder stopped working correctly recently.  Even though it was applying a tag to matching items, the collection size was never getting smaller.  After investigating, I found that a change has been made in Exchange Online.  The property that holds the GUID of the applied tag is no longer used by Exchange Online.  This is also the property I am using to search for items to tag.  You can tell Exchange to set a value for the property, and it will respond without error, but it doesn’t actually update the property.  So my script kept getting items that had already been updated.  I suspect this has something to do with added support for labels from the Security and Compliance Center.

I have updated the script to instead filter items that do not have a retention period set.  To accommodate this distinction from how Exchange on-premises still operates, I have added the Environment parameter, which defaults to Exchange Online, so that the GUID will still be applied when run against an on-premises mailbox.  The code that handles changed result sets (where the number of items in the results changes while in the middle of processing) had a display issue where it would double the number of items processed (it didn’t affect the items, but was only a display issue); I have changed the processing loop to now retrieve all items first, then process them in one batch, rather than retrieving and processing them in 50-item batches.

  Set-DefaultFolderItemsTag.ps1 (9.4 KiB)

Delegate management module updated

The module has been updated to version 1.5.1. This version adds automatic support for localization of the Sent Items and Deleted Items folders. If the display name of those folders in the owner’s mailbox is not in English, the localized display name of the folder will be used when getting, setting, or removing delegates.

I have also added permission validation to the owner’s mailbox for the person executing a cmdlet. When using impersonation, if you don’t have permission to a mailbox Exchange responds with an error indicating as much. But if using full access, Exchange doesn’t respond with such an error, just failing on whatever request is being made. Usually when permission is the issue, the error contains “The specified object was not found in the store,” so the module checks for that error, informs you that it appears you don’t have permission, and then gracefully aborts the cmdlet.

Download the updated module and overwrite your existing copy.  If you were already using v1.5.0, keep your existing settings file so your specific settings remain.

  DelegateManagement.zip (9.2 KiB)