Delete all empty subfolders of Deleted Items

At my company we use a retention policy to delete items in the Deleted Items folder that are older than 14 days. (This is implemented via Managed Folders, a.k.a. MRM 1.0.) Managed Folders, and even Retention Policies (MRM 2.0), act on items, not folders. A common scenario, though, is that users will soft-delete a folder as an easy way to move all of its items to the Deleted Items folder. After 14 days, the managed folder policy will delete the items in that folder, but it leaves the folder itself. Over time, users may have dozens of empty folders in the Deleted Items folder, and the only way they will ever be deleted is if users delete them one by one. It goes without saying (though I am obviously saying it anyway) that users don’t do this, so lots and lots of empty folders build up over time.

I don’t like this, so I wrote a script that deletes the empty subfolders of Deleted Items for all the mailboxes in the organization. (I actually wrote two scripts, one for the Deleted Items, and one for any specified parent folder.) The script uses the EWS Managed API. You can have any version installed, and it will check for one, starting with the most recent version. The intent of the script is to process all mailboxes in the org, so it retrieves all mailboxes and pipes them into a ForEach-Object loop:

(Get-Mailbox -ResultSize unlimited) | ForEach-Object {
	$EMSDeletedItemsFolderID = Get-FolderId -Mailbox $_.Identity
	$EWSDeletedItemsFolderID = Convert-EMSFolderID -EmailAddress $_.PrimarySMTPAddress -EMSFolderId $EMSDeletedItemsFolderID
	Remove-EmptySubfolder -EmailAddress $_.PrimarySMTPAddress -EWSFolderId $EWSDeletedItemsFolderID
	}

You can always change this line to restrict the returned mailboxes, or use the other script in the download, which supports pipelined input. Note the use of the parentheses when getting the collection of mailboxes. This is to avoid the pipeline constraint when using implicit remoting. The first function called is to get the folder ID of the Deleted Items folder for a given mailbox:

function Get-FolderId ($Mailbox)
	{
	$MailboxFolder = Get-MailboxFolderStatistics -Identity $Mailbox -FolderScope 'DeletedItems' | Select-Object -First 1
	$MailboxFolder.FolderId
	}

The FolderScope parameter is set to the Deleted Items folder, which is piped to Select-Object to limit the result to the top-level folder, ignoring any subfolders that will also be returned. The FolderId property is the function’s output.

The next step is to convert the folder ID from the management shell to one that is usable by Exchange Web Services:

function Convert-EMSFolderID ($EmailAddress, $EMSFolderId)
	{
	$EMSFolderId = $EMSFolderId.Replace('+','%2b')
	$ExchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$ExchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($ExchangeVersion)    
	$ExchangeService.AutodiscoverUrl($EmailAddress,{$true})    
	#Uncomment to use impersonation instead of FMA
	#$ExchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)   
	$aiItem = New-Object -TypeName Microsoft.Exchange.WebServices.Data.AlternateId        
	$aiItem.Mailbox = $EmailAddress
	$aiItem.UniqueId = $EMSFolderId     
	$aiItem.Format = [Microsoft.Exchange.WebServices.Data.IdFormat]::OwaId        
	$convertedId = $ExchangeService.ConvertId($aiItem, [Microsoft.Exchange.WebServices.Data.IdFormat]::EwsId)   
	$convertedId.UniqueId
	}

Any plus (+) signs are transposed to hex since they get in the way. A typical connection to EWS is then established. The AlternateId class is used to specify an ID and what format it is in. Folder IDs in the management shell’s cmdlets are in the OwaId format. To convert them to the EwsId format needed by EWS when referencing an item, the ConvertId() method of the ExchangeService class (the base class of the EWS connection) is used. The UniqueId property is the function’s output.

The last function called is to actually delete any subfolders of the folder with the ID the previous function returned:

function Remove-EmptySubfolder ($EmailAddress, $EWSFolderId)
	{
	$ExchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$ExchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($ExchangeVersion)
	$ExchangeService.AutodiscoverUrl($EmailAddress,{$true})    
	#Uncomment to use impersonation instead of FMA
	#$ExchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)   
	#Bind to folder with specific ID
	$FolderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId($EWSFolderId)   
	$TargetFolder = [Microsoft.Exchange.WebServices.Data.Folder]::Bind($ExchangeService,$FolderId)
	#Create view large enough to hold all of the search results to avoid paging
	$FolderView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderView(850)
	#Search filter for folders with no contents
	$SearchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::TotalCount,0)
	$SearchResults = $ExchangeService.FindFolders($TargetFolder.Id,$SearchFilter,$FolderView)
	if ($SearchResults.TotalCount -gt 0)
		{ 
    	foreach ($folder in $SearchResults.Folders)
			{ 
			#Exclude indirect subfolders
			if ($folder.ParentFolderId.UniqueId -eq $EWSFolderId)
				{
				Write-Output -InputObject "$EmailAddress`: $($folder.DisplayName)"
				$folder.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete)
				}
			}
		}
	else
		{
    	Write-Output -InputObject "$EmailAddress`: No folders to delete"
		}
	}

You can see this function redundantly establishes a new EWS connection. This is because I was adapting functions used elsewhere for this script and I didn’t optimize it. The function may take less time to complete by using a session that has already been established, but I didn’t determine that or change the previous function to create the session variable in the parent scope so it could be reused. After the session initialization, the function connects to the mailbox and binds to the Deleted Items folder using the folder ID from the previous function.

In order to get the list of empty subfolders, a search is performed for folders that contain no items. This is done by creating a view that will hold the results and defining a search filter whose only restriction is that it contains no items. The search is executed and then looped through each returned folder. Because the search is recursive, the loop will ignore any folder whose parent folder is not Deleted Items. The folder is then deleted.

Write-Output is used, not only to save puppies, but so that you can see what is happening while still allowing you to save the output to file. This can be accomplished with Tee-Object, e.g.:

.\Remove-EmptyDeletedItemsSubfolder.ps1 | Tee-Object c:\deletedfolders.txt

One caveat of the script as written is that a subfolder that also contains subfolders, which may contain items, will still be deleted. Since I am deleting subfolders of Deleted Items, whose items are deleted after 14 days anyway, I am not too concerned about inadvertently deleting items. If this will be a problem for you, you will need to modify the script accordingly. (I may do so in the future.) Another workaround if this is a problem, is to use the second script (explained next) which has a parameter for skipping folders that have subfolders, regardless of their content.

While writing the script, I thought it might be beneficial to be able to run this process against any parent folder. So I converted the Deleted Items script into one that does just that. You can run this script with parameters that will do the exact same thing as the first script, but it also lets you specify any of the other folder scopes or a custom folder path. You can also indicate that you don’t want subfolders that have subfolders to be included (to avoid the potential issue using the first script). Lastly, this script requires that you provide a mailbox to act on. This can be manually specified with the Identity parameter, or you can pipe any number of mailboxes into it.

The FolderScope and FolderPath parameters are mutually exclusive. Use FolderScope when you want to easily specify one of the inbuilt scopes available to the Get-MailboxFolderStatistics cmdlet. This could be, for example, Inbox, RssSubscriptions, or SentItems. Use FolderPath when you want to specify a literal path to the parent folder, such as /Inbox/Subfolder1. The path to the folder is validated to ensure it starts with a forward slash and does not end with one, which matches the output of the Get-MailboxFolderStatistics cmdlet.

The functions in this script are broken down to be inline (not using functions) in order to support pipelining. The other change is supporting the exclusion of subfolders that have subfolders. This is done by modifying the search to use an additional restriction:

$SearchFilter1 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::TotalCount,0)
	$SearchFilter = $SearchFilter1
	if ($ExcludeFoldersWithSubfolders)
		{
		$SearchFilter2 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::ChildFolderCount,0)
		$SearchFilterCollection = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+SearchFilterCollection([Microsoft.Exchange.WebServices.Data.LogicalOperator]::And)
		$SearchFilterCollection.Add($SearchFilter1)
		$SearchFilterCollection.Add($SearchFilter2)
		$SearchFilter = $SearchFilterCollection
		}
	$SearchResults = $ExchangeService.FindFolders($TargetFolder.Id,$SearchFilter,$FolderView)

If the parameter to exclude subfolders is used, a search filter collection has to be used because of the need for multiple criteria. You define the search filters the same (using a unique name for each variable), but then you create a search filter collection object, add the search filters to the collection, and then use that object as the search filter in the search execution.

Both scripts are included in the download file, or you can copy the code below.

  Remove-EmptySubfolders.zip (3.4 KiB)

Remove-EmptyDeletedItemsSubfolder

<#
	.Synopsis
		Deletes empty subfolders of Deleted Items folder.
	.Description
		Gets all mailboxes in the organization and searches each mailbox's
		Deleted Items folder for immediate subfolders that do not contain
		any items.
	.Notes
		Version: 1.0
		Date: 8/2/13
#>

#Paths to EWS Managed API DLL
$ewsAPIPaths = "C:\Program Files\Microsoft\Exchange\Web Services\2.0\Microsoft.Exchange.WebServices.dll",
	"C:\Program Files\Microsoft\Exchange\Web Services\1.2\Microsoft.Exchange.WebServices.dll",
	"C:\Program Files\Microsoft\Exchange\Web Services\1.1\Microsoft.Exchange.WebServices.dll",
	"C:\Program Files\Microsoft\Exchange\Web Services\1.0\Microsoft.Exchange.WebServices.dll"

#Test if any version of API is installed
foreach ($path in $ewsAPIPaths)
	{
	if (Test-Path -Path $path)
		{
		Add-Type -Path $path
		$apiFound = $true
		break
		}
	}

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

#Convert folder ID from EMS to folder ID used by EWS
function Convert-EMSFolderID ($EmailAddress, $EMSFolderId)
	{
	$EMSFolderId = $EMSFolderId.Replace('+','%2b')
	$ExchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$ExchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($ExchangeVersion)    
	$ExchangeService.AutodiscoverUrl($EmailAddress,{$true})    
	#Uncomment to use impersonation instead of FMA
	#$ExchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)   
	$aiItem = New-Object -TypeName Microsoft.Exchange.WebServices.Data.AlternateId        
	$aiItem.Mailbox = $EmailAddress
	$aiItem.UniqueId = $EMSFolderId     
	$aiItem.Format = [Microsoft.Exchange.WebServices.Data.IdFormat]::OwaId        
	$convertedId = $ExchangeService.ConvertId($aiItem, [Microsoft.Exchange.WebServices.Data.IdFormat]::EwsId)   
	$convertedId.UniqueId
	}

function Get-FolderId ($Mailbox)
	{
	$MailboxFolder = Get-MailboxFolderStatistics -Identity $Mailbox -FolderScope 'DeletedItems' | Select-Object -First 1
	$MailboxFolder.FolderId
	}

function Remove-EmptySubfolder ($EmailAddress, $EWSFolderId)
	{
	$ExchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$ExchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($ExchangeVersion)
	$ExchangeService.AutodiscoverUrl($EmailAddress,{$true})    
	#Uncomment to use impersonation instead of FMA
	#$ExchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)   
	#Bind to folder with specific ID
	$FolderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId($EWSFolderId)   
	$TargetFolder = [Microsoft.Exchange.WebServices.Data.Folder]::Bind($ExchangeService,$FolderId)
	#Create view large enough to hold all of the search results to avoid paging
	$FolderView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderView(850)
	#Search filter for folders with no contents
	$SearchFilter = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::TotalCount,0)
	$SearchResults = $ExchangeService.FindFolders($TargetFolder.Id,$SearchFilter,$FolderView)
	if ($SearchResults.TotalCount -gt 0)
		{ 
    	foreach ($folder in $SearchResults.Folders)
			{ 
			#Exclude indirect subfolders
			if ($folder.ParentFolderId.UniqueId -eq $EWSFolderId)
				{
				Write-Output -InputObject "$EmailAddress`: $($folder.DisplayName)"
				$folder.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete)
				}
			}
		}
	else
		{
    	Write-Output -InputObject "$EmailAddress`: No folders to delete"
		}
	}

(Get-Mailbox -ResultSize unlimited) | ForEach-Object {
	$EMSDeletedItemsFolderID = Get-FolderId -Mailbox $_.Identity
	$EWSDeletedItemsFolderID = Convert-EMSFolderID -EmailAddress $_.PrimarySMTPAddress -EMSFolderId $EMSDeletedItemsFolderID
	Remove-EmptySubfolder -EmailAddress $_.PrimarySMTPAddress -EWSFolderId $EWSDeletedItemsFolderID
	}

Remove-EmptySubfolder

<#
	.Synopsis
		Deletes subfolders that do not contain any items.
	.Description
		Given a mailbox folder, immediate subfolders that do not contain any items will be deleted.
	.Parameter Identity
		Mailbox identity to act on.
	.Parameter FolderScope
		Valid FolderScope value from Get-MailboxFolderStatistics cmdlet, excluding All.
		This is the parent folder to use for empty subfolders.
	.Parameter FolderPath
		The path to the folder, including the foward slash prefix.  This is the parent
		folder to use for empty subfolders.
	.Parameter ExcludeFoldersWithSubFolders
		Switch to indicate that any subfolder of the parent folder that itself has subfolders
		should not be included for deletion.  This is because the subfolder may contain
		items that would also be deleted.
	.Example
		.\Remove-EmptySubfolder.ps1 JohnDoe -FolderScope DeletedItems
	.Example
		(Get-Mailbox) | .\Remove-EmptySubfolder.ps1 -FolderPath "/Inbox/Folder1"
	.Notes
		Version: 1.0
		Date: 8/30/13
#>
param
	(
	[Parameter(ValueFromPipeline=$true,Position=0)][string]$Identity,
	[Parameter(Mandatory=$true,ParameterSetName='folderscope')]
	[ValidateSet('Calendar','Contacts','ConversationHistory','DeletedItems','Drafts',
	'Inbox','JunkEmail','Journal','ManagedCustomFolder','Notes','Outbox','Personal',
	'RecoverableItems','RssSubscriptions','SentItems','SyncIssues','Tasks')][string]$FolderScope,
	[Parameter(Mandatory=$true,ParameterSetName='folderpath')]
	[ValidatePattern('(?# Path must begin with / and not end with /)^/.*[^/]$')][string]$FolderPath,
	[switch]$ExcludeFoldersWithSubfolders
	)

begin {
	#Paths to EWS Managed API DLL
	$ewsAPIPaths = "C:\Program Files\Microsoft\Exchange\Web Services\2.0\Microsoft.Exchange.WebServices.dll",
		"C:\Program Files\Microsoft\Exchange\Web Services\1.2\Microsoft.Exchange.WebServices.dll",
		"C:\Program Files\Microsoft\Exchange\Web Services\1.1\Microsoft.Exchange.WebServices.dll",
		"C:\Program Files\Microsoft\Exchange\Web Services\1.0\Microsoft.Exchange.WebServices.dll"

	#Test if any version of API is installed
	foreach ($path in $ewsAPIPaths)
		{
		if (Test-Path -Path $path)
			{
			Add-Type -Path $path
			$apiFound = $true
			break
			}
		}

	if (-not($apiFound))
		{
		Write-Error -Message 'The Exchange Web Services Managed API is required to run this script.' -Category NotInstalled
		break
		}
	}
	
process	{
	$EmailAddress = (Get-Mailbox -Identity $Identity).PrimarySMTPAddress
	
	#Get EMS folder ID
	if ($FolderScope)
		{
		$EMSFolder = Get-MailboxFolderStatistics -Identity $Identity -FolderScope $FolderScope | Select-Object -First 1
		$EMSFolderId = $EMSFolder.FolderId
		}
	else
		{
		$EMSFolder = Get-MailboxFolderStatistics -Identity $Identity | Where-Object {$_.FolderPath -eq $FolderPath}
		if ($EMSFolder)
			{
			$EMSFolderId = $EMSFolder.FolderId
			}
		else
			{
			Write-Output -InputObject "$EmailAddress`: No matching parent folder"
			break
			}
		}

	#Convert folder ID from EMS to folder ID used by EWS
	$EMSFolderId = $EMSFolderId.Replace('+','%2b')
	$ExchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2
	$ExchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($ExchangeVersion)    
	$ExchangeService.AutodiscoverUrl($EmailAddress,{$true})    
	#Uncomment to use impersonation instead of FMA
	#$ExchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId([Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress)   
	$aiItem = New-Object -TypeName Microsoft.Exchange.WebServices.Data.AlternateId        
	$aiItem.Mailbox = $EmailAddress
	$aiItem.UniqueId = $EMSFolderId     
	$aiItem.Format = [Microsoft.Exchange.WebServices.Data.IdFormat]::OwaId        
	$convertedId = $ExchangeService.ConvertId($aiItem, [Microsoft.Exchange.WebServices.Data.IdFormat]::EwsId)   
	$EWSFolderId = $convertedId.UniqueId
	
	#Bind to folder with specific ID
	$FolderId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId($EWSFolderId)   
	$TargetFolder = [Microsoft.Exchange.WebServices.Data.Folder]::Bind($ExchangeService,$FolderId)
	#Create view large enough to hold all of the search results to avoid paging
	$FolderView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderView(850)
	#Search filter for folders with no contents
	$SearchFilter1 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::TotalCount,0)
	$SearchFilter = $SearchFilter1
	if ($ExcludeFoldersWithSubfolders)
		{
		$SearchFilter2 = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.FolderSchema]::ChildFolderCount,0)
		$SearchFilterCollection = New-Object -TypeName Microsoft.Exchange.WebServices.Data.SearchFilter+SearchFilterCollection([Microsoft.Exchange.WebServices.Data.LogicalOperator]::And)
		$SearchFilterCollection.Add($SearchFilter1)
		$SearchFilterCollection.Add($SearchFilter2)
		$SearchFilter = $SearchFilterCollection
		}
	$SearchResults = $ExchangeService.FindFolders($TargetFolder.Id,$SearchFilter,$FolderView)
	if ($SearchResults.TotalCount -gt 0)
		{ 
    	foreach ($folder in $SearchResults.Folders)
			{ 
			#Exclude indirect subfolders
			if ($folder.ParentFolderId.UniqueId -eq $EWSFolderId)
				{
				Write-Output -InputObject "$EmailAddress`: $($folder.DisplayName)"
				$folder.Delete([Microsoft.Exchange.WebServices.Data.DeleteMode]::HardDelete)
				}
			}
		}
	else
		{
    	Write-Output -InputObject "$EmailAddress`: No subfolders to delete"
		}
	}
end	{}

2 thoughts on “Delete all empty subfolders of Deleted Items

  1. Is there an update of the script that works for Exchange 2013? I’ve tried to modify your script, adding the path “C:\Program Files\Microsoft\Exchange Server\V15\Bin\Microsoft.Exchange.WebSerivces.dll”, but it’s still bombing out with the error: The Exchange Web Services Managed API is required to run this script.

  2. Yes, it works with Exchange 2013. It has been awhile since I used the script, so I had to update the API load code to not be for a specific version. Replace the section for loading the API with this:

    #Check for EWS API
    $apiPath = (($(Get-ItemProperty -ErrorAction SilentlyContinue -Path Registry::$(Get-ChildItem -ErrorAction SilentlyContinue -Path 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Exchange\Web Services' |
    Sort-Object -Property Name -Descending | Select-Object -First 1 -ExpandProperty Name)).'Install Directory') + 'Microsoft.Exchange.WebServices.dll')
    if (Test-Path -Path $apiPath)
    {
    Add-Type -Path $apiPath
    }
    else
    {
    Write-Error -Message 'The Exchange Web Services Managed API is required to use this script.' -Category NotInstalled
    break
    }

Leave a Reply

Your email address will not be published. Required fields are marked *

*