Many of us heavily rely on our digital calendars to organize our schedules, and with those often come third-party conferencing products such as Webex, GoToMeeting and a myriad of others services to help accommodate these needs. However, we recently came across an issue where one of these tools crashed while sending a calendar invite causing over 30,000 duplicates of a meeting.

The result was not just a plethora of meetings to delete, it also caused the user to not be able to view their calendar at all as opening it in Outlook ended with an application crash. Opening it in the Outlook Web App (OWA) caused the browser to freeze and lock up. So, how do you clean this up? Even if we could do it by hand (who would want to), we can't in this case. Troubling times like this almost always call for our oh-so convenient friend, PowerShell.

What Do You Need?

  • Microsoft Exchange Web Services Managed API (EWS API). At the moment, the latest version is 2.2 and can be downloaded here. This needs to be installed on whichever machine you run the cleanup from (which does not need to be the end users').
  • This guide utilizes a PowerShell script another Microsoft developer has already made, found here, but ours embellishes on some "gotchyas" we came across with this issue.
  • Assuming you're a sys admin for the organization, you can handle this less disruptively to the user by temporarily granting your service management account access to manage the mailbox experiencing the issue.
  • If you do not have admin capabilities to do the above, you will need the user to enter their credentials in order to parse and clean up the mailbox.

Cleaning Up Duplicates

Based on David Barrett's article linked above, we should just be able to run this to clear out duplicates, however things didn't quite work that easy.

With this script and the EWS API, you actually don't need to pre-connect to a PowerShell session to Office 365, that will automatically be handled. Regardless, upon running the command as recommended, we ran into some exceptions about calling Bind without a URL being set. 

Upon some further digging looking into the variables being used, we found the EWS URL was not getting set automatically like it should and instead was blank. To work around this, you can manually assign the EWS URL by adding the following flag which should work for all O365 tenants.

.\Remove-DuplicateAppointments.ps1 -Mailbox mailbox@domain.com -Credentials (Get-Credential) -EwsURL https://outlook.office365.com/EWS/Exchange.asmx -LogFile .\duplicate-log.txt

The script will take a while but ultimately get's things cleaned up nicely along with reporting everything deleted. 

Help! My Deletes Are Timing Out!

For us, with so many items to delete, we ended up running into issues with deleting so many things so quickly. As a workaround, I adjusted the script to run smaller batch sizes of deletes, wait a few seconds in-between and report on progress.

If you have this issue and would like to implement this, replace the original code block below (currently starting at line 303) with the new snippet of code. You can easily change the batch delete size or delay time by modifying $batchSize or $delayTime respectively.

Note: $delayTime is measured in seconds.

Original Code Snippet

if (!$WhatIf)
    {
	    # Delete the items (we will do this in batches of 500)
	    $itemId = New-Object Microsoft.Exchange.WebServices.Data.ItemId("xx")
	    $itemIdType = [Type] $itemId.GetType()
	    $baseList = [System.Collections.Generic.List``1]
	    $genericItemIdList = $baseList.MakeGenericType(@($itemIdType))
	    $deleteIds = [Activator]::CreateInstance($genericItemIdList)
	    ForEach ($dupe in $duplicateItems)
	    {
            Log ([string]::Format("Deleting: {0}", $dupe.Subject)) Gray
		    $deleteIds.Add($dupe.Id)
		    if ($deleteIds.Count -ge 500)
		    {
			    # Send the delete request
			    [void]$global:service.DeleteItems( $deleteIds, [Microsoft.Exchange.WebServices.Data.DeleteMode]::SoftDelete, [Microsoft.Exchange.WebServices.Data.SendCancellationsMode]::SendToNone, $null )
                Write-Verbose ([string]::Format("{0} items deleted", $deleteIds.Count))
			    $deleteIds = [Activator]::CreateInstance($genericItemIdList)
		    }
	    }
	    if ($deleteIds.Count -gt 0)
	    {
		    [void]$global:service.DeleteItems( $deleteIds, [Microsoft.Exchange.WebServices.Data.DeleteMode]::SoftDelete, [Microsoft.Exchange.WebServices.Data.SendCancellationsMode]::SendToNone, $null )
	    }
	    Write-Verbose ([string]::Format("{0} items deleted", $deleteIds.Count))
    }
    else
    {
        # We aren't actually deleting, so just report what we would delete
	    ForEach ($dupe in $duplicateItems)
	    {
            if ([String]::IsNullOrEmpty($dupe.Subject))
            {
                Log "Would delete: [No Subject]" Gray
            }
            else
            {
                Log ([string]::Format("Would delete: {0}", $dupe.Subject)) Gray
            }
        }
    }

 

New Code Snippet

if (!$WhatIf)
    {
	    # Delete the items (we will do this in batches of 500)
	    $itemId = New-Object Microsoft.Exchange.WebServices.Data.ItemId("xx")
	    $itemIdType = [Type] $itemId.GetType()
	    $baseList = [System.Collections.Generic.List``1]
	    $genericItemIdList = $baseList.MakeGenericType(@($itemIdType))
	    $deleteIds = [Activator]::CreateInstance($genericItemIdList)
        
	    # Eli: Added these variables
	    $deleteCount = 0
	    $batchSize = 100
	    $delayTime = 5

	    ForEach ($dupe in $duplicateItems)
	    {
            Log ([string]::Format("Deleting: {0}", $dupe.Subject)) Gray
		    $deleteIds.Add($dupe.Id)

            # Eli: Original count is 500, adjusting to smaller size due to server inability to delete.
		    if ($deleteIds.Count -ge $batchSize)
		    {
			    # Send the delete request
			    [void]$global:service.DeleteItems( $deleteIds, [Microsoft.Exchange.WebServices.Data.DeleteMode]::SoftDelete, [Microsoft.Exchange.WebServices.Data.SendCancellationsMode]::SendToNone, $null )
			    Write-Verbose ([string]::Format("{0} items deleted", $deleteIds.Count))

			     # Eli: Report on progress
 			    $deleteCount += $batchSize
 			    Log ([string]::Format("$deleteCount of {0} items processed", $duplicateItems.Count)) Blue

			    $deleteIds = [Activator]::CreateInstance($genericItemIdList)
			    
			    # Eli: Adding a wait - received errors about server being too busy after many duplicates were processed
  			    Log ([string]::Format("Letting the server catch its breath for $delayTime seconds...")) Blue
			    Start-Sleep -s $delayTime
		    }
	    }
	    if ($deleteIds.Count -gt 0)
	    {
		    [void]$global:service.DeleteItems( $deleteIds, [Microsoft.Exchange.WebServices.Data.DeleteMode]::SoftDelete, [Microsoft.Exchange.WebServices.Data.SendCancellationsMode]::SendToNone, $null )
	    }
	    Write-Verbose ([string]::Format("{0} items deleted", $deleteIds.Count))
    }