Remove text messaging settings on behalf of users

Exchange has the ability to send text messages to specific carriers in a few countries, and is enabled by default. This allows users to configure calendar notifications (such as changes to meetings that are occurring in the next three days) and rules to forward email as a text message. Users have to use OWA (or if you prefer the new name, Outlook on the web) to configure this. But what if your users do this before you realize it is enabled by default and now you want to disable it?

If you modify the role assignment policy to remove MyTextMessaging or modify OWA Mailbox policy to remove Text Messaging, it hides this feature from users, but it doesn’t disable anything already in place. You then decide to use PowerShell to run Clear-TextMessagingAccount for someone, but it says the user cannot be read. You can run it for your own account, but nobody else, even as an admin. This is because the write scope of the role that contains the cmdlet is Self. So how to remove the settings for another user?

I wrote a script that uses the EWS Managed API modify the hidden messages that contain the settings and delete any inbox rules that are forwarding to a mobile device. I should point out that doing it this way is unsupported, but I have used it successfully for mailboxes on Exchange 2013 and in Exchange Online.

The calendar notification settings and text messaging configuration are stored in folder associated items (FAI) in the root folder of the mailbox, in the roaming XML property of a user configuration message. Because of this, you can use the Microsoft.Exchange.WebServices.Data.UserConfiguration class to easily get messages with a specific subclass and retrieve this property without having to define a property set with the extended MAPI property. The subclass for the calendar notification settings is CalendarNotification.001 and text messaging configuration is TextMessaging.001. If you already have a service object created, you can get the message for calendar notification with these two lines:

$folderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,'alias@company.com')
$calNotify = [Microsoft.Exchange.WebServices.Data.UserConfiguration]::Bind($exchangeService, 'CalendarNotifcation.001', $folderId, [Microsoft.Exchange.WebServices.Data.UserConfigurationProperties]::All)

The roaming properties of a user configuration message are stored in the Dictionary, XmlData, and BinaryData properties of the search result object. The property for the calendar notification settings (PR_ROAMING_XMLSTREAM as the XmlData property) is a binary value returned as a byte array, so it needs to be converted to a string cast as an XML object so it can be manipulated with XML methods:

[xml]$calStream = [System.Text.Encoding]::ASCII.GetString($calNotify.XmlData)

The three notification types have their own node and contains an element whose value indicates whether it is enabled. Since I don’t care what the other options are, only that they are disabled, this can be done by directly setting the value for the element:

$calStream.CalendarNotificationSettings.UpdateSettings.Enabled = 'false'
$calStream.CalendarNotificationSettings.ReminderSettings.Enabled = 'false'
$calStream.CalendarNotificationSettings.SummarySettings.Enabled = 'false'

To write the data back to the XmlData property and save it in the mailbox, it needs to be converted back to a byte array. This isn’t done with a one-liner like converting from a byte array. The XML data is converted to a string, which is then converted to a byte array. There could be a more efficient way of doing this, but I don’t know it at the time of this writing. The first line is the one-liner to take the XML data and store it as a byte array in the property, the second saves the message back to the mailbox, and the two functions that convert XML to a string and a string to a byte array follow:

$calNotify.xmlData = Convert-StringToByteArray -string (Convert-XmlToString -xml $calStream)
$calNotify.Update()

function Convert-XmlToString ($xml)
	{
	$sw = New-Object -TypeName System.IO.StringWriter
	$xmlSettings = New-Object -TypeName System.Xml.XmlWriterSettings
	$xmlSettings.ConformanceLevel = [System.Xml.ConformanceLevel]::Fragment
	$xmlSettings.Indent = $true
	$xw = [System.Xml.XmlWriter]::Create($sw, $xmlSettings)
	$xml.WriteTo($xw)
	$xw.Close()
	$sw.ToString()
	}
	
function Convert-StringToByteArray ($string)
	{
	$byteArray = New-Object -TypeName Byte[] -ArgumentList $string.Length
	$i = 0
	foreach ($char in $string.ToCharArray())
		{
  		$byteArray[$i] = [byte]$char
  		$i++
		}
	,$byteArray
	}

For the text messaging configuration, it is in the same property of its message. Once converted to XML, devices are stored in the MachineToPersonMessagingPolicies node, with a PossibleRecipient node for each device that has ever been configured. To simply delete any devices, you can remove all sub-nodes since there aren’t any others:

$textStream.SelectSingleNode('//MachineToPersonMessagingPolicies').RemoveAll()

Then convert the XML data back to a byte array and save the message the same as before.

What remains are any inbox rules that may have been created that forward to a text messaging device. As an admin, you can use PowerShell to get rules, but you won’t see any rules that have been disabled in Outlook. Even if a rule is visible because it is enabled or has been disabled via OWA, and so you are able to see if a given rule is forwarding to a text messaging device, if you delete the rule, you will also delete any rules that are currently disabled via Outlook. What’s worse, you won’t even know if there are disabled rules that will be deleted because the warning is presented for every mailbox regardless of the existence of any applicable rules.

So the script will get all FAI messages that are rules and delete any that are forwarding to a device configured via the text messaging feature. The first step is to get the rules by searching for all FAIs in the inbox whose class is that of a rule:

$folderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Inbox,'alias@company.com')
$searchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.EmailMessageSchema]::ItemClass, 'IPM.Rule.Version2.Message')
$itemView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ItemView(100)    
$itemView.Traversal = [Microsoft.Exchange.WebServices.Data.ItemTraversal]::Associated  
$inboxRules = $exchangeService.FindItems($folderId, $searchFilter, $itemView)

After getting the rules, we need to retrieve the property that contains a rule’s actions, which is PR_EXTENDED_RULE_ACTIONS (0x0E990102), a binary property:

$propExtRuleActions = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x0E99,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary)
$propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet($propExtRuleActions)
[void]$exchangeService.LoadPropertiesForItems($inboxRules, $propertySet)

Parsing the binary data is not easy (for me) because it includes pieces of variable-length information. If the entire value is converted to a string, however, an action that forwards to a configured text messaging device contains the string MOBILE: followed by the E.164-formatted phone number. So, all that needs to be done is to get the rule’s actions, convert it to string and check for MOBILE, and delete the rule:

foreach ($rule in $inboxRules.Items)
	{
	$ruleActions = $null
	if ($rule.TryGetProperty($propExtRuleActions,[ref]$ruleActions))
		{
		if ([System.Text.Encoding]::ASCII.GetString($ruleActions) -like '*MOBILE:*')
			{
			$rule.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete)
			}
		}
	}

The script supports on-premises and Exchange Online, autodiscover or specified URL, pipelining mailboxes into it, impersonation and specifying credentials. The output will contain what actions it took on a mailbox, including whether any of the features were not configured in the first place. You can run it multiple times against a mailbox without it having an issue that any or all features are not configured. The full script can be expanded below, and it can also be downloaded via the following link:

  Remove-TextMessagingConfiguration.zip (2.8 KiB)

<#
	.Synopsis
		Remove text messaging configuration and inbox rules
	.Description
		Disable calendar notification, remove mobile devices added as a text messaging 
		device and delete inbox rules that forward to a text messaging device.
	.Parameter EmailAddress
		Email address of the mailbox.  Accepts pipeline input from Get-Mailbox.
	.Parameter EWSUrl
		To not use autodiscover, specify the URL to use for EWS.
	.Parameter Credential
		Provide credentials to use instead of the current user.
	.Parameter EWSApiPath
		Explicit path to EWS API DLL if it has not been installed via setup routine.
	.Parameter UseImpersonation
		Switch to specify connection to the mailbox via impersonation instead of
		full mailbox access.
	.Parameter UseExchangeOnlineURL
		Switch to use the hard-coded EWS URL for Exchange Online.  Cannot be used
		with the EWSUrl parameter.
	.Example
		Remove-TextMessagingConfiguration.ps1 -EmailAddress johndoe@company.com -Credential (get-credential)
	.Example
		Get-Mailbox johndoe | Remove-TextMessagingConfiguration -EWSUrl 'https://owa.company.com/ews/exchange.asmx' -UseImpersonation
	.Notes
		Version: 1.1
		Date: 11/20/15
	#>
	
[CmdletBinding()]
param 
	(
	[parameter(Mandatory=$true,Position=0,ValueFromPipelinebyPropertyName=$true)][Alias('PrimarySMTPAddress')]$EmailAddress,
	[parameter(Mandatory=$false,ParameterSetName='ews')][string]$EWSUrl,
	[parameter(Mandatory=$false,ParameterSetName='exo')][switch]$UseExchangeOnlineURL,
	[parameter(Mandatory=$false)][pscredential]$Credential,
	[parameter(Mandatory=$false)][string]$EWSApiPath,
	[switch]$UseImpersonation
	)

begin
	{
	#Test if any version of API is installed before continuing
	if ($EWSApiPath)
		{$apiPath = $EWSApiPath}
	else
		{
		$apiPath = (($(Get-ItemProperty -ErrorAction SilentlyContinue -Path Registry::$(Get-ChildItem -ErrorAction SilentlyContinue -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Exchange\Web Services' |
			Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty Name)).'Install Directory') + 'Microsoft.Exchange.WebServices.dll')
		}
	if (Test-Path $apiPath)
		{
		Add-Type -Path $apiPath
		}
	else
		{
		Write-Error "The Exchange Web Services Managed API is required to use this script." -Category NotInstalled
		break
		}
	$exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$exchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion)
	if ($Credential)
		{
		$exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential)
		}

	function Get-UserConfigurationMessage ($targetAddress, $className)
		{
		if ($UseImpersonation)
			{
			$exchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $targetAddress)
			}
		#Bind to root of mailbox and return FAI with configuration class of specified name
		$folderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$targetAddress)
		[Microsoft.Exchange.WebServices.Data.UserConfiguration]::Bind($exchangeService, $className, $folderId, [Microsoft.Exchange.WebServices.Data.UserConfigurationProperties]::All)
		}
		
	function Get-Rules ($targetAddress)
		{
		if ($UseImpersonation)
			{
			$exchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $targetAddress)
			}
		#Search inbox for rule messages
		$folderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Inbox,$targetAddress)
		$searchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.EmailMessageSchema]::ItemClass, 'IPM.Rule.Version2.Message')
		$itemView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ItemView(100)    
		$itemView.Traversal = [Microsoft.Exchange.WebServices.Data.ItemTraversal]::Associated  
		,$exchangeService.FindItems($folderId, $searchFilter, $itemView)
		}

	function Convert-XmlToString ($xml)
		{
		$sw = New-Object -TypeName System.IO.StringWriter
		$xmlSettings = New-Object -TypeName System.Xml.XmlWriterSettings
		$xmlSettings.ConformanceLevel = [System.Xml.ConformanceLevel]::Fragment
		$xmlSettings.Indent = $true
		$xw = [System.Xml.XmlWriter]::Create($sw, $xmlSettings)
		$xml.WriteTo($xw)
		$xw.Close()
		$sw.ToString()
		}
		
	function Convert-StringToByteArray ($string)
		{
		$byteArray = New-Object -TypeName Byte[] -ArgumentList $string.Length
		$i = 0
		foreach ($char in $string.ToCharArray())
			{
	  		$byteArray[$i] = [byte]$char
	  		$i++
			}
		,$byteArray
		}
	}

process
	{
	#Putting autodiscover in process allows per-user autodiscover endpoint
	if ($EWSUrl)
		{
		$exchangeService.Url = $EWSUrl
		}
	elseif ($UseExchangeOnlineURL)
		{
		$exchangeService.Url = 'https://outlook.office365.com/ews/Exchange.asmx'
		}
	else
		{
		$exchangeService.AutodiscoverUrl($EmailAddress, {$true})
		}
	#Create custom object to hold results	
	$output = "" | Select-Object 'EmailAddress','CalendarNotify','TextConfiguration','InboxRules'
	$output.EmailAddress = $EmailAddress
	
	#Get calendar notification settings
	try 
		{
		$calNotify = Get-UserConfigurationMessage -targetAddress $EmailAddress -className 'CalendarNotification.001'
		#Convert binary property to XML
		[xml]$calStream = [System.Text.Encoding]::ASCII.GetString($calNotify.XmlData)
		#Disable the three notification types
		$notifyEnabled = $false
		if ($calStream.CalendarNotificationSettings.UpdateSettings.Enabled -eq 'true')
			{
			$calStream.CalendarNotificationSettings.UpdateSettings.Enabled = 'false'
			$notifyEnabled = $true
			}
		if ($calStream.CalendarNotificationSettings.ReminderSettings.Enabled -eq 'true')
			{
			$calStream.CalendarNotificationSettings.ReminderSettings.Enabled = 'false'
			$notifyEnabled = $true
			}
		if ($calStream.CalendarNotificationSettings.SummarySettings.Enabled -eq 'true')
			{
			$calStream.CalendarNotificationSettings.SummarySettings.Enabled = 'false'
			$notifyEnabled = $true
			}
		
		if ($notifyEnabled)
			{
			#Convert XML back to binary and save
			$calNotify.xmlData = Convert-StringToByteArray -string (Convert-XmlToString -xml $calStream)
			$calNotify.Update()
			$output.CalendarNotify = 'Deleted'
			}
		else
			{
			$output.CalendarNotify = 'NotConfigured'
			}
		}
	catch
		{
		if ($error[0].Exception -like '*The specified object was not found in the store.*')
			{
			$output.CalendarNotify = 'NotFound'
			}
		else
			{
			$output.CalendarNotify = 'Error'
			}
		}

	#Get text messaging settings
	try 
		{
		$textConfig = Get-UserConfigurationMessage -targetAddress $EmailAddress -className 'TextMessaging.001'
		#Convert binary property to XML
		[xml]$textStream = [System.Text.Encoding]::ASCII.GetString($textConfig.xmldata)
		if ($textStream.TextMessagingSettings.MachineToPersonMessagingPolicies.PossibleRecipient)
			{
			$xpath = '//MachineToPersonMessagingPolicies' #Node name that contains devices
			#Remove any defined mobile devices
			$textStream.SelectSingleNode($xpath).RemoveAll()
			#Convert XML back to binary and save
			$textConfig.xmlData = Convert-StringToByteArray -string (Convert-XmlToString -xml $textStream)
			$textConfig.Update()
			$output.TextConfiguration = 'Deleted'
			}
		else
			{
			$output.TextConfiguration = 'NotConfigured'
			}
		}
	catch
		{
		if ($error[0].Exception -like '*The specified object was not found in the store.*')
			{
			$output.TextConfiguration = 'NotFound'
			}
		else
			{
			$output.TextConfiguration = 'Error'
			}
		}

	#Check for inbox rules that forward to mobile device
	try
		{
		$inboxRules = Get-Rules -targetAddress $EmailAddress
		if ($inboxRules)
			{
			#Get property that contains rule actions
			$propExtRuleActions = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x0E99,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary)
			$propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet($propExtRuleActions)
			[void]$exchangeService.LoadPropertiesForItems($inboxRules, $propertySet)
			$matchingRule = $false
			foreach ($rule in $inboxRules.Items)
				{
				$ruleActions = $null
				if ($rule.TryGetProperty($propExtRuleActions,[ref]$ruleActions))
					{
					#Convert from binary and look for string that indicates forwarding to device
					if ([System.Text.Encoding]::ASCII.GetString($ruleActions) -like '*MOBILE:*')
						{
						$rule.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete)
						$matchingRule = $true
						$output.InboxRules = 'Deleted'
						}
					}
				}
			if (-not($matchingRule))
				{
				$output.InboxRules = 'NotConfigured'
				}
			}
		else
			{
			$output.InboxRules = 'NotConfigured'
			}
		}
	catch
		{
		$output.InboxRules = 'Error'
		}
	$output
	}

Script to set retention tag on default folder items updated to v1.1.1

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 [This article]

When running v1.0 of the script in a folder with lots of items, it would keep stopping with no errors, but there were more items to process. I found that this was happening when the number of items to process changes because a new item was added to the folder. In other words, while processing deleted items and another item is added to the Deleted Items folder, the total number of items changes, resulting in Exchange not returning the next set of items correctly. v1.1 correctly accounts for this condition.

Additionally, I found that calendar items in the Deleted Items folder cannot be processed with the API. Trying to change any property returns an error that it can’t update calendar items that are already deleted. But since you can manually assign a tag to it in Outlook, I consider it a bug that you can’t update calendar items in the Deleted Items folder. So, I updated the search filter to exclude calendar items when not searching in the Calendar folder (processing meeting responses is okay; it is only appointment/meeting items that are affected).

You can now choose the default folder to process. If you don’t specify one, the Deleted Items folder is selected. I included all default folders that can have a retention policy tag assigned AND have a well-known folder ID. This means that you can’t use the script to process items in the Clutter or RSS Feeds folders. If you are interested in having the script work against those folders, let me know and I will add the code necessary to do so.

Download the updated version below. (The inline code of the first post has been updated, too.)

  Set-DefaultFolderItemsTag.zip (2.6 KiB)

Use EWS to apply retention policy 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 [This article]
  2. Script to set retention tag on default folder items updated to v1.1.1

When working with retention policies and the types of tags you can apply to folders and items, you can assign a personal tag to any item and to any custom folder. You cannot, however, assign a personal tag to a default folder (such as Deleted Items), even if a retention policy tag has not been assigned to the folder. This means that if no default policy tag has been assigned to the policy, the items in that folder will never expire. The only way for a user to expire items in that folder is to assign a personal tag to each and every item. For the deleted items folder, that can be a lot of items, and its contents are changing daily.

This script uses the EWS Managed API to get all items in the Deleted Items folder that do not have a tag assigned to them and then assign a specific tag to each. To start, you need to connect to EWS:

function Connect-WebServices ($smtpAddress)
	{
	$exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2013_SP1 
	$exchangeService = New-Object Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion) 
 	$exchangeService.Credentials = New-Object Microsoft.Exchange.WebServices.Data.WebCredentials($EXOCreds)
    #Use hard-coded URL
    $exchangeService.Url = 'https://outlook.office365.com/EWS/Exchange.asmx'
    #Impersonate mailbox
	#$exchangeService.ImpersonatedUserId = New-Object Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $smtpAddress)
    $folderID= New-Object Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::$DefaultFolder,$smtpAddress)     
    [Microsoft.Exchange.WebServices.Data.Folder]::Bind($exchangeService,$folderID)
	}

I am using a function because I lifted this code from another one of my scripts, so calling this function returns an object for the Deleted Items folder. Based on the credentials format and EWS URL, you can see that I am connecting to Exchange Online. This can be easily changed to support on-premises. I am not using autodiscover because it is very slow when querying EXO, and since all EXO mailboxes can be accessed with the a single FQDN, it is simpler this way. I am not using impersonation because I am running this against my own mailbox, but you can uncomment the line if you choose to use it.

To search for items that do not have a tag assigned, this is used:

$policyTagProperty = New-Object Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x3019,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary)
$itemView.PropertySet.Add($policyTagProperty)
$itemSearchFilter = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+Exists($policyTagProperty)
$itemSearchFilterNot = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+Not($itemSearchFilter)
$itemSearchResult = $folder.FindItems($itemSearchFilterNot,$itemView)

The MAPI property that indicates whether a tag has been assigned (whether implicitly or explicitly) is an extended property that you declare and add to a property set. The property is binary and contains the GUID of the tag, but since I am only looking for items without a tag, I only care if the property has a value. To do this, you first define a search filter object that says to include items where the property exists. Then to negate that, so I can find items without that property, you create another search filter object using the Not class that contains the other search filter.

Then you can apply the tag and its corresponding days until expiration value:

$policyTagGUID = New-Object Guid("{33CEDA03-0536-424C-8ECA-E839E0BC5945}")
$item.SetExtendedProperty($policyTagProperty, $policyTagGUID.ToByteArray())
$retentionPeriodProperty = New-Object Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x301A,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Integer)
$item.SetExtendedProperty($retentionPeriodProperty, 30)
$item.Update([Microsoft.Exchange.WebServices.Data.ConflictResolutionMode]::AlwaysOverwrite)

To assign the tag, you need to know its GUID. You can get this from PowerShell, but if you don’t have access to Exchange to get this, you can manually assign the tag to an item, then use MFCMAPI and look at the item’s properties for the value in PR_POLICY_TAG (0x30190102). The RAW representation of the GUID will need to be converted to the proper byte order, which can be done in a variety of ways, but this site is an easy way. When assigning the tag you also have to set the property that contains the number of days after which the tag is configured to expire. In my case, it is a 30-day tag. (I tested not setting the property and the result in Outlook does show that the tag is assigned but it doesn’t show the expiration date. I don’t know if the property is only used to calculate the displayed date or if MRM actually uses it when expiring items.)

The complete script can be downloaded from the link or copied from the code below. It includes checking for the EWS Managed API and a progress bar (since this is not a fast operation).

  Set-DefaultFolderItemsTag.zip (2.6 KiB)

#Apply retention tag to items in deleted items folder that do not have one
#v1.1 4/2/15
#1.1 Exclude appointment items, detect changed search results, add help, choose folder to process

<#
	.Synopsis
		Assign personal tag to all items in a default folder.
	.Description
		Get all items in a folder that do not have a retention tag explicitly
        assigned and assign a personal tag them.
	.Parameter EmailAddress
		Email address of mailbox to process
	.Parameter DefaultFolder
		The name of the default folder to process.  Default value is DeletedItems.
	.Example
		Set-DefaultFolderItemsTag.ps1 user@domain
	.Example
		Set-DefaultFolderItemsTag.ps1 user@domain -DefaultFolder SentItems
	.Notes
		Version: 1.1
		Date: 4/2/15
	#>

Param (
	[Parameter(Position=0,Mandatory=$true,HelpMessage="Email address of mailbox")][string]$EmailAddress,
    [Parameter(Position=1,Mandatory=$false,HelpMessage="Name of default folder to process")]
    [ValidateSet('Calendar','ConversationHistory','DeletedItems','Inbox','Journal','JunkEmail','Notes','SentItems','SyncIssues','Tasks')]
    [string]$DefaultFolder = 'DeletedItems'
	)

#Region Variables

#Replace with GUID of tag to assign to items
$personalTagGUID = "{33CEDA03-0536-424C-8ECA-E839E0BC5945}"
#Replace with the number of days the tag is configured for retaining items
$personalTagRetentionDays = 30

#EndRegion

#Paths to EWS Managed API DLL
$ewsAPIVersions = '2.2','2.1','2.0','1.2','1.1','1.0'
$ewsAPIPath = 'C:\Program Files\Microsoft\Exchange\Web Services\_v_\Microsoft.Exchange.WebServices.dll'

#Test if any version of API is installed before continuing and load latest version
foreach ($version in $ewsAPIVersions)
	{
	$path = $ewsAPIPath.Replace('_v_',$version)
    if (Test-Path $path)
		{
		Add-Type -Path $path
		$apiFound = $true
		break
		}
	}

if (-not($apiFound))
	{
	Write-Error "The Exchange Web Services Managed API is required to use this script." -Category NotInstalled
	break
	}

function Connect-WebServices ($smtpAddress, $folder)
	{
	$exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2013_SP1 
	$exchangeService = New-Object Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion) 
 	$exchangeService.Credentials = New-Object Microsoft.Exchange.WebServices.Data.WebCredentials($EXOCreds)
    #Use hard-coded URL
    $exchangeService.Url = 'https://outlook.office365.com/EWS/Exchange.asmx'
    #Enable tracing to debug
    #$exchangeService.TraceEnabled = $true
    #Impersonate mailbox
	#$exchangeService.ImpersonatedUserId = New-Object Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $smtpAddress)
    $folderID= New-Object Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::$folder,$smtpAddress)     
    [Microsoft.Exchange.WebServices.Data.Folder]::Bind($exchangeService,$folderID)
	}

function Apply-RetentionPolicy
    {
    #Bind to folder of mailbox
    $folder = Connect-WebServices $EmailAddress $DefaultFolder
    if (-not($folder))
        {
        Write-Error -Message "Error binding to folder in mailbox for $EmailAddress." -Category ConnectionError
        }
    else
        {
        #Set up paged search to stay below FindCountLimit
        $pageSize = 50
        $offset = 0
        $moreItems = $true
        $itemCount = 0
       
        $firstRun = $true

        while ($moreItems)
            {
            #Setup the view to do a paged search
            $itemView = New-Object Microsoft.Exchange.WebServices.Data.ItemView($pageSize,$offset,[Microsoft.Exchange.WebServices.Data.OffsetBasePoint]::Beginning)
            $itemView.Traversal = [Microsoft.Exchange.WebServices.Data.ItemTraversal]::Shallow
            $itemView.PropertySet = New-Object Microsoft.Exchange.WebServices.Data.PropertySet([Microsoft.Exchange.WebServices.Data.BasePropertySet]::IdOnly)
            #Define retention policy property to include in search
            $policyTagProperty = New-Object Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x3019,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary)
            #Add property to property set
            $itemView.PropertySet.Add($policyTagProperty)
            #Create the search filter to find items with no tag set and are not appointments
            $itemSearchFilter1 = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+Exists($policyTagProperty)
            $itemSearchFilter2 = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.EmailMessageSchema]::ItemClass, 'IPM.Appointment')
            $itemSearchFilterCollection = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+SearchFilterCollection([Microsoft.Exchange.WebServices.Data.LogicalOperator]::Or)
            $itemSearchFilterCollection.Add($itemSearchFilter1)
            $itemSearchFilterCollection.Add($itemSearchFilter2)
            $itemSearchFilterNot = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+Not($itemSearchFilterCollection)
            #Search for items in folder matching search filter
            $itemSearchResult = $folder.FindItems($itemSearchFilterNot,$itemView)
            
            if ($firstRun)
                {
                if ($itemSearchResult.TotalCount -eq 0)
                    {
                    Write-Output "There are no items to process."
                    }
                else
                    {
                    $totalItems = $itemSearchResult.TotalCount
                    $firstRun = $false
                    }
                }
        
            #Detect changed result set
            #Indicator is when no results returned but the server says there is still a count
            #or when server's total count is greater than what is returned but it says there aren't more
            if (($itemSearchResult.Items.Count -eq 0 -and $itemSearchResult.TotalCount -gt 0) -or ($itemSearchResult.Items.Count -le $pageSize -and $itemSearchResult.TotalCount -gt $pageSize -and $itemSearchResult.MoreAvailable -eq $false))
                {
                $resultsChanged = $true
                $totalItems = $itemCount + $itemSearchResult.TotalCount
                }
            #Process each item in current search result collection
            foreach ($item in $itemSearchResult.Items)
                {
                $itemCount ++
                Write-Progress -Activity "Applying retention tag" -CurrentOperation "Updating item $itemCount of $totalItems" -PercentComplete ($itemCount/$totalItems*100)
                $policyTagGUID = New-Object Guid($personalTagGUID)
                $item.SetExtendedProperty($policyTagProperty, $policyTagGUID.ToByteArray())
                $retentionPeriodProperty = New-Object Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(0x301A,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Integer)
                $item.SetExtendedProperty($retentionPeriodProperty, $personalTagRetentionDays)
                $item.Update([Microsoft.Exchange.WebServices.Data.ConflictResolutionMode]::AlwaysOverwrite)
                }
            
            #If the results have changed, set offset to 0
            if (-not($resultsChanged))
                {
                if ($itemSearchResult.MoreAvailable -eq $false)
                    {$moreItems = $false}
                if ($moreItems)
                    {$offset += $pageSize}
                }
            else
                {
                $resultsChanged = $false
                $offset = 0
                }
            }
        }
    }

if (-not($EXOCreds))
    {
    $EXOCreds = Get-Credential -Message 'Enter the credentials to use to access Exchange Online.'
    }
Apply-RetentionPolicy $EmailAddress

Delegate management module updated to v1.4.5

The module has been updated mostly for fixing issues when working with Exchange Online. The first version that supported it didn’t account account for object properties that are different compared to on-premises, as well as how to get user information. These are the changes in this version:

  • Fixed when using a default connection mode of EXO so that the rest of the module knows it.
  • Added option to not use autodiscover when using EXO since those lookups can sometimes add a lot of time to the cmdlets running.  If default mode is EXO and you don’t want to use autodiscover, uncomment that line below the default mode.  If using EXO on-demand, you can set the option with the DoNotUseAutodiscover switch parameter of Set-DelegateManagementMode. (The cmdlet’s help has been updated to reflect this.)
  • Added usage of the Azure Active Directory module when using EXO mode.  This means you need to have the WAAD module installed to work against Exchange Online.  Since that module is 64-bit only, you can only run the delegate management module in a 64-bit PowerShell session.
  • Fixed (hopefully and finally) the Write-Progress prompt that some people were getting that interrupted the cmdlets.  (Thanks, Jim.)
  • Fixed getting Send As permission in EXO due to it using a different cmdlet.
  • Fixed getting folder permissions in EXO due to the object properties being different.
  • Added removal of Deleted Items and Sent Items folder permissions when removing a delegate.

There are other things I have discovered need fixing: Exchange cmdlets loaded by the module are not accessible outside of the module; Exchange cmdlet errors are not caught in PowerShell 4 so the module cmdlets keep running after a terminating error would be detected if running in PowerShell 2; if multiple objects are pipelined to Get-MailboxDelegate and one of them does not have a mailbox, the cmdlet terminates without processing the remaining objects in the pipeline.

I also still intend to add support for hybrid mode.  It is more complicated, though, such as with adding delegates since I need to account for attempts at cross-premises delegation, which isn’t supported.

  DelegateManagement.zip (7.5 KiB)

Delegate management module updated to support Exchange Online

The module for managing Exchange mailbox delegates has been updated with support for Exchange Online. In its current version (v1.4) you can use one mode or the other. The default mode is on-premises, but you can change this on demand to use Exchange Online by using Set-DelegateMananagementMode, a new cmdlet added in this version. If you change it on demand, you will be prompted for your Office 365 credentials. If you will be exclusively working with Exchange Online, you can change the line near the top of the module to default to using that method. In that case, you will be prompted for credentials the first time you run a cmdlet.

It is my intention to update the module to support a hybrid environment, but I first need to set up one in my lab in order to test it.

[download=”24″]

Database seeding completion estimate script updated

Articles in the "Database seeding estimation" series

  1. Estimate the time to complete seeding a mailbox database copy
  2. Database seeding completion estimate script updated [This article]

The script has been updated to include the average throughput from the sampling duration in the output. This will help gauge why the seeding completion time may vary between multiple executions of the script, such as during vs. outside of business hours. Additionally, some variable names have been updated (to better reflect their contents), and per scripting best practices, the help has an added example so that all parameters are used at least once among all the examples. The inline code has been updated, as has the download.

  Get-DatabaseSeedingCompletion.zip (1.5 KiB)