Delegate management module updated to 1.4.6

A small update has been made so that comparing delegates to users with Full Access and Send As works for Exchange Online accounts. The code to translate a user ID to a SID returns an error when used against Exchange Online. If the connection mode is for Exchange Online, it will now use SMTP address to match a delegate to a user with Full Access and Send As permissions. Download the updated module here:

  DelegateManagement.zip (7.6 KiB)

Default folder retention tag script updated to 1.3

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

I spent some time figuring out why calendar items in the Deleted Items folder that would be immediately expired could not be updated with a tag. I found that Exchange wants to send an update to the organizer, regardless whether the calendar item is even a meeting or one that is in the past. If the item is in a different folder when the tag is applied, the error is that there is no recipient on the message, whereas the error when in the Deleted Items is that it can’t update an item that is already deleted. The latter error being misleading is why I originally couldn’t figure it out what they meant or why it was happening.

The solution is to use a second argument in the Update method to tell Exchange not to send an update message for invitations or cancellations. You have to do this even if it isn’t a meeting. Presumably, this is a bug where Exchange is running through logic against all IPM.Appointment-class items, rather than skipping those that aren’t meetings.

The script has been updated to account for this, plus some minor updates since the last version posted.

  Set-DefaultFolderItemsTag.zip (2.6 KiB)

Get Inumbo message tracking records with PowerShell

Inumbo is a cloud-based mail hygiene solution.  Whether using their free or paid subscription, you can search the message tracking log in the web portal to see a history of messages being processed for your subscription.  The main thing I use it for is to know if I haven’t received a message because it was marked as spam.  But rather than use the web portal, you can also use their REST API to programmatically get tracking records.  So instead of logging into the portal and searching, you just run a script and perform filtered searches and also get more information that what is exposed in the portal.

You need to get a read-only or read/write API key since that is what is used to authenticate the request.  You don’t have to use any search restrictions, and my script doesn’t require any either, but usually you’ll want to narrow it down to a time frame, sender, recipient, or action performed (delivered, rejected, etc.)  I have included these common search parameters, plus subject and sender IP.  Subject is a substring filter, and the start date and end date parameters are not mutually exclusive.

Speaking of dates, the API requires the format to be in epoch time (number of seconds since 1/1/1970, aka UNIX time).  To convert a .NET DateTime object to UNIX time, I use a method that was introduced in .NET 4.6.  If you aren’t using 4.6+, you can modify the Get-UnixTime function (at line 61) to calculate it using a time span, and there is link in the script for how to do that.  Furthermore, the script will account for time zone in the request and the response, so you can use “3/23/16 7:00 AM,” “3/23/16 7:00 AM -0700,” or “3/23/16 2:00 PM UTC” and get the same results, and the time stamp for each result will be in local time.

There are a number of properties for a record, so I only keep the ones that are relevant for normal queries.  These are time stamp, action, sender IP, sender, recipient, subject, RPD (anti-spam) score, SpamAssassin score, and description (why it was rejected or next hop information).  That’s still nine properties, too many to display in a table and see enough of the values to be meaningful, and I want the default output to be as a table.  So I specified the default properties to return five of the nine in a table.

How did I do that?  I created a custom formatting file (included in the download).  The file specifies that, for the custom type I assign to the record object, the default view is a table with five properties and specific widths for the columns, except for the subject which will fill the rest of the width.  To use the file, you need to run Update-FormatData .\TrackingInfo.format.psx1.  You will need to this once in each time you open a new shell.  You can add the command to your profile or even add the line to the script.  If you don’t use the formatting file, I still set a default property set in the script so the five properties are displayed, but the default will be in a list.  You, of course, can use standard format and export cmdlets to choose the properties displayed and how they are displayed.  So, if you want to see all properties, pipeline the results to, for example, Format-List -Property *.

The script’s code can be expanded and seen below, but you can download the script and the formatting file in the below attachment.

  Get-InumboTracking.zip (2.9 KiB)

<#
	.Synopsis
		Retrieve message tracking information from Inumbo
	.Description
		Get message tracking details for messages from Inumbo, optionally filtering on
		sender, recipient, date range, IP, subject, and action.
	.Parameter Action
		Restrict search results to valid action: quarantine, deliver, delete, bounce, reject, defer, or error.
	.Parameter Sender
		Sender email address to restrict search results.
	.Parameter Recipient
		Recipient email address to restrict search results.
	.Parameter Subject
		Substring of subject to restrict search results.
	.Parameter SenderIP
		IP address of sending server to restrict search results
	.Parameter StartDate
		Date and time to restrict the search results to messages with a timestamp
		after the date provided. Any DateTime object, or string that can be converted
		to DateTime, can be used and time zone will be accounted for. It can be used
		with or without EndDate.
	.Parameter EndDate
		Date and time to restrict the search results to messages with a timestamp
		before the date provided. Any DateTime object, or string that can be converted
		to DateTime, can be used and time zone will be accounted for. It can be used
		with or without StartDate.
	.Parameter ResultSize
		Maximum number of records to return.  There is no default other than any
		limit that Inumbo may enforce.  If a number larger than 50 is specified, the
		result size will be the number of available records if the total is within
		the next higher multiple of 50, or the next higher multiple of 50 if there
		are more records.  For example, if the ResultSize parameter is 65 and there
		are 80 records, the result size will be 80; if there are 120 records, the
		result size will be 100.
	.Example
		Get-InumboTracking.ps1 -StartDate "3/21/16 7:00 AM"
	.Example
		Get-InumboTracking.ps1 -Sender johndoe@company.com -EndDate "3/18/16 9:00 AM -0700"
	.Notes
		Version: 1.0
		Date: 3/21/16
#>

Param (
	[ValidateSet('QUARANTINE', 'DELIVER', 'DELETE', 'BOUNCE', 'REJECT', 'DEFER', 'ERROR')][string]$Action,
	[ValidateScript({$_ -as [System.Net.Mail.MailAddress]})][string]$Sender,
	[ValidateScript({$_ -as [System.Net.Mail.MailAddress]})][string]$Recipient,
	[string]$Subject,
	[ValidateScript({$_ -as [System.Net.IPAddress]})][string]$SenderIP,
	[DateTime]$StartDate,
	[DateTime]$EndDate,
	[int]$ResultSize
	)

#Put your Inumbo read-only or read/write key below
$apiKey = '123456789abcdefghijklmnopqrst'

#This function requires .NET 4.6
#If using a lower version, the function can be modified to use a timespan to make the calculation
#http://stackoverflow.com/questions/4192971/in-powershell-how-do-i-convert-datetime-to-unix-time
function Get-UnixTime ($date)
	{
	$dateOffset = New-Object -TypeName System.DateTimeOffset($date)
	$dateOffset.ToUnixTimeSeconds()
	}

function Get-RPDScoreTranslation ($score)
	{
	#RPD is CYREN's Recurrent Pattern Detection anti-spam engine
	if (-not($score))
		{return $null}
	switch ([int]$score)
		{
		0	{$return = 'Unknown'}
		10 	{$return = 'Suspect'}
		40 	{$return = 'ValidBulk'}
		50 	{$return = 'Bulk'}
		100 	{$return = 'Spam'}
		}
	$return
	}

#Convert API key to credential object to send as a password
$credential = New-Object -TypeName Management.Automation.PSCredential('api',(ConvertTo-SecureString $apiKey -AsPlainText -Force))
$url = 'https://api.inumbo.com/v1/tracking'

#Build query
if ($Action -or $Sender -or $Recipient -or $SenderIP -or $Subject -or $StartDate -or $EndDate)
	{
	$queryString = '&query='
	if ($Action)
		{
		$queryString += 'action=' + $Action.ToUpper() + ' '
		}
	if ($Sender)
		{
		$queryString += "from=$Sender "
		}
	if ($Recipient)
		{
		$queryString += "to=$Recipient "
		}
	if ($SenderIP)	
		{
		$queryString += "ip=$SenderIP "
		}
	if ($Subject)
		{
		$queryString += "subject~$Subject "
		}
	if ($StartDate)
		{
		#Convert date to Unix time format needed by API
		$start = Get-UnixTime $StartDate
		$queryString += "time>$start "
		}
	if ($EndDate)
		{
		#Convert date to Unix time format needed by API
		$end = Get-UnixTime $EndDate
		$queryString += "time<$end "
		}
	$queryString = $queryString.TrimEnd(' ')
	}
	
if ($ResultSize -le 50 -and $ResultSize -gt 0)
	{
	#Use limit parameter if result size specified is 50 or less
	$limitParam = "?limit=$ResultSize"
	$fullUrl = $url + $limitParam + $queryString
	$response = Invoke-RestMethod -Method Get -Uri $fullUrl -Credential $credential
	$result = $response.items
	}
else
	{
	$offset = 0
	do
		{
		#If no result size or larger than 50, use paging to build result object
		$offsetParam = "?offset=$offset"
		$fullUrl = $url + $offsetParam + $queryString
		$response = Invoke-RestMethod -Method Get -Uri $fullUrl -Credential $credential
		$result += $response.items
		$offset = $offset + 50
		}
	until ($response.total_count -eq 0 -or $result.Count -ge $ResultSize)
	}

#Filter results to contain desired properties
$output = $result | Sort-Object -Property msgts | 
	Select-Object -Property @{n='Timestamp';e={([DateTime]$_.msgts0).ToLocalTime()}},
		@{n='Action';e={$_.msgaction}},
		@{n='SenderIP';e={$_.msgfromserver}},
		@{n='Sender';e={$_.msgfrom}},
		@{n='Recipient';e={$_.msgto}},
		@{n='Subject';e={$_.msgsubject}},
		#Convert spam rating to corresponding word meaning
		@{n='RPDScore';e={Get-RPDScoreTranslation $_.score_rpd}},
		#SpamAssassin rating (>=5 means spam)
		@{n='SAScore';e={[math]::Round($_.score_sa,1)}},
		@{n='Description';e={$_.msgdescription}}

#Add custom type for use with formatting file
foreach ($entry in $output)
	{
	$entry.pstypenames.Insert(0,'Tracking.Information')
	}

#Create the default property display set (mostly for use without formatting file)
$defaultDisplaySet = 'Timestamp','Action','Sender','Recipient','Subject'
$defaultDisplayPropertySet = New-Object -TypeName System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet',[string[]]$defaultDisplaySet)
$psStandardMembers = [System.Management.Automation.PSMemberInfo[]]@($defaultDisplayPropertySet)
$output | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $psStandardMembers

Write-Output $output

Search mailboxes for large items that may impede migrations to Exchange Online

I have a customer that will be enabling hybrid mode soon and moving mailboxes to Exchange Online. One part of the project entails finding mailboxes that will not have a successful migration because they contain items over 150 MB. I referred them to the script on the TechNet Gallery that does exactly that. When that script was run against an admin’s mailbox, it took 10 minutes to complete. Extrapolating that single mailbox’s time for all 80,000 mailboxes (555 days) is far from accurate, but it does indicate that the process would likely take far longer than they have available to complete that part. (The extrapolation also doesn’t factor running multiple threads of the script.) So I looked at the script to see how it is doing what it does. It enumerates every folder, searches every folder for every item in it, then looks at the size of each item so it can report how many total items there are in each folder and how many are over the size limit.

So they could get results in far shorter time, I wrote a script that uses the EWS Managed API and leverages the hidden AllItems search folder created by Outlook 2010+ when it connects to a mailbox. Since it isn’t a well-known folder name, you have to find the folder first. The following code searches the root of the mailbox for a search folder (folder property type is 2) whose display name is AllItems:

$folderIdRoot = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$mailbox)
$folderView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderView(10)
$folderView.Traversal = [Microsoft.Exchange.WebServices.Data.FolderTraversal]::Shallow
$propFolderType = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(13825,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Integer)
$folderSearchFilter1 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo($propFolderType,"2")
$folderSearchFilter2 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::DisplayName,"AllItems")
$folderSearchFilterColl = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+SearchFilterCollection([Microsoft.Exchange.WebServices.Data.LogicalOperator]::And)
$folderSearchFilterColl.Add($folderSearchFilter1)
$folderSearchFilterColl.Add($folderSearchFilter2)
$folderSearchResult = $exchangeService.FindFolders($folderIdRoot,$folderSearchFilterColl,$folderView)

What if the folder doesn’t exist? If Outlook 2010+ for Windows hasn’t been used against the mailbox, the folder won’t exist. If this is the case, the folder needs to be created. To determine the search restriction used for the folder when created by Outlook, I used MFCMAPI and saw that there is only one: the item has the message class property populated. To create the same search folder with EWS:

$searchFolder = New-Object Microsoft.Exchange.WebServices.Data.SearchFolder($exchangeService)
$folderSearchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+Exists([Microsoft.Exchange.WebServices.Data.ItemSchema]::ItemClass)
$searchFolder.SearchParameters.SearchFilter = $folderSearchFilter
$searchFolder.SearchParameters.Traversal = [Microsoft.Exchange.WebServices.Data.SearchFolderTraversal]::Deep
$msgRootFolderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::MsgFolderRoot)
$searchFolder.SearchParameters.RootFolderIds.Add($msgRootFolderId)
$searchFolder.DisplayName = 'AllItems'
$folderIdRoot = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$mailbox)
$searchFolder.Save($folderIdRoot)

When you create a search folder you need to specify the folders to search and whether subfolders are included, the name of the folder, the search parameters (restrictions), and where to put it. In this case, the folder to search is the well-known folder MsgFolderRoot, which is the visible root folder in the IPM subtree, and subfolders are included by specifying a deep traversal. (This means the recoverable items folders are not included. If you want to include them, you can add to RootFolderIds with the well-known folder for Recoverable Items.) The search parameter is that the ItemClass property exists. (This translates to PR_MESSAGE_CLASS when viewed with MFCMAPI.) The folder is then saved in the root of the mailbox. The folder search can be run again to get the newly created folder.

To get a count of any items that are over 150 MB, do a search against that search folder using a query string. This type of search leverages the content index and is faster than using a search filter with a restriction. This search returns the count of any items over 150 MB, where $maxSize is an integer representing the limit in MB:

$searchBase = $folderSearchResult.Folders[0]
$itemSizeBytes = ($maxSize.ToString()+'MB')/1l
$searchQuery = "Size:>$itemSizeBytes"
$propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet([Microsoft.Exchange.WebServices.Data.BasePropertySet]::IdOnly)  
$itemView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ItemView(10)
$itemView.PropertySet = $propertySet
$itemSearchResults = $searchBase.FindItems($searchQuery,$itemView)

Putting this all together, the script takes an email address (or mailboxes or email addresses from the pipeline), looks for the search folder, creates it if missing and looks for it again, searches for any items in the search folder over a given size, and outputs an object with the email address, the number of items found, and any errors. Running it in the customer’s environment went from 10 minutes per mailbox to 14 mailboxes per minute. You can pipeline the output to CSV to use a source with the large item script from TechNet to get more details of which folder has the oversize items, etc.

For performance, the autodiscover URL of the first mailbox in the pipeline will be cached and used for subsequent mailboxes. Or you can specify a URL to use instead. The default item limit is 150 MB, but you can specify any size you want. There is a switch to use impersonation; otherwise, full mailbox access is needed. I found that you don’t need any permission to a mailbox in order to bind to the root folder, so if you then do a search for the search folder, you get the same result when there isn’t a folder or when you don’t have permission. Therefore, the script checks the account’s permission to the root folder (which is contained in the bind response). Depending on what you want to do with the output, such as feed it to the TechNet script, you can choose to not include mailboxes with 0 large items in the output with the appropriate switch. Lastly, since creating a search folder and waiting for it to initially be populated can take a little time, when a mailbox needs the search folder created and you want to know that it is doing so, use the Verbose switch to see that in the console.

You can download the script via the link below or expand and copy code:

  Get-MailboxLargeItemCount.ps1 (8.3 KiB)

<#
	.Synopsis
		Get number of items in a mailbox over a given size
	.Description
		Get number of items in a mailbox over a given size, leveraging the AlItems search folder.
		If the folder does not exist, it will be created.
	.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 MaxItemSizeMB
		Integer, in megabytes, of the item size that must be exceeded to be included in the count.
		Default is 150.
	.Parameter DoNotIncludeZeroCountMailboxInOutput
		Switch to indicate that a successful search that returns 0 matching items should not be 
		included in the output.
	.Example
		Get-MailboxLargeItemCount.ps1 -EmailAddress johndoe@company.com -Credential (get-credential)
	.Example
		Get-Mailbox johndoe | Get-MailboxLargeItemCount.ps1 -EWSUrl 'https://owa.company.com/ews/exchange.asmx' -UseImpersonation
	.Notes
		Version: 1.1
		Date: 2/10/16
	#>
	
[CmdletBinding()]
param
	(
	[parameter(Mandatory=$true,Position=0,ValueFromPipelinebyPropertyName=$true)][Alias('PrimarySMTPAddress')][string]$EmailAddress,
	#Requires -Version 3
	[pscredential]$Credential, #If not using v3+, you can remove the [pscredential] accelerator reference
	[string]$EWSUrl,
	[string]$EWSApiPath,
	[switch]$UseImpersonation,
	[int]$MaxItemSizeMB = 150,
	[switch]$DoNotIncludeZeroCountMailboxInOutput
	)
begin
	{
	$i = 0
	function Get-SearchFolder ($mailbox)
		{
		$folderIdRoot = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$mailbox)
		$folderView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderView(10)
		$folderView.Traversal = [Microsoft.Exchange.WebServices.Data.FolderTraversal]::Shallow
		#Property that indicates type of folder
		$propFolderType = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition(13825,[Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Integer)
		#Folder property that equates to search folder
		$folderSearchFilter1 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo($propFolderType,"2")
		$folderSearchFilter2 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::DisplayName,"AllItems")
		$folderSearchFilterColl = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+SearchFilterCollection([Microsoft.Exchange.WebServices.Data.LogicalOperator]::And)
		$folderSearchFilterColl.Add($folderSearchFilter1)
		$folderSearchFilterColl.Add($folderSearchFilter2)
		if (([Microsoft.Exchange.WebServices.Data.Folder]::Bind($exchangeService,$folderIdRoot)).EffectiveRights -eq [Microsoft.Exchange.WebServices.Data.EffectiveRights]::None)
			{
			return 'NoPerm'
			}
		,$exchangeService.FindFolders($folderIdRoot,$folderSearchFilterColl,$folderView)
		}
	function Create-SearchFolder ($mailbox)
		{
		$searchFolder = New-Object Microsoft.Exchange.WebServices.Data.SearchFolder($exchangeService)
		#Include all items that have a message class
		$folderSearchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+Exists([Microsoft.Exchange.WebServices.Data.ItemSchema]::ItemClass)
		$searchFolder.SearchParameters.SearchFilter = $folderSearchFilter
		$searchFolder.SearchParameters.Traversal = [Microsoft.Exchange.WebServices.Data.SearchFolderTraversal]::Deep
		#Include all items in the visible folder structure
		$msgRootFolderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::MsgFolderRoot)
		$searchFolder.SearchParameters.RootFolderIds.Add($msgRootFolderId)
		$searchFolder.DisplayName = 'AllItems'
		#Save the folder in the mailbox root (not visible to users)
		$folderIdRoot = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Root,$mailbox)
		try
			{
			$searchFolder.Save($folderIdRoot)
			$true
			}
		catch
			{
			$false
			}
		}
	function Get-ItemsOverSize ($folder, $maxSize)
		{
		$itemSizeBytes = ($maxSize.ToString()+'MB')/1l
		$searchQuery = "Size:>$itemSizeBytes"
		$propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet([Microsoft.Exchange.WebServices.Data.BasePropertySet]::IdOnly)  
		$itemView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ItemView(10)
		$itemView.PropertySet = $propertySet
		,$searchBase.FindItems($searchQuery,$itemView)
		}
	}
process
	{
	#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)
		}
	if ($EWSUrl)
		{
		$exchangeService.Url = $EWSUrl
		}
	elseif ($i -eq 0)
		{
		#Improve autodiscover performance for foreign mailboxes by disabling SCP 
		$exchangeService.EnableScpLookup = $false
		$exchangeService.AutodiscoverUrl($EmailAddress, {$true})
		#Cache the autodiscover URL for subsequent objects in the pipeline
		$autoEWSUrl = $exchangeService.Url
		}
	else
		{
		$exchangeService.Url = $autoEWSUrl
		}
	if ($UseImpersonation)
		{
		$exchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)
		}
	$include = $null
	$output = "" | Select-Object 'EmailAddress','ItemsOverSize','Note'
	$output.EmailAddress = $EmailAddress
	try
		{
		#Get AllItems search folder
		$folderSearchResult = Get-SearchFolder -mailbox $EmailAddress
		if ($folderSearchResult -eq 'NoPerm')
			{
			throw 'Error'
			}
		try
			{
			$searchBase = $folderSearchResult.Folders[0]
			if ($searchBase)
				{
				#Search for any items over the limit
				$itemSearchResult = Get-ItemsOverSize -folder $searchBase -maxSize $MaxItemSizeMB
				if ($itemSearchResult.TotalCount -gt 0)
					{
					$output.ItemsOverSize = $itemSearchResult.TotalCount
					$include = $true
					}
				elseif ($itemSearchResult.TotalCount -eq 0 -and -not ($DoNotIncludeZeroCountMailboxInOutput))
					{
					$output.ItemsOverSize = 0
					$include = $true
					}
				}
			else #Mailbox is missing the AllItems search folder
				{
				#Create AllItems search folder
				Write-Verbose "Creating search folder for $($EmailAddress)"
				if (Create-SearchFolder -mailbox $EmailAddress)
					{
					$folderSearchResult = Get-SearchFolder -mailbox $EmailAddress
					$searchBase = $folderSearchResult.Folders[0]
					#Search for any items over the limit
					$itemSearchResult = Get-ItemsOverSize -folder $searchBase -maxSize $MaxItemSizeMB
					if ($itemSearchResult.TotalCount -gt 0)
						{
						$output.ItemsOverSize = $itemSearchResult.TotalCount
						$include = $true
						}
					elseif ($itemSearchResult.TotalCount -eq 0 -and -not ($DoNotIncludeZeroCountMailboxInOutput))
						{
						$output.ItemsOverSize = 0
						$include = $true
						}
					}
				else
					{
					$output.Note = 'ErrorCreatingSearchFolder'
					$include = $true
					}
				}
			}
		catch {}
		}
	catch
		{
		$output.Note = 'ErrorSearchingFolders'
		$include = $true
		}
	if ($include)
		{
		$output
		}
	$i++
	}

Remove text messaging settings on behalf of users

4/18/16 Edit: I was reviewing this script for an unrelated reason when I discovered that I had used incorrect construction in the begin block since you cannot access parameter values in it.  I have updated the script in the download and the inline code at the end of the post that any any code that references parameters has been moved to the process block.

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 UseExchangeOnline
		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.2
		Date: 4/15/16
	#>
	
[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]$UseExchangeOnline,
	[parameter(Mandatory=$false)][pscredential]$Credential,
	[parameter(Mandatory=$false)][string]$EWSApiPath,
	[switch]$UseImpersonation
	)

begin
	{
	$firstRun = $true
	function Get-UserConfigurationMessage ($targetAddress, $className, $impersonate)
		{
		if ($impersonate)
			{
			$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, $impersonate)
		{
		if ($impersonate)
			{
			$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
	{
	if ($firstRun)
		{
		#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)
			}
		$firstRun = $false
		}
	if ($EWSUrl)
		{
		$exchangeService.Url = $EWSUrl
		}
	elseif ($UseExchangeOnline)
		{
		$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' -impersonate $UseImpersonation
		#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' -impersonate $UseImpersonation
		#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 -impersonate $UseImpersonation
		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
	}

Add travel time appointments in Outlook

I like having travel time appointments in my calendar so that my free/busy accurately reflects that I am unavailable, but also so that I am reminded with enough time that I need to leave. I used to use an add-in from Instyler (no link since the site is no longer active) that allowed me to add travel to and/or from appointments to a selected calendar item, but it doesn’t work with Outlook 2013. So I chose to write a macro several years ago to do it. Like my attachment rename macro, when someone asked about using it in a newsletter, it gave me an opportunity to fix the main limitation in my use case: multiple accounts.

The original code leveraged the Applicaton.CreateItem() method, which creates the item in the default folder of the respective item type (olAppointmentItem, in this case). But I have long had two Exchange accounts in my profile, work and personal. I couldn’t get it to create the appointment item in the selected calendar. So I figured out how to open a particular calendar based on the folder path of the selected calendar.

The macro works against single-occurrence appointments and meetings. (Working with recurrences is messy.) There are actually three subroutines and one function. Two of the subs are for indicating which type of travel appointment to create, to or from. The third sub is for actually creating the appointment item, and the function is for opening the target calendar. I modified the ribbon for appointment items to add a button for creating a “travel to” appointment and a button for creating a “travel from” appointment. Each will prompt for how long the travel time should be, defaulting to 30 minutes. The travel to appointment sets a reminder, but the return travel appointment does not. It also uses the subject of the selected item to construct the subject of the travel time appointment. Paste these four code blocks into the VBA editor and then you can assign your added buttons to the CreateTravelToAppointment and CreateTravelFromAppointment subroutines.

Sub CreateTravelToAppointment()
    Dim objExplorer As Outlook.Explorer
    Dim objSelection As Outlook.Selection
    Dim objSelectedAppointment As Outlook.AppointmentItem
    Dim strFolderPath As String, intMinutes As Integer, dtStartTime As Date, strSubject As String
    
    'Get currently selected appointment item and the calendar it is in
    Set objExplorer = Outlook.ActiveExplorer
    Set objSelection = objExplorer.Selection
    'Get path to current calendar folder (allows for working with non-default and additional calendars)
    strFolderPath = objExplorer.CurrentFolder.folderPath
    
    If objSelection.Count <> 1 Then
        noItem = MsgBox("You must first select an appointment item.", vbCritical, "No item selected")
    Else
        Set objSelectedAppointment = objSelection.Item(1)
        'Get travel time duration to calculate start time
        intMinutes = InputBox("How many minutes for the travel time?", "Enter travel minutes", 30)
        dtStartTime = objSelectedAppointment.Start - TimeSerial(0, intMinutes, 0)
        strSubject = "Travel to " & objSelectedAppointment.subject
        Call CreateTravelAppointment(strFolderPath, strSubject, dtStartTime, intMinutes, True)
    End If
    
    Set objSelectAppointment = Nothing
    Set objSelection = Nothing
    Set objExplorer = Nothing
        
End Sub
Sub CreateTravelFromAppointment()
    Dim objExplorer As Outlook.Explorer
    Dim objSelection As Outlook.Selection
    Dim objSelectedAppointment As Outlook.AppointmentItem
    Dim strFolderPath As String, intMinutes As Integer, dtStartTime As Date, strSubject As String
    
    'Get currently selected appointment item and the calendar it is in
    Set objExplorer = Outlook.ActiveExplorer
    Set objSelection = objExplorer.Selection
    'Get path to current calendar folder (allows for working with non-default and additional calendars)
    strFolderPath = objExplorer.CurrentFolder.folderPath
       
    If objSelection.Count <> 1 Then
        noItem = MsgBox("You must first select an appointment item.", vbCritical, "No item selected")
    Else
        Set objSelectedAppointment = objSelection.Item(1)
        'Get travel time duration to calculate start time
        intMinutes = InputBox("How many minutes for the return travel time?", "Enter travel minutes", 30)
        dtStartTime = objSelectedAppointment.End
        strSubject = "Travel from " & objSelectedAppointment.subject
        Call CreateTravelAppointment(strFolderPath, strSubject, dtStartTime, intMinutes, False)
    End If
        
    Set objSelectAppointment = Nothing
    Set objSelection = Nothing
    Set objExplorer = Nothing
    
End Sub
Sub CreateTravelAppointment(path, subject, starttime, duration, setreminder)
    Dim objCalFolder As Outlook.Folder
    Dim objCalItem As Outlook.AppointmentItem
    
    'Get folder object for given path
    Set objCalFolder = OpenOutlookFolder(path)
    'Create appointment item and set properties
    Set objCalItem = objCalFolder.Items.Add
    objCalItem.subject = subject
    objCalItem.Start = starttime
    objCalItem.duration = duration
    objCalItem.BusyStatus = olOutOfOffice
    'Don't set reminder for return travel time
    If setreminder = False Then
        objCalItem.ReminderSet = False
    End If
    objCalItem.Save
    
    Set objCalItem = Nothing
    Set objCalFolder = Nothing
End Sub
Function OpenOutlookFolder(ByVal strPath As String) As Object
    Dim objSession As NameSpace
    Dim arrFolders As Variant, varFolder As Variant, bolBeyondRoot As Boolean
    
    Set objSession = Outlook.Application.GetNamespace("MAPI")

    On Error Resume Next
    Do While Left(strPath, 1) = "\"
        strPath = Right(strPath, Len(strPath) - 1)
    Loop
    arrFolders = Split(strPath, "\")
    For Each varFolder In arrFolders
        Select Case bolBeyondRoot
            Case False
                Set OpenOutlookFolder = objSession.Folders(varFolder)
                bolBeyondRoot = True
            Case True
                Set OpenOutlookFolder = OpenOutlookFolder.Folders(varFolder)
        End Select
        If Err.Number <> 0 Then
            Set OpenOutlookFolder = Nothing
            Exit For
        End If
    Next
    On Error GoTo 0
    
    Set objSession = Nothing
    
End Function