Entra / Microsoft 365 · Compliance & audit
Purge compliance search items
An example script to show how to purge items found by a compliance search.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-ExchangeOnline -SkipLoadingCmdletHelp -ShowBanner:$false
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([int] $LookbackDays = 60,[string] $StartDate = "Get-Date (Get-Date).AddDays(-$LookbackDays) -format yyyy-MM-dd",[string] $EndDate = "Get-Date -format yyyy-MM-dd")function Get-RecoverableItemsFolderId ([string]$MailboxId) {# Function to return folder identifiers for the Recoverable Items folders in a mailbox# The types of folders we want to exclude from the search$FolderExclusions = [System.Collections.Generic.List[Object]]::new()[array]$RecoverableFolders = 'RecoverableItemsVersions', 'RecoverableItemsDeletions', 'RecoverableItemsPurges'# Process Recoverable Items folders in the primary mailbox$Folders = Get-ExoMailboxFolderStatistics -Identity $MailboxIdForeach ($Folder in $Folders) {If ($Folder.FolderType -in $RecoverableFolders) {$encoding= [System.Text.Encoding]::GetEncoding("us-ascii")$nibbler= $encoding.GetBytes("0123456789ABCDEF")$folderIdBytes = [Convert]::FromBase64String($Folder.FolderId)$indexIdBytes = New-Object byte[] 48$indexIdIdx=0$folderIdBytes | Select-Object -skip 23 -First 24 | ForEach-Object{$indexIdBytes[$indexIdIdx++]=$nibbler[$_ -shr 4];$indexIdBytes[$indexIdIdx++]=$nibbler[$_ -band 0xF]}$folderQuery = "folderid:$($encoding.GetString($indexIdBytes))"$ReportLine = [PSCustomObject][Ordered]@{FolderPath = $Folder.FolderPathFolderQuery = $folderQuery}$FolderExclusions.Add($ReportLine)}}# Process Recoverable Items folders in the archive mailbox (if enabled)If ((Get-Mailbox -Identity $MailboxId).ArchiveGuid) {$Folders = Get-ExoMailboxFolderStatistics $MailboxIdForeach ($Folder in $Folders) {If ($Folder.FolderType -in $RecoverableFolders) {$encoding= [System.Text.Encoding]::GetEncoding("us-ascii")$nibbler= $encoding.GetBytes("0123456789ABCDEF")$folderIdBytes = [Convert]::FromBase64String($Folder.FolderId)$indexIdBytes = New-Object byte[] 48$indexIdIdx=0$folderIdBytes | Select-Object -skip 23 -First 24 | ForEach-Object{$indexIdBytes[$indexIdIdx++]=$nibbler[$_ -shr 4];$indexIdBytes[$indexIdIdx++]=$nibbler[$_ -band 0xF]}$folderQuery = "folderid:$($encoding.GetString($indexIdBytes))"$ReportLine = [PSCustomObject][Ordered]@{FolderPath = $Folder.FolderPathFolderQuery = $folderQuery}$FolderExclusions.Add($ReportLine)}}}Return $FolderExclusions}function Get-EmailDetailsFromPreviewResults {param ([string]$Line)# function to extract email details from the format used by the preview results$Pattern = "Location: (?<Location>.*?); Sender: (?<Sender>.*?); Subject: (?<Subject>.*?); Type: .*?; Size: .*?; Received Time: (?<ReceivedTime>.*?); Data Link: .*"if ($Line -match $Pattern) {return [PSCustomObject]@{Location = $matches['Location']Sender = $matches['Sender']Subject = $matches['Subject']ReceivedTime = $matches['ReceivedTime']}} else {Write-Host "Line does not match the expected format."return $null}}function Get-ComplianceSearchPreview {Param ([string]$SearchName)# function to return the set of preview items for a compliance searchWrite-Host "Finding preview items for review..."$SearchNamePreview = ("{0}_Preview" -f $SearchName)Remove-ComplianceSearchAction -Identity $SearchNamePreview -Confirm:$False -ErrorAction SilentlyContinue | Out-NullNew-ComplianceSearchAction -SearchName $SearchName -Preview -ErrorAction SilentlyContinue -Confirm:$false | Out-Null$PreviewState = Get-ComplianceSearchAction -Identity $SearchNamePreviewIf ($null -ne $PreviewState){While ($PreviewState.Status -ne "Completed") {Start-Sleep -Seconds 5$PreviewState = Get-ComplianceSearchAction -Identity $SearchNamePreview}}If ($PreviewState.Results) {Write-Host "Formatting preview results..."$PreviewItems = $PreviewState.Results -replace "{" -replace "}" -split ".eml," -replace "`n"ForEach ($Item in $PreviewItems) {$PreviewItem = Get-EmailDetailsFromPreviewResults -Line $Item$ReportLine = [PSCustomObject][Ordered]@{Recipient = $PreviewItem.LocationSender = $PreviewItem.SenderSubject = $PreviewItem.Subject'Received Date' = $PreviewItem.ReceivedTime}$PreviewReport.Add($ReportLine)}$PreviewReport = $PreviewReport | Sort-Object {$_.'Received Date' -as [datetime]} -Descending}Return $PreviewReport}[array]$Modules = Get-Module | Select-Object -ExpandProperty NameIf ("ExchangeOnlineManagement" -notin $Modules) {Write-Host "Connecting to Exchange Online..."Connect-ExchangeOnline -SkipLoadingCmdletHelp -ShowBanner:$false}Write-Host "Connecting to Security & Compliance Center endpoint..."Connect-IPPSSession -ShowBanner:$false# Start of processing - clear the screen and ask which mailbox to search, what to look for, and so on.Clear-Host$SearchAllMailboxes = $false[string]$Mbx = Read-Host "Enter the mailbox to search or type All to search all mailboxes"$RecoverableItemsOption = "Y"If (Get-Mailbox -Identity $Mbx -ErrorAction SilentlyContinue) {$MailboxId = Get-Mailbox -Identity $Mbx$MailboxesToSearch = $MailboxId.UserPrincipalName$MailboxDisplayName = ("the {0} mailbox" -f $MailboxId.DisplayName)} ElseIf ($Mbx.ToLower() -eq "all") {# Checking all mailboxes...$MailboxId = $null$SearchAllMailboxes = $true$RecoverableItemsOption = "N"$MailboxesToSearch = "All"$MailboxDisplayName = "All mailboxes"} Else {Write-Host "Mailbox $Mbx not found" -ForegroundColor RedBreak}[string]$SearchString = Read-Host "Enter the message subject to search for"# If we search all mailboxes, we won't exclude Recoverable Items, but if the flag is false, ask if we should exclude the RI foldersIf ($SearchAllMailboxes -eq $false) {$RecoverableItemsOption = Read-Host "Exclude Recoverable Items folders [Y] Yes [N] No (default is `"N`")"If ($RecoverableItemsOption.ToUpper() -eq "Y") {Write-Host "Recoverable Items folders will be excluded..."}}Write-Host "Computing query criteria"# start and end date for the query. The default search goes back 60 days from today. Obviously, it can be any value you want.# Construct the KQL query for the compliance search$KQLQuery = ("(c:c)(subjecttitle: '{0}')" -f $SearchString)$DateRange = ("(sent={0}..{1})" -f $StartDate, $EndDate)# add the folder identifiers for the Recoverable Items folders to the query if the option is set$FormattedFolderIds = $nullIf ($RecoverableItemsOption.ToUpper() -eq "Y") {Write-Host "Fetching folder identifiers from the target mailbox to exclude Recoverable Items folders from the search"$FoldersToExclude = Get-RecoverableItemsFolderId -MailboxId $MailboxId.ExternalDirectoryObjectId$FormattedFolderIds = " NOT ((" + ($FoldersToExclude.FolderQuery -join ") OR (") + "))"}$KQLQuery = ("{0}{1}{2}" -f $KQLQuery, $DateRange, $FormattedFolderIds)# Tell the administrator what we are going to doWrite-Host ("Compliance search will run against {0} using query {1}" -f $MailboxDisplayName, $KQLQuery)$SearchName = ("Compliance Purge {0} {1}" -f $MailboxesToSearch, $EndDate)# remove any old search of the same name and create and start a new searchRemove-ComplianceSearch -Identity $SearchName -Confirm:$False -ErrorAction SilentlyContinueWrite-Host ("Creating and running new compliance search ({0})" -f $SearchName) -ForegroundColor YellowNew-ComplianceSearch -Name $SearchName -ExchangeLocation $MailboxesToSearch -ContentMatchQuery $KQLQuery -Description 'Compliance Search Test' | Out-NullStart-ComplianceSearch -Identity $SearchNameWrite-Host "Waiting for compliance search to finish..."Do {$ComplianceSearch = Get-ComplianceSearch $SearchName -ErrorAction SilentlyContinue$Status = $ComplianceSearch.StatusStart-Sleep -Seconds 3} While ($Status -ne 'Completed')[int]$ItemsFound = $ComplianceSearch.ItemsIf (!($ItemsFound)) {Write-Host "No items found in compliance search" -ForegroundColor Redbreak}# Use regex to find all instances of the search results where item count is greater than zero$LocationsWithItemCount = [regex]::Matches($ComplianceSearch.SuccessResults, "Item count: (\d+)")[array]$Locations = $LocationsWithItemCount | Where-Object { $_.Groups[1].Value -gt 0 }# Use regex to extract email addresses, item counts, and total sizes from the search results$LocationsWithEmail = [regex]::Matches($ComplianceSearch.SuccessResults, "Location: (\S+@\S+\.\S+), Item count: (\d+), Total size: (\d+)")# Extract email addresses and item counts where item count is greater than 0[array]$LocationsWithItems = $nullforeach ($Match in $LocationsWithEmail) {$Email = $match.Groups[1].Value$ItemCount = [int]$match.Groups[2].Valueif ($ItemCount -gt 0) {$LocationsWithItems += [PSCustomObject]@{Email = $emailItemCount = $itemCount}}}# Figure out how many loops might be needed to remove all items[int]$LocationsGT10 = 0; [int]$HighestValue = 0If ($LocationsWithItems.Count -eq 1) {$HighestLocation = $LocationsWithItems[0].Email$HighestValue = $LocationsWithItems[0].ItemCount} Else {ForEach ($Item in $LocationsWithItems) {$ItemCount = $Item.ItemCountIf ($ItemCount -gt 10) {$LocationsGT10++}If ($ItemCount -gt $HighestValue) {$HighestValue = $ItemCount$HighestLocation = $Item.Email}}}$LoopsNeeded = [math]::ceiling($HighestValue/10)# Let the administrator know what we have foundWrite-Host ("Compliance search completed. {0} items found in {1} locations. {2} iterations are required to remove these items. The mailbox with most items is {3} with {4}." -f $ItemsFound, $Locations.Count, $LoopsNeeded, $HighestLocation, $HighestValue) -ForeGroundColor YellowWrite-Host ""If ($LocationsWithItems.count -gt 1) {Write-Host "Locations with items found by the search" -ForegroundColor YellowWrite-Host "----------------------------------------"$LocationsWithItems | Sort-Object -Property ItemCount -Descending | Format-Table -AutoSizeWrite-Host ""}# Section to find preview and show some times to user to check$Global:PreviewReport = [System.Collections.Generic.List[Object]]::new()$PreviewReport = Get-ComplianceSearchPreview -SearchName $SearchNameIf ($PreviewReport) {Write-Host "Preview of items found by the search" -ForegroundColor YellowWrite-Host "------------------------------------"$PreviewReport | Select-Object -First 10 | Format-Table -AutoSizeWrite-Host ""} Else {Write-Host "No preview items found" -ForegroundColor Red}$OKToProceed = Read-Host "Do you want to proceed with the purge? [Y] Yes [N] No"If ($OKToProceed.ToUpper() -eq "N") {Write-Host "Exiting without purging items" -ForegroundColor RedBreak}Write-Host "Setting up to purge items..."$SearchNameAction = $ComplianceSearch.Name + "_Purge"$TotalResults = 0[int]$Count = 0While ($Count -lt $LoopsNeeded) {$Count++# Remove old purge actionWrite-Host "Removing previous compliance search action (if one exists)" -ForegroundColor YellowRemove-ComplianceSearchAction $SearchNameAction -Confirm:$False -ErrorAction SilentlyContinueWrite-Host ("Submitting search purge action for run {0}" -f $Count)New-ComplianceSearchAction -SearchName $SearchName -Purge -PurgeType HardDelete -Confirm:$False | Out-NullWrite-Host "Waiting for compliance purge action to finish..."Do {$Action = Get-ComplianceSearchAction $SearchNameAction$Status = $Action.StatusStart-Sleep -Seconds 3} While ($Status -ne 'Completed')# Figure out what happened during the purge action$Action = Get-ComplianceSearchAction $SearchNameAction# Parse the purge action results to extract the results of all locations where items were foundIf ($Action.Results -match "Item count: (\d+);") {[int]$Results = [int]$matches[1]} Else {Write-Host "No items were removed by the purge"}$TotalResults = $TotalResults + $ResultsWrite-Host ("Loop {0} purged {1} items (well, ten items maybe)" -f $Count, $Results) -ForegroundColor Green$Action = $null; $Status = $null; $Results = $null}Write-Host ("Purge completed. {0} items removed from {1} locations" -f $TotalResults, $Locations.Count) -ForegroundColor Green
Parameters
ParameterDefaultNotes
-LookbackDays60Number of days to scope the compliance search for items to purge.-StartDateGet-Date (Get-Date).AddDays(-60) -format yyyy-MM-ddStart of the reporting window.-EndDateGet-Date -format yyyy-MM-ddEnd of the reporting window.Attribution
Author
Office365itpros