diff --git a/.vscode/.gitignore b/.vscode/.gitignore new file mode 100644 index 0000000000..450a23404b --- /dev/null +++ b/.vscode/.gitignore @@ -0,0 +1 @@ +launch.json diff --git a/PublicFolders/src/SourceSideValidations/Get-BadDumpsterMappings.ps1 b/PublicFolders/src/SourceSideValidations/Get-BadDumpsterMappings.ps1 deleted file mode 100644 index d44495426a..0000000000 --- a/PublicFolders/src/SourceSideValidations/Get-BadDumpsterMappings.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -function Get-BadDumpsterMappings { - [CmdletBinding()] - [OutputType([System.Object[]])] - param ( - [Parameter()] - [PSCustomObject] - $FolderData - ) - - begin { - $startTime = Get-Date - $progressCount = 0 - $badDumpsterMappings = @() - $sw = New-Object System.Diagnostics.Stopwatch - $sw.Start() - $progressParams = @{ - Activity = "Checking dumpster mappings" - Id = 2 - ParentId = 1 - } - } - - process { - $FolderData.IpmSubtree | ForEach-Object { - $progressCount++ - if ($sw.ElapsedMilliseconds -gt 1000) { - $sw.Restart() - Write-Progress @progressParams -Status $progressCount -PercentComplete ($progressCount * 100 / $FolderData.IpmSubtree.Count) - } - - if (-not (Test-DumpsterValid $_ $FolderData)) { - $badDumpsterMappings += $_ - } - } - - Write-Progress @progressParams -Status "Checking EFORMS dumpster mappings" - - $FolderData.NonIpmSubtree | Where-Object { $_.Identity -like "\NON_IPM_SUBTREE\EFORMS REGISTRY\*" } | ForEach-Object { - if (-not (Test-DumpsterValid $_ $FolderData)) { - $badDumpsterMappings += $_ - } - } - } - - end { - Write-Progress @progressParams -Completed - Write-Host "Get-BadDumpsterMappings duration" ((Get-Date) - $startTime) - return $badDumpsterMappings - } -} - -function Test-DumpsterValid { - [CmdletBinding()] - [OutputType([bool])] - param ( - [Parameter()] - [PSCustomObject] - $Folder, - - [Parameter()] - [PSCustomObject] - $FolderData - ) - - begin { - $valid = $true - } - - process { - $dumpster = $FolderData.NonIpmEntryIdDictionary[$Folder.DumpsterEntryId] - - if ($null -eq $dumpster -or - (-not $dumpster.Identity.StartsWith("\NON_IPM_SUBTREE\DUMPSTER_ROOT", "OrdinalIgnoreCase")) -or - $dumpster.DumpsterEntryId -ne $Folder.EntryId) { - - $valid = $false - } - } - - end { - return $valid - } -} diff --git a/PublicFolders/src/SourceSideValidations/Get-FolderData.ps1 b/PublicFolders/src/SourceSideValidations/Get-FolderData.ps1 index b44b7bf487..14e6baa402 100644 --- a/PublicFolders/src/SourceSideValidations/Get-FolderData.ps1 +++ b/PublicFolders/src/SourceSideValidations/Get-FolderData.ps1 @@ -1,15 +1,24 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +. $PSScriptRoot\Get-IpmSubtree.ps1 +. $PSScriptRoot\Get-NonIpmSubtree.ps1 +. $PSScriptRoot\Get-ItemCount.ps1 + function Get-FolderData { [CmdletBinding()] param ( [Parameter()] [bool] - $StartFresh = $true + $StartFresh = $true, + + [Parameter()] + [bool] + $SlowTraversal = $false ) begin { + Write-Verbose "$($MyInvocation.MyCommand) called." $startTime = Get-Date $serverName = (Get-Mailbox -PublicFolder (Get-OrganizationConfig).RootPublicFolderMailbox.HierarchyMailboxGuid.ToString()).ServerName $folderData = [PSCustomObject]@{ @@ -21,66 +30,99 @@ function Get-FolderData { NonIpmEntryIdDictionary = @{} MailboxToServerMap = @{} ItemCounts = @() + ItemCountDictionary = @{} + Errors = New-Object System.Collections.ArrayList } } process { if (-not $StartFresh -and (Test-Path $PSScriptRoot\IpmSubtree.csv)) { $folderData.IpmSubtree = Import-Csv $PSScriptRoot\IpmSubtree.csv - $folderData.NonIpmSubtree = Import-Csv $PSScriptRoot\NonIpmSubtree.csv - $folderData.ItemCounts = Import-Csv $PSScriptRoot\ItemCounts.csv } else { Add-JobQueueJob @{ - ArgumentList = $serverName + ArgumentList = $serverName, $SlowTraversal Name = "Get-IpmSubtree" ScriptBlock = ${Function:Get-IpmSubtree} } + } + if (-not $StartFresh -and (Test-Path $PSScriptRoot\NonIpmSubtree.csv)) { + $folderData.NonIpmSubtree = Import-Csv $PSScriptRoot\NonIpmSubtree.csv + } else { Add-JobQueueJob @{ - ArgumentList = $serverName + ArgumentList = $serverName, $SlowTraversal Name = "Get-NonIpmSubtree" ScriptBlock = ${Function:Get-NonIpmSubtree} } + } - Add-JobQueueJob @{ - ArgumentList = $serverName - Name = "Get-ItemCount" - ScriptBlock = ${Function:Get-ItemCount} + # If we're not doing slow traversal, we can get the stats concurrently with the other jobs + if (-not $SlowTraversal) { + if (-not $StartFresh -and (Test-Path $PSScriptRoot\ItemCounts.csv)) { + $folderData.ItemCounts = Import-Csv $PSScriptRoot\ItemCounts.csv + } else { + Add-JobQueueJob @{ + ArgumentList = $serverName + Name = "Get-ItemCount" + ScriptBlock = ${Function:Get-ItemCount} + } } + } - $completedJobs = Wait-QueuedJob + $completedJobs = Wait-QueuedJob - foreach ($job in $completedJobs) { - if ($null -ne $job.IpmSubtree) { - $folderData.IpmSubtree = $job.IpmSubtree - $folderData.IpmSubtree | Export-Csv $PSScriptRoot\IpmSubtree.csv - } + foreach ($job in $completedJobs) { + if ($null -ne $job.IpmSubtree) { + $folderData.IpmSubtree = $job.IpmSubtree + $folderData.IpmSubtree | Export-Csv $PSScriptRoot\IpmSubtree.csv + } - if ($null -ne $job.NonIpmSubtree) { - $folderData.NonIpmSubtree = $job.NonIpmSubtree - $folderData.NonIpmSubtree | Export-Csv $PSScriptRoot\NonIpmSubtree.csv - } + if ($null -ne $job.NonIpmSubtree) { + $folderData.NonIpmSubtree = $job.NonIpmSubtree + $folderData.NonIpmSubtree | Export-Csv $PSScriptRoot\NonIpmSubtree.csv + } - if ($null -ne $job.ItemCounts) { - $folderData.ItemCounts = $job.ItemCounts - $folderData.ItemCounts | Export-Csv $PSScriptRoot\ItemCounts.csv - } + if ($null -ne $job.ItemCounts) { + $folderData.ItemCounts = $job.ItemCounts + $folderData.ItemCounts | Export-Csv $PSScriptRoot\ItemCounts.csv } } $folderData.IpmSubtreeByMailbox = $folderData.IpmSubtree | Group-Object ContentMailbox $folderData.IpmSubtree | ForEach-Object { $folderData.ParentEntryIdCounts[$_.ParentEntryId] += 1 } $folderData.IpmSubtree | ForEach-Object { $folderData.EntryIdDictionary[$_.EntryId] = $_ } + # We can't count on $folder.Path.Depth being available in remote powershell, + # so we calculate the depth by walking the parent entry IDs. + $folderData.IpmSubtree | ForEach-Object { + $pathDepth = 0 + $parent = $folderData.EntryIdDictionary[$_.ParentEntryId] + while ($null -ne $parent) { + $pathDepth++ + $parent = $folderData.EntryIdDictionary[$parent.ParentEntryId] + } + + Add-Member -InputObject $_ -MemberType NoteProperty -Name FolderPathDepth -Value $pathDepth + } $folderData.NonIpmSubtree | ForEach-Object { $folderData.NonIpmEntryIdDictionary[$_.EntryId] = $_ } - $folderData.ItemCounts | ForEach-Object { - if ($_.ItemCount -gt 0) { - $folder = $folderData.EntryIdDictionary[$_.EntryId.ToString()] - if ($null -ne $folder) { - $folder.ItemCount = $_.ItemCount + # If we're doing slow traversal, we have to get the stats after we have the hierarchy + # grouped by mailbox. + if ($SlowTraversal) { + if (-not $StartFresh -and (Test-Path $PSScriptRoot\ItemCounts.csv)) { + $folderData.ItemCounts = Import-Csv $PSScriptRoot\ItemCounts.csv + } else { + Write-Verbose "Starting slow traversal item count." + $itemCountResult = Get-ItemCount $serverName $folderData + $folderData.ItemCounts = $itemCountResult.ItemCounts + $folderData.ItemCounts | Export-Csv $PSScriptRoot\ItemCounts.csv + foreach ($errorParam in $itemCountResult.Errors) { + $errorResult = New-TestResult @errorParam + $folderData.Errors.Add($errorResult) } } } + + $folderData.ItemCounts | ForEach-Object { $folderData.ItemCountDictionary[$_.EntryId] = $_.ItemCount } } end { @@ -91,7 +133,3 @@ function Get-FolderData { return $folderData } } - -. $PSScriptRoot\Get-IpmSubtree.ps1 -. $PSScriptRoot\Get-NonIpmSubtree.ps1 -. $PSScriptRoot\Get-ItemCount.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Get-IpmSubtree.ps1 b/PublicFolders/src/SourceSideValidations/Get-IpmSubtree.ps1 index 1fa1ae42cf..50338fa377 100644 --- a/PublicFolders/src/SourceSideValidations/Get-IpmSubtree.ps1 +++ b/PublicFolders/src/SourceSideValidations/Get-IpmSubtree.ps1 @@ -6,57 +6,117 @@ function Get-IpmSubtree { param ( [Parameter(Position = 0)] [string] - $Server + $Server, + + [Parameter(Position = 1)] + [bool] + $SlowTraversal = $false ) begin { $WarningPreference = "SilentlyContinue" - Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) | Out-Null $progressCount = 0 - $errors = 0 - $ipmSubtree = @() + $maxRetries = 10 + $retryDelay = [TimeSpan]::FromMinutes(5) + $ipmSubtree = New-Object System.Collections.ArrayList $sw = New-Object System.Diagnostics.Stopwatch $sw.Start() $progressParams = @{ Activity = "Retrieving IPM_SUBTREE folders" } + + # Only used for slow traversal to save progress in case of failure + $foldersProcessed = New-Object 'System.Collections.Generic.HashSet[string]' + + # This must be defined in the function scope because this function is runs as a job + function Get-FoldersRecursive { + [CmdletBinding()] + param ( + [Parameter(Position = 0)] + [object] + $Folder, + + [Parameter(Position = 1)] + [object] + $FoldersProcessed + ) + + $children = Get-PublicFolder $Folder.EntryId -GetChildren -ResultSize Unlimited + foreach ($child in $children) { + if (-not $FoldersProcessed.Contains($child.EntryId.ToString())) { + if ($child.HasSubfolders) { + Get-FoldersRecursive $child $FoldersProcessed + } + + $child + } + } + } } process { - if (-not $startFresh -and (Test-Path $PSScriptRoot\IpmSubtree.csv)) { - Write-Progress @progressParams - $ipmSubtree = Import-Csv $PSScriptRoot\IpmSubtree.csv - } else { - $ipmSubtree = Get-PublicFolder -Recurse -ResultSize Unlimited | - Select-Object Identity, EntryId, ParentFolder, DumpsterEntryId, FolderPath, FolderSize, HasSubfolders, ContentMailboxName, MailEnabled, MailRecipientGuid | - ForEach-Object { - $progressCount++ - $currentFolder = $_.Identity.ToString() - try { - if ($sw.ElapsedMilliseconds -gt 1000) { - $sw.Restart() - Write-Progress @progressParams -Status $progressCount - } - - [PSCustomObject]@{ - Identity = $_.Identity.ToString() - EntryId = $_.EntryId.ToString() - ParentEntryId = $_.ParentFolder.ToString() - DumpsterEntryId = if ($_.DumpsterEntryId) { $_.DumpsterEntryId.ToString() } else { $null } - FolderPathDepth = $_.FolderPath.Depth - FolderSize = $_.FolderSize - HasSubfolders = $_.HasSubfolders - ContentMailbox = $_.ContentMailboxName - MailEnabled = $_.MailEnabled - MailRecipientGuid = $_.MailRecipientGuid - ItemCount = 0 - } - } catch { - $errors++ - Write-Error -Message $currentFolder -Exception $_.Exception - break - } + $getCommand = { Get-PublicFolder -Recurse -ResultSize Unlimited } + + if ($SlowTraversal) { + $getCommand = { $top = Get-PublicFolder "\"; Get-FoldersRecursive $top $foldersProcessed; $top } + } + + $outputResultsScriptBlock = { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $Folder + ) + + process { + $progressCount++ + + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount + } + + $result = [PSCustomObject]@{ + Name = $Folder.Name + Identity = $Folder.Identity.ToString() + EntryId = $Folder.EntryId.ToString() + ParentEntryId = $Folder.ParentFolder.ToString() + DumpsterEntryId = if ($Folder.DumpsterEntryId) { $Folder.DumpsterEntryId.ToString() } else { $null } + FolderSize = $Folder.FolderSize + HasSubfolders = $Folder.HasSubfolders + ContentMailbox = $Folder.ContentMailboxName + MailEnabled = $Folder.MailEnabled + MailRecipientGuid = $Folder.MailRecipientGuid + } + + [void]$ipmSubtree.Add($result) + + [void]$foldersProcessed.Add($Folder.EntryId.ToString()) + } + } + + for ($retryCount = 1; $retryCount -le $maxRetries; $retryCount++) { + try { + Get-PSSession | Remove-PSSession + Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) -AllowClobber | Out-Null + Invoke-Command $getCommand | &$outputResultsScriptBlock + break + } catch { + if (-not $SlowTraversal) { + throw + } + + $sw.Restart() + while ($sw.ElapsedMilliseconds -lt $retryDelay.TotalMilliseconds) { + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Error: $($_.Message)" + Start-Sleep -Seconds 5 + $remainingMilliseconds = $retryDelay.TotalMilliseconds - $sw.ElapsedMilliseconds + if ($remainingMilliseconds -lt 0) { $remainingMilliseconds = 0 } + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Will retry in $([TimeSpan]::FromMilliseconds($remainingMilliseconds))" + Start-Sleep -Seconds 5 } + } } } diff --git a/PublicFolders/src/SourceSideValidations/Get-ItemCount.ps1 b/PublicFolders/src/SourceSideValidations/Get-ItemCount.ps1 index e9b89d31d3..32e8332649 100644 --- a/PublicFolders/src/SourceSideValidations/Get-ItemCount.ps1 +++ b/PublicFolders/src/SourceSideValidations/Get-ItemCount.ps1 @@ -1,38 +1,96 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +. .\Get-ItemCountJob.ps1 + function Get-ItemCount { <# .SYNOPSIS - Populates the ItemCount property on our PSCustomObjects. + Gets the item count for each folder. #> [CmdletBinding()] param ( [Parameter(Position = 0)] [string] - $Server + $Server, + + [Parameter(Position = 1)] + [PSCustomObject] + $FolderData = $null ) begin { - $WarningPreference = "SilentlyContinue" - Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) | Out-Null + Write-Verbose "$($MyInvocation.MyCommand) called." + $progressCount = 0 $sw = New-Object System.Diagnostics.Stopwatch $sw.Start() $progressParams = @{ Activity = "Getting public folder statistics" } + + $itemCounts = New-Object System.Collections.ArrayList + $errors = New-Object System.Collections.ArrayList } process { - $itemCounts = Get-PublicFolderStatistics -ResultSize Unlimited | ForEach-Object { - $progressCount++ - if ($sw.ElapsedMilliseconds -gt 1000) { - $sw.Restart() - Write-Progress @progressParams -Status $progressCount + if ($null -eq $FolderData) { + $WarningPreference = "SilentlyContinue" + Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) | Out-Null + $itemCounts = Get-PublicFolderStatistics -ResultSize Unlimited | ForEach-Object { + $progressCount++ + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount + } + + Select-Object -InputObject $_ -Property EntryId, ItemCount } + } else { + $batchSize = 10000 + $jobsToCreate = New-Object 'System.Collections.Generic.Dictionary[string, System.Collections.ArrayList]' + foreach ($group in $folderData.IpmSubtreeByMailbox) { + # MailboxToServerMap is not populated yet, so we can't use it here + $server = (Get-Mailbox $group.Name -PublicFolder).ServerName + [int]$mailboxBatchCount = ($group.Group.Count / $batchSize) + 1 + Write-Verbose "Creating $mailboxBatchCount item count jobs for $($group.Group.Count) folders in mailbox $($group.Name) on server $server." + $jobsForThisMailbox = New-Object System.Collections.ArrayList + for ($i = 0; $i -lt $mailboxBatchCount; $i++) { + $batch = $group.Group | Select-Object -First $batchSize -Skip ($batchSize * $i) + $argumentList = $server, $group.Name, $batch + [void]$jobsForThisMailbox.Add(@{ + ArgumentList = $argumentList + Name = "Item Count $($group.Name) Job $($i + 1)" + ScriptBlock = ${Function:Get-ItemCountJob} + }) + } - Select-Object -InputObject $_ -Property EntryId, ItemCount + [void]$jobsToCreate.Add($group.Name, $jobsForThisMailbox) + } + + # Add the jobs by round-robin among the mailboxes so we don't execute all jobs + # for one mailbox in parallel unless we have to + $jobsAddedThisRound = 0 + $index = 0 + do { + $jobsAddedThisRound = 0 + foreach ($mailboxName in $jobsToCreate.Keys) { + $batchesForThisMailbox = $jobsToCreate[$mailboxName] + if ($batchesForThisMailbox.Count -gt $index) { + $jobParams = $batchesForThisMailbox[$index] + Add-JobQueueJob $jobParams + $jobsAddedThisRound++ + } + } + + $index++ + } while ($jobsAddedThisRound -gt 0) + + Wait-QueuedJob | ForEach-Object { + $itemCounts.AddRange($_.ItemCounts) + $errors.AddRange($_.Errors) + Write-Verbose "Retrieved item counts for $($itemCounts.Count) folders so far. $($errors.Count) errors encountered." + } } } @@ -41,6 +99,7 @@ function Get-ItemCount { return [PSCustomObject]@{ ItemCounts = $itemCounts + Errors = $errors } } } diff --git a/PublicFolders/src/SourceSideValidations/Get-ItemCountJob.ps1 b/PublicFolders/src/SourceSideValidations/Get-ItemCountJob.ps1 new file mode 100644 index 0000000000..a12569a337 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Get-ItemCountJob.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Get-ItemCountJob { + [CmdletBinding()] + param ( + [Parameter(Position = 0)] + [string] + $Server, + + [Parameter(Position = 1)] + [string] + $Mailbox, + + [Parameter(Position = 2)] + [PSCustomObject[]] + $Folders + ) + + begin { + $retryDelay = [TimeSpan]::FromMinutes(5) + $WarningPreference = "SilentlyContinue" + Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) -AllowClobber | Out-Null + $startTime = Get-Date + $progressCount = 0 + $sw = New-Object System.Diagnostics.Stopwatch + $sw.Start() + $progressParams = @{ + Activity = "Getting public folder statistics" + } + + $itemCounts = New-Object System.Collections.ArrayList + $errors = New-Object System.Collections.ArrayList + } + + process { + $ErrorActionPreference = "Stop" # So our try/catch works + $itemCounts = New-Object System.Collections.ArrayList + foreach ($folder in $Folders) { + $progressCount++ + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount + } + + $maxRetries = 5 + for ($retryCount = 1; $retryCount -le $maxRetries; $retryCount++) { + try { + $stats = Get-PublicFolderStatistics $folder.EntryId | Select-Object EntryId, ItemCount + [void]$itemCounts.Add($stats) + break + } catch { + # Only retry Kerberos errors + if ($_.ToString().Contains("Kerberos")) { + $sw.Restart() + while ($sw.ElapsedMilliseconds -lt $retryDelay.TotalMilliseconds) { + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Error: $($_.Message)" + Start-Sleep -Seconds 5 + $remainingMilliseconds = $retryDelay.TotalMilliseconds - $sw.ElapsedMilliseconds + if ($remainingMilliseconds -lt 0) { $remainingMilliseconds = 0 } + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Will retry in $([TimeSpan]::FromMilliseconds($remainingMilliseconds))" + Start-Sleep -Seconds 5 + } + + Get-PSSession | Remove-PSSession + Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) -AllowClobber | Out-Null + } else { + $errorReport = @{ + TestName = "Get-ItemCount" + ResultType = "CouldNotGetItemCount" + Severity = "Error" + FolderIdentity = $folder.Identity + FolderEntryId = $folder.EntryId + ResultData = $_.ToString() + } + + [void]$errors.Add($errorReport) + } + } + } + } + } + + end { + Write-Progress @progressParams -Completed + $duration = ((Get-Date) - $startTime) + return [PSCustomObject]@{ + ItemCounts = $itemCounts + Errors = $errors + Duration = $duration + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Get-NonIpmSubtree.ps1 b/PublicFolders/src/SourceSideValidations/Get-NonIpmSubtree.ps1 index 0c6fb8397d..52cef1dbb3 100644 --- a/PublicFolders/src/SourceSideValidations/Get-NonIpmSubtree.ps1 +++ b/PublicFolders/src/SourceSideValidations/Get-NonIpmSubtree.ps1 @@ -6,46 +6,111 @@ function Get-NonIpmSubtree { param ( [Parameter(Position = 0)] [string] - $Server + $Server, + + [Parameter(Position = 1)] + [bool] + $SlowTraversal = $false ) begin { $WarningPreference = "SilentlyContinue" - Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) | Out-Null $progressCount = 0 - $errors = 0 - $nonIpmSubtree = @() + $maxRetries = 10 + $retryDelay = [timespan]::FromMinutes(5) + $nonIpmSubtree = New-Object System.Collections.ArrayList $sw = New-Object System.Diagnostics.Stopwatch $sw.Start() $progressParams = @{ Activity = "Retrieving NON_IPM_SUBTREE folders" } + + # Only used for slow traversal to save progress in case of failure + $foldersProcessed = New-Object 'System.Collections.Generic.HashSet[string]' + + # This must be defined in the function scope because this function is runs as a job + function Get-FoldersRecursive { + [CmdletBinding()] + param ( + [Parameter(Position = 0)] + [object] + $Folder, + + [Parameter(Position = 1)] + [object] + $FoldersProcessed + ) + + $children = Get-PublicFolder $Folder.EntryId -GetChildren -ResultSize Unlimited + foreach ($child in $children) { + if (-not $FoldersProcessed.Contains($child.EntryId.ToString())) { + if ($child.HasSubfolders) { + Get-FoldersRecursive $child $FoldersProcessed + } + + $child + } + } + } } process { - $nonIpmSubtree = Get-PublicFolder \non_ipm_subtree -Recurse -ResultSize Unlimited | - Select-Object Identity, EntryId, DumpsterEntryId, MailEnabled | - ForEach-Object { + $getCommand = { Get-PublicFolder "\non_ipm_subtree" -Recurse -ResultSize Unlimited } + + if ($SlowTraversal) { + $getCommand = { $top = Get-PublicFolder "\non_ipm_subtree"; Get-FoldersRecursive $top $foldersProcessed; $top } + } + + $outputResultsScriptBlock = { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $Folder + ) + + process { $progressCount++ - $currentFolder = $_.Identity.ToString() - try { - if ($sw.ElapsedMilliseconds -gt 1000) { - $sw.Restart() - Write-Progress @progressParams -Status $progressCount - } + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount + } - [PSCustomObject]@{ - Identity = $_.Identity.ToString() - EntryId = $_.EntryId.ToString() - DumpsterEntryId = if ($_.DumpsterEntryId) { $_.DumpsterEntryId.ToString() } else { $null } - MailEnabled = $_.MailEnabled - } - } catch { - $errors++ - Write-Error -Message $currentFolder -Exception $_.Exception - break + $result = [PSCustomObject]@{ + Identity = $Folder.Identity.ToString() + EntryId = $Folder.EntryId.ToString() + DumpsterEntryId = if ($Folder.DumpsterEntryId) { $Folder.DumpsterEntryId.ToString() } else { $null } + MailEnabled = $Folder.MailEnabled } + + $null = $nonIpmSubtree.Add($result) + + $null = $foldersProcessed.Add($Folder.EntryId.ToString()) } + } + + for ($retryCount = 1; $retryCount -le $maxRetries; $retryCount++) { + try { + Get-PSSession | Remove-PSSession + Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) -AllowClobber | Out-Null + Invoke-Command $getCommand | &$outputResultsScriptBlock + break + } catch { + if (-not $SlowTraversal) { + throw + } + + $sw.Restart() + while ($sw.ElapsedMilliseconds -lt $retryDelay.TotalMilliseconds) { + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Error: $($_.Message)" + Start-Sleep -Seconds 5 + $remainingMilliseconds = $retryDelay.TotalMilliseconds - $sw.ElapsedMilliseconds + if ($remainingMilliseconds -lt 0) { $remainingMilliseconds = 0 } + Write-Progress @progressParams -Status "Retry $retryCount of $maxRetries. Will retry in $([TimeSpan]::FromMilliseconds($remainingMilliseconds))" + Start-Sleep -Seconds 5 + } + } + } } end { diff --git a/PublicFolders/src/SourceSideValidations/JobQueue.ps1 b/PublicFolders/src/SourceSideValidations/JobQueue.ps1 index 41c459d345..c8ff2a8e78 100644 --- a/PublicFolders/src/SourceSideValidations/JobQueue.ps1 +++ b/PublicFolders/src/SourceSideValidations/JobQueue.ps1 @@ -32,7 +32,6 @@ function Wait-QueuedJob { begin { $jobsRunning = @() - $jobResults = @() $jobQueueMaxConcurrency = 5 } @@ -55,7 +54,7 @@ function Wait-QueuedJob { } Write-Host $job.Name "job finished." Remove-Job $job -Force - $jobResults += $result + $result } $jobsRunning = @($jobsRunning | Where-Object { -not $justFinished.Contains($_) }) @@ -73,10 +72,4 @@ function Wait-QueuedJob { Start-Sleep 1 } } - - end { - $jobsToReturn = $jobResults - $jobResults = @() - return $jobsToReturn - } } diff --git a/PublicFolders/src/SourceSideValidations/Remove-InvalidPermission.ps1 b/PublicFolders/src/SourceSideValidations/Remove-InvalidPermission.ps1 deleted file mode 100644 index 0be03bf187..0000000000 --- a/PublicFolders/src/SourceSideValidations/Remove-InvalidPermission.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -function Remove-InvalidPermission { - [CmdletBinding(SupportsShouldProcess)] - param ( - [Parameter()] - [string] - $CsvFile - ) - - begin { - - $progressParams = @{ - Activity = "Removing invalid permissions" - } - - $sw = New-Object System.Diagnostics.Stopwatch - $sw.Start() - } - - process { - - $badPermissions = Import-Csv $csvFile - $progressCount = 0 - $entryIdsProcessed = New-Object 'System.Collections.Generic.HashSet[string]' - foreach ($permission in $badPermissions) { - $progressCount++ - if ($sw.ElapsedMilliseconds -gt 1000) { - $sw.Restart() - Write-Progress @progressParams -Status "$progressCount / $($badPermissions.Count)" -PercentComplete ($progressCount * 100 / $badPermissions.Count) -CurrentOperation $permission.Identity - } - - if ($entryIdsProcessed.Add($permission.EntryId)) { - $permsOnFolder = Get-PublicFolderClientPermission -Identity $permission.EntryId - $permsOnFolder | ForEach-Object { - if ( - ($_.User.DisplayName -ne "Default") -and - ($_.User.DisplayName -ne "Anonymous") -and - ($null -eq $_.User.ADRecipient) -and - ($_.User.UserType -eq "Unknown") - ) { - if ($PSCmdlet.ShouldProcess("$($permission.Identity)", "Remove $($_.User.DisplayName)")) { - Write-Host "Removing $($_.User.DisplayName) from folder $($permission.Identity)" - $_ | Remove-PublicFolderClientPermission -Confirm:$false - } - } - } - } - } - } - - end {} -} diff --git a/PublicFolders/src/SourceSideValidations/SourceSideValidations.ps1 b/PublicFolders/src/SourceSideValidations/SourceSideValidations.ps1 index 8de8493851..00a8e7e107 100644 --- a/PublicFolders/src/SourceSideValidations/SourceSideValidations.ps1 +++ b/PublicFolders/src/SourceSideValidations/SourceSideValidations.ps1 @@ -7,27 +7,41 @@ param ( [bool] $StartFresh = $true, + [Parameter(Mandatory = $false, ParameterSetName = "Default")] + [switch] + $SlowTraversal, + [Parameter(Mandatory = $true, ParameterSetName = "RemoveInvalidPermissions")] - [Switch] + [switch] $RemoveInvalidPermissions, + [Parameter(Mandatory = $true, ParameterSetName = "SummarizePreviousResults")] + [Switch] + $SummarizePreviousResults, + + [Parameter(ParameterSetName = "Default")] [Parameter(ParameterSetName = "RemoveInvalidPermissions")] + [Parameter(ParameterSetName = "SummarizePreviousResults")] [string] - $CsvFile = (Join-Path $PSScriptRoot "InvalidPermissions.csv"), + $ResultsFile = (Join-Path $PSScriptRoot "ValidationResults.csv"), [Parameter()] [switch] - $SkipVersionCheck + $SkipVersionCheck, + + [Parameter(Mandatory = $false, ParameterSetName = "Default")] + [ValidateSet("Dumpsters", "Limits", "Names", "MailEnabled", "Permissions")] + [string[]] + $Tests = @("Dumpsters", "Limits", "Names", "MailEnabled", "Permissions") ) +. $PSScriptRoot\Tests\DumpsterMapping\AllFunctions.ps1 +. $PSScriptRoot\Tests\Limit\AllFunctions.ps1 +. $PSScriptRoot\Tests\Name\AllFunctions.ps1 +. $PSScriptRoot\Tests\MailEnabledFolder\AllFunctions.ps1 +. $PSScriptRoot\Tests\Permission\AllFunctions.ps1 . $PSScriptRoot\Get-FolderData.ps1 -. $PSScriptRoot\Get-LimitsExceeded.ps1 -. $PSScriptRoot\Get-BadDumpsterMappings.ps1 -. $PSScriptRoot\Get-BadPermission.ps1 -. $PSScriptRoot\Get-BadPermissionJob.ps1 . $PSScriptRoot\JobQueue.ps1 -. $PSScriptRoot\Remove-InvalidPermission.ps1 -. $PSScriptRoot\Get-BadMailEnabledFolder.ps1 . $PSScriptRoot\..\..\..\Shared\Test-ScriptVersion.ps1 if (-not $SkipVersionCheck) { @@ -38,34 +52,44 @@ if (-not $SkipVersionCheck) { } } +if ($SummarizePreviousResults) { + $results = Import-Csv $ResultsFile + $results | Write-TestDumpsterMappingResult + $results | Write-TestFolderLimitResult + $results | Write-TestFolderNameResult + $results | Write-TestMailEnabledFolderResult + $results | Write-TestPermissionResult + Write-Host + return +} + if ($RemoveInvalidPermissions) { - if (-not (Test-Path $CsvFile)) { - Write-Error "File not found: $CsvFile" + if (-not (Test-Path $ResultsFile)) { + Write-Error "File not found: $ResultsFile. Please specify -ResultsFile or run without -RemoveInvalidPermissions to generate a results file." } else { - Remove-InvalidPermission -CsvFile $CsvFile + Import-Csv $ResultsFile | Remove-InvalidPermission } + return } $startTime = Get-Date -$startingErrorCount = $Error.Count - -Set-ADServerSettings -ViewEntireForest $true - -if ($Error.Count -gt $startingErrorCount) { - # If we already have errors, we're not running from the right shell. +if ($null -eq (Get-Command Set-ADServerSettings -ErrorAction:SilentlyContinue)) { + Write-Warning "Exchange Server cmdlets are not present in this shell." return } +Set-ADServerSettings -ViewEntireForest $true + $progressParams = @{ Activity = "Validating public folders" Id = 1 } -Write-Progress @progressParams -Status "Step 1 of 5" +Write-Progress @progressParams -Status "Step 1 of 6" -$folderData = Get-FolderData -StartFresh $StartFresh +$folderData = Get-FolderData -StartFresh $StartFresh -SlowTraversal $SlowTraversal if ($folderData.IpmSubtree.Count -lt 1) { return @@ -94,166 +118,64 @@ if ($script:anyDatabaseDown) { # Now we're ready to do the checks -Write-Progress @progressParams -Status "Step 2 of 5" - -$badDumpsters = @(Get-BadDumpsterMappings -FolderData $folderData) - -Write-Progress @progressParams -Status "Step 3 of 5" - -$limitsExceeded = Get-LimitsExceeded -FolderData $folderData - -Write-Progress @progressParams -Status "Step 4 of 5" - -$badMailEnabled = Get-BadMailEnabledFolder -FolderData $folderData - -Write-Progress @progressParams -Status "Step 5 of 5" - -$badPermissions = @(Get-BadPermission -FolderData $folderData) - -# Output the results - -if ($badMailEnabled.FoldersToMailDisable.Count -gt 0) { - $foldersToMailDisableFile = Join-Path $PSScriptRoot "FoldersToMailDisable.txt" - Set-Content -Path $foldersToMailDisableFile -Value $badMailEnabled.FoldersToMailDisable - - Write-Host - Write-Host $badMailEnabled.FoldersToMailDisable.Count "folders should be mail-disabled, either because the MailRecipientGuid" - Write-Host "does not exist, or because they are system folders. These are listed in the file called:" - Write-Host $foldersToMailDisableFile -ForegroundColor Green - Write-Host "After confirming the accuracy of the results, you can mail-disable them with the following command:" - Write-Host "Get-Content `"$foldersToMailDisableFile`" | % { Set-PublicFolder `$_ -MailEnabled `$false }" -ForegroundColor Green +if (Test-Path $ResultsFile) { + $directory = [System.IO.Path]::GetDirectoryName($ResultsFile) + $fileName = [System.IO.Path]::GetFileNameWithoutExtension($ResultsFile) + $timeString = (Get-Item $ResultsFile).LastWriteTime.ToString("yyMMdd-HHmm") + Move-Item -Path $ResultsFile -Destination (Join-Path $directory "$($fileName)-$timeString.csv") } -if ($badMailEnabled.MailPublicFoldersToDelete.Count -gt 0) { - $mailPublicFoldersToDeleteFile = Join-Path $PSScriptRoot "MailPublicFolderOrphans.txt" - Set-Content -Path $mailPublicFoldersToDeleteFile -Value $badMailEnabled.MailPublicFoldersToDelete - - Write-Host - Write-Host $badMailEnabled.MailPublicFoldersToDelete.Count "MailPublicFolders are orphans and should be deleted. They exist in Active Directory" - Write-Host "but are not linked to any public folder. These are listed in a file called:" - Write-Host $mailPublicFoldersToDeleteFile -ForegroundColor Green - Write-Host "After confirming the accuracy of the results, you can delete them with the following command:" - Write-Host "Get-Content `"$mailPublicFoldersToDeleteFile`" | % { `$folder = ([ADSI](`"LDAP://`$_`")); `$parent = ([ADSI]`"`$(`$folder.Parent)`"); `$parent.Children.Remove(`$folder) }" -ForegroundColor Green +if ($folderData.Errors.Count -gt 0) { + $folderData.Errors | Export-Csv $ResultsFile -NoTypeInformation } -if ($badMailEnabled.MailPublicFolderDuplicates.Count -gt 0) { - $mailPublicFolderDuplicatesFile = Join-Path $PSScriptRoot "MailPublicFolderDuplicates.txt" - Set-Content -Path $mailPublicFolderDuplicatesFile -Value $badMailEnabled.MailPublicFolderDuplicates +if ("Dumpsters" -in $Tests) { + Write-Progress @progressParams -Status "Step 2 of 6" - Write-Host - Write-Host $badMailEnabled.MailPublicFolderDuplicates.Count "MailPublicFolders are duplicates and should be deleted. They exist in Active Directory" - Write-Host "and point to a valid folder, but that folder points to some other directory object." - Write-Host "These are listed in a file called:" - Write-Host $mailPublicFolderDuplicatesFile -ForegroundColor Green - Write-Host "After confirming the accuracy of the results, you can delete them with the following command:" - Write-Host "Get-Content `"$mailPublicFolderDuplicatesFile`" | % { `$folder = ([ADSI](`"LDAP://`$_`")); `$parent = ([ADSI]`"`$(`$folder.Parent)`"); `$parent.Children.Remove(`$folder) }" -ForegroundColor Green - - if ($badMailEnabled.EmailAddressMergeCommands.Count -gt 0) { - $emailAddressMergeScriptFile = Join-Path $PSScriptRoot "AddAddressesFromDuplicates.ps1" - Set-Content -Path $emailAddressMergeScriptFile -Value $badMailEnabled.EmailAddressMergeCommands - Write-Host "The duplicates we are deleting contain email addresses that might still be in use." - Write-Host "To preserve these, we generated a script that will add these to the linked objects for those folders." - Write-Host "After deleting the duplicate objects using the command above, run the script as follows to" - Write-Host "populate these addresses:" - Write-Host ".\$emailAddressMergeScriptFile" -ForegroundColor Green - } + $badDumpsters = Test-DumpsterMapping -FolderData $folderData + $badDumpsters | Export-Csv $ResultsFile -NoTypeInformation -Append } -if ($badMailEnabled.MailDisabledWithProxyGuid.Count -gt 0) { - $mailDisabledWithProxyGuidFile = Join-Path $PSScriptRoot "MailDisabledWithProxyGuid.txt" - Set-Content -Path $mailDisabledWithProxyGuidFile -Value $badMailEnabled.MailDisabledWithProxyGuid +if ("Limits" -in $Tests) { + Write-Progress @progressParams -Status "Step 3 of 6" - Write-Host - Write-Host $badMailEnabled.MailDisabledWithProxyGuid.Count "public folders have proxy GUIDs even though the folders are mail-disabled." - Write-Host "These folders should be mail-enabled. They can be mail-disabled again afterwards if desired." - Write-Host "To mail-enable these folders, run:" - Write-Host "Get-Content `"$mailDisabledWithProxyGuidFile`" | % { Enable-MailPublicFolder `$_ }" -ForegroundColor Green + # This test emits results in a weird order, so sort them. + $limitsExceeded = Test-FolderLimit -FolderData $folderData | Sort-Object FolderIdentity + $limitsExceeded | Export-Csv $ResultsFile -NoTypeInformation -Append } -if ($badMailEnabled.MailPublicFoldersDisconnected.Count -gt 0) { - $mailPublicFoldersDisconnectedFile = Join-Path $PSScriptRoot "MailPublicFoldersDisconnected.txt" - Set-Content -Path $mailPublicFoldersDisconnectedFile -Value $badMailEnabled.MailPublicFoldersDisconnected +if ("Names" -in $Tests) { + Write-Progress @progressParams -Status "Step 4 of 6" - Write-Host - Write-Host $badMailEnabled.MailPublicFoldersDisconnected.Count "MailPublicFolders are disconnected from their folders. This means they exist in" - Write-Host "Active Directory and the folders are probably functioning as mail-enabled folders," - Write-Host "even while the properties of the public folders themselves say they are not mail-enabled." - Write-Host "This can be complex to fix. Either the directory object should be deleted, or the public folder" - Write-Host "should be mail-enabled, or both. These directory objects are listed in a file called:" - Write-Host $mailPublicFoldersDisconnectedFile -ForegroundColor Green + $badNames = Test-FolderName -FolderData $folderData + $badNames | Export-Csv $ResultsFile -NoTypeInformation -Append } -if ($badDumpsters.Count -gt 0) { - $badDumpsterFile = Join-Path $PSScriptRoot "BadDumpsterMappings.txt" - Set-Content -Path $badDumpsterFile -Value $badDumpsters +if ("MailEnabled" -in $Tests) { + Write-Progress @progressParams -Status "Step 5 of 6" - Write-Host - Write-Host $badDumpsters.Count "folders have invalid dumpster mappings. These folders are listed in" - Write-Host "the following file:" - Write-Host $badDumpsterFile -ForegroundColor Green - Write-Host "The -ExcludeDumpsters switch can be used to skip these folders during migration, or the" - Write-Host "folders can be deleted." + $badMailEnabled = Test-MailEnabledFolder -FolderData $folderData + $badMailEnabled | Export-Csv $ResultsFile -NoTypeInformation -Append } -if ($limitsExceeded.ChildCount.Count -gt 0) { - $tooManyChildFoldersFile = Join-Path $PSScriptRoot "TooManyChildFolders.txt" - Set-Content -Path $tooManyChildFoldersFile -Value $limitsExceeded.ChildCount +if ("Permissions" -in $Tests) { + Write-Progress @progressParams -Status "Step 6 of 6" - Write-Host - Write-Host $limitsExceeded.ChildCount.Count "folders have exceeded the child folder limit of 10,000. These folders are" - Write-Host "listed in the following file:" - Write-Host $tooManyChildFoldersFile -ForegroundColor Green - Write-Host "Under each of the listed folders, child folders should be relocated or deleted to reduce this number." + $badPermissions = Test-Permission -FolderData $folderData + $badPermissions | Export-Csv $ResultsFile -NoTypeInformation -Append } -if ($limitsExceeded.FolderPathDepth.Count -gt 0) { - $pathTooDeepFile = Join-Path $PSScriptRoot "PathTooDeep.txt" - Set-Content -Path $pathTooDeepFile -Value $limitsExceeded.FolderPathDepth - - Write-Host - Write-Host $limitsExceeded.FolderPathDepth.Count "folders have exceeded the path depth limit of 299. These folders are" - Write-Host "listed in the following file:" - Write-Host $pathTooDeepFile -ForegroundColor Green - Write-Host "These folders should be relocated to reduce the path depth, or deleted." -} - -if ($limitsExceeded.ItemCount.Count -gt 0) { - $tooManyItemsFile = Join-Path $PSScriptRoot "TooManyItems.txt" - Set-Content -Path $tooManyItemsFile -Value $limitsExceeded.ItemCount - - Write-Host - Write-Host $limitsExceeded.ItemCount.Count "folders exceed the maximum of 1 million items. These folders are listed" - Write-Host "in the following file:" - Write-Host $tooManyItemsFile - Write-Host "In each of these folders, items should be deleted to reduce the item count." -} - -if ($badPermissions.Count -gt 0) { - $badPermissionsFile = Join-Path $PSScriptRoot "InvalidPermissions.csv" - $badPermissions | Export-Csv -Path $badPermissionsFile -NoTypeInformation - - Write-Host - Write-Host $badPermissions.Count "invalid permissions were found. These are listed in the following CSV file:" - Write-Host $badPermissionsFile -ForegroundColor Green - Write-Host "The invalid permissions can be removed using the RemoveInvalidPermissions switch as follows:" - Write-Host ".\SourceSideValidations.ps1 -RemoveInvalidPermissions" -ForegroundColor Green -} +# Output the results -$folderCountMigrationLimit = 250000 +$badDumpsters | Write-TestDumpsterMappingResult +$limitsExceeded | Write-TestFolderLimitResult +$badNames | Write-TestFolderNameResult +$badMailEnabled | Write-TestMailEnabledFolderResult +$badPermissions | Write-TestPermissionResult -if ($folderData.IpmSubtree.Count -gt $folderCountMigrationLimit) { - Write-Host - Write-Host "There are $($folderData.IpmSubtree.Count) public folders in the hierarchy. This exceeds" - Write-Host "the supported migration limit of $folderCountMigrationLimit for Exchange Online. The number" - Write-Host "of public folders must be reduced prior to migrating to Exchange Online." -} elseif ($folderData.IpmSubtree.Count * 2 -gt $folderCountMigrationLimit) { - Write-Host - Write-Host "There are $($folderData.IpmSubtree.Count) public folders in the hierarchy. Because each of these" - Write-Host "has a dumpster folder, the total number of folders to migrate will be $($folderData.IpmSubtree.Count * 2)." - Write-Host "This exceeds the supported migration limit of $folderCountMigrationLimit for Exchange Online." - Write-Host "New-MigrationBatch can be run with the -ExcludeDumpsters switch to skip the dumpster" - Write-Host "folders, or public folders may be deleted to reduce the number of folders." -} +Write-Host +Write-Host "Validation results were written to file:" +Write-Host $ResultsFile -ForegroundColor Green $private:endTime = Get-Date diff --git a/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/AllFunctions.ps1 b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/AllFunctions.ps1 new file mode 100644 index 0000000000..9fc5ea65ca --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/AllFunctions.ps1 @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\Test-DumpsterMapping.ps1 +. $PSScriptRoot\Write-TestDumpsterMappingResult.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Test-DumpsterMapping.ps1 b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Test-DumpsterMapping.ps1 new file mode 100644 index 0000000000..01adb44746 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Test-DumpsterMapping.ps1 @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\..\New-TestResult.ps1 + +function Test-DumpsterMapping { + [CmdletBinding()] + [OutputType([System.Object[]])] + param ( + [Parameter()] + [PSCustomObject] + $FolderData + ) + + begin { + function Test-DumpsterValid { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter()] + [PSCustomObject] + $Folder, + + [Parameter()] + [PSCustomObject] + $FolderData + ) + + begin { + $valid = $true + } + + process { + $dumpster = $FolderData.NonIpmEntryIdDictionary[$Folder.DumpsterEntryId] + + if ($null -eq $dumpster -or + (-not $dumpster.Identity.StartsWith("\NON_IPM_SUBTREE\DUMPSTER_ROOT", "OrdinalIgnoreCase")) -or + $dumpster.DumpsterEntryId -ne $Folder.EntryId) { + + $valid = $false + } + } + + end { + return $valid + } + } + + function NewTestDumpsterMappingResult { + [CmdletBinding()] + param ( + [Parameter(Position = 0)] + [object] + $Folder + ) + + process { + $params = @{ + TestName = "DumpsterMapping" + ResultType = "BadDumpsterMapping" + Severity = "Error" + FolderIdentity = $Folder.Identity + FolderEntryId = $Folder.EntryId + } + + New-TestResult @params + } + } + + $startTime = Get-Date + $progressCount = 0 + $sw = New-Object System.Diagnostics.Stopwatch + $sw.Start() + $progressParams = @{ + Activity = "Checking dumpster mappings" + Id = 2 + ParentId = 1 + } + } + + process { + $FolderData.IpmSubtree | ForEach-Object { + $progressCount++ + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount -PercentComplete ($progressCount * 100 / $FolderData.IpmSubtree.Count) + } + + if (-not (Test-DumpsterValid $_ $FolderData)) { + NewTestDumpsterMappingResult $_ + } + } + + Write-Progress @progressParams -Status "Checking EFORMS dumpster mappings" + + $FolderData.NonIpmSubtree | Where-Object { $_.Identity -like "\NON_IPM_SUBTREE\EFORMS REGISTRY\*" } | ForEach-Object { + if (-not (Test-DumpsterValid $_ $FolderData)) { + NewTestDumpsterMappingResult $_ + } + } + } + + end { + Write-Progress @progressParams -Completed + + $params = @{ + TestName = "DumpsterMapping" + ResultType = "Duration" + Severity = "Information" + ResultData = ((Get-Date) - $startTime) + } + + New-TestResult @params + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Write-TestDumpsterMappingResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Write-TestDumpsterMappingResult.ps1 new file mode 100644 index 0000000000..a20029ab1b --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/DumpsterMapping/Write-TestDumpsterMappingResult.ps1 @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Write-TestDumpsterMappingResult { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $badDumpsters = New-Object System.Collections.ArrayList + } + + process { + if ($TestResult.TestName -eq "DumpsterMapping" -and $TestResult.ResultType -eq "BadDumpsterMapping") { + $badDumpsters += $TestResult + } + } + + end { + if ($badDumpsters.Count -gt 0) { + Write-Host + Write-Host $badDumpsters.Count "folders have invalid dumpster mappings. These folders" + Write-Host "are shown in the results CSV with a result type of BadDumpsterMapping." + Write-Host "The -ExcludeDumpsters switch can be used to skip these folders during migration, or the" + Write-Host "folders can be deleted." + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/Limit/AllFunctions.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Limit/AllFunctions.ps1 new file mode 100644 index 0000000000..5fb3a96a07 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Limit/AllFunctions.ps1 @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\Test-FolderLimit.ps1 +. $PSScriptRoot\Write-TestFolderLimitResult.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Tests/Limit/Test-FolderLimit.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Limit/Test-FolderLimit.ps1 new file mode 100644 index 0000000000..3f5d9e1002 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Limit/Test-FolderLimit.ps1 @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\..\New-TestResult.ps1 + +function Test-FolderLimit { + <# + .SYNOPSIS + Flags folders that exceed the child count limit, depth limit, + or item limit. + #> + [CmdletBinding()] + param ( + [Parameter()] + [PSObject] + $FolderData + ) + + begin { + $startTime = Get-Date + $progressCount = 0 + $sw = New-Object System.Diagnostics.Stopwatch + $sw.Start() + $progressParams = @{ + Activity = "Checking limits" + Id = 2 + ParentId = 1 + } + $testResultParams = @{ + TestName = "Limit" + Severity = "Error" + } + $folderCountMigrationLimit = 250000 + $aggregateChildItemCounts = @{} + } + + process { + # We start from the deepest folders and work upwards so we can calculate the aggregate child + # counts in one pass + foreach ($folder in ($FolderData.IpmSubtree | Sort-Object FolderPathDepth -Descending)) { + $progressCount++ + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status $progressCount -PercentComplete ($progressCount * 100 / $FolderData.IpmSubtree.Count) + } + + [int]$itemCount = $FolderData.ItemCountDictionary[$folder.EntryId] + + $parent = $FolderData.EntryIdDictionary[$folder.ParentEntryId] + if ($null -ne $parent) { + $aggregateChildItemCounts[$parent.EntryId] += $itemCount + } + + if ($itemCount -lt 1 -and $aggregateChildItemCounts[$folder.EntryId] -lt 1 -and $folder.FolderPathDepth -gt 0) { + $emptyFolderInformation = @{ + TestName = "Limit" + Severity = "Information" + ResultType = "EmptyFolder" + FolderIdentity = $folder.Identity.ToString() + FolderEntryId = $folder.EntryId.ToString() + } + New-TestResult @emptyFolderInformation + } + + if ($FolderData.ParentEntryIdCounts[$folder.EntryId] -gt 10000) { + $testResultParams.ResultType = "ChildCount" + $testResultParams.FolderIdentity = $folder.Identity.ToString() + $testResultParams.FolderEntryId = $folder.EntryId.ToString() + New-TestResult @testResultParams + } + + if ($folder.FolderPathDepth -gt 299) { + $testResultParams.ResultType = "FolderPathDepth" + $testResultParams.FolderIdentity = $folder.Identity.ToString() + $testResultParams.FolderEntryId = $folder.EntryId.ToString() + New-TestResult @testResultParams + } + + if ($itemCount -gt 1000000) { + $testResultParams.ResultType = "ItemCount" + $testResultParams.FolderIdentity = $folder.Identity.ToString() + $testResultParams.FolderEntryId = $folder.EntryId.ToString() + New-TestResult @testResultParams + } + } + + if ($folderData.IpmSubtree.Count -gt $folderCountMigrationLimit) { + $testResultParams.ResultType = "HierarchyCount" + $testResultParams.FolderIdentity = "" + $testResultParams.FolderEntryId = "" + $testResultParams.ResultData = $folderData.IpmSubtree.Count + New-TestResult @testResultParams + } elseif ($folderData.IpmSubtree.Count * 2 -gt $folderCountMigrationLimit) { + $testResultParams.ResultType = "HierarchyAndDumpsterCount" + $testResultParams.FolderIdentity = "" + $testResultParams.FolderEntryId = "" + $testResultParams.ResultData = $folderData.IpmSubtree.Count + New-TestResult @testResultParams + } + } + + end { + Write-Progress @progressParams -Completed + + $params = @{ + TestName = $testResultParams.TestName + ResultType = "Duration" + Severity = "Information" + FolderIdentity = "" + FolderEntryId = "" + ResultData = ((Get-Date) - $startTime) + } + + New-TestResult @params + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/Limit/Write-TestFolderLimitResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Limit/Write-TestFolderLimitResult.ps1 new file mode 100644 index 0000000000..3307808958 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Limit/Write-TestFolderLimitResult.ps1 @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Write-TestFolderLimitResult { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $childCount = 0 + $folderPathDepth = 0 + $itemCount = 0 + $emptyFolders = 0 + $hierarchyCount = $null + $hierarchyAndDumpsterCount = $null + $folderCountMigrationLimit = 250000 + } + + process { + if ($TestResult.TestName -eq "Limit") { + switch ($TestResult.ResultType) { + "EmptyFolder" { $emptyFolders++ } + "ChildCount" { $childCount++ } + "FolderPathDepth" { $folderPathDepth++ } + "ItemCount" { $itemCount++ } + "HierarchyCount" { $hierarchyCount = $TestResult.ResultData } + "HierarchyAndDumpsterCount" { $hierarchyAndDumpsterCount = $TestResult.ResultData } + } + } + } + + end { + if ($childCount -gt 0) { + Write-Host + Write-Host $childCount "folders have exceeded the child folder limit of 10,000." + Write-Host "These folders are shown in the results CSV with a result type of ChildCount." + Write-Host "Under each of the listed folders, child folders should be relocated or deleted to reduce this number." + } + + if ($folderPathDepth -gt 0) { + Write-Host + Write-Host $folderPathDepth "folders have exceeded the path depth limit of 299." + Write-Host "These folders are shown in the results CSV with a result type of FolderPathDepth." + Write-Host "These folders should be relocated to reduce the path depth, or deleted." + } + + if ($itemCount -gt 0) { + Write-Host + Write-Host $itemCount "folders exceed the maximum of 1 million items." + Write-Host "These folders are shown in the results CSV with a result type of ItemCount." + Write-Host "In each of these folders, items should be deleted to reduce the item count." + } + + if ($null -ne $hierarchyCount) { + Write-Host + Write-Host "There are $hierarchyCount public folders in the hierarchy. This exceeds" + Write-Host "the supported migration limit of $folderCountMigrationLimit for Exchange Online. The number" + Write-Host "of public folders must be reduced prior to migrating to Exchange Online." + } + + if ($null -ne $hierarchyAndDumpsterCount) { + Write-Host + Write-Host "There are $hierarchyAndDumpsterCount public folders in the hierarchy. Because each of these" + Write-Host "has a dumpster folder, the total number of folders to migrate will be twice as many." + Write-Host "This exceeds the supported migration limit of $folderCountMigrationLimit for Exchange Online." + Write-Host "New-MigrationBatch can be run with the -ExcludeDumpsters switch to skip the dumpster" + Write-Host "folders, or public folders may be deleted to reduce the number of folders." + } + + if ($emptyFolders -gt 0) { + Write-Host + Write-Host $emptyFolders "folders contain no items and have only empty subfolders." + Write-Host "These folders are shown in the results CSV with a result type of EmptyFolder." + Write-Host "These will not cause a migration issue, but they may be pruned if desired." + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/AllFunctions.ps1 b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/AllFunctions.ps1 new file mode 100644 index 0000000000..46dec529a5 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/AllFunctions.ps1 @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\Test-MailEnabledFolder.ps1 +. $PSScriptRoot\Write-TestMailEnabledFolderResult.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Get-BadMailEnabledFolder.ps1 b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Test-MailEnabledFolder.ps1 similarity index 50% rename from PublicFolders/src/SourceSideValidations/Get-BadMailEnabledFolder.ps1 rename to PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Test-MailEnabledFolder.ps1 index 3cabf6e7aa..da96be1e61 100644 --- a/PublicFolders/src/SourceSideValidations/Get-BadMailEnabledFolder.ps1 +++ b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Test-MailEnabledFolder.ps1 @@ -1,7 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -function Get-BadMailEnabledFolder { +. $PSScriptRoot\..\New-TestResult.ps1 + +function Test-MailEnabledFolder { [CmdletBinding()] [OutputType([PSCustomObject])] param ( @@ -11,6 +13,57 @@ function Get-BadMailEnabledFolder { ) begin { + function GetCommandToMergeEmailAddresses($publicFolder, $orphanedMailPublicFolder) { + $linkedMailPublicFolder = Get-PublicFolder $publicFolder.Identity | Get-MailPublicFolder + $emailAddressesOnGoodObject = @($linkedMailPublicFolder.EmailAddresses | Where-Object { $_.ToString().StartsWith("smtp:", "OrdinalIgnoreCase") } | ForEach-Object { $_.ToString().Substring($_.ToString().IndexOf(':') + 1) }) + $emailAddressesOnBadObject = @($orphanedMailPublicFolder.EmailAddresses | Where-Object { $_.ToString().StartsWith("smtp:", "OrdinalIgnoreCase") } | ForEach-Object { $_.ToString().Substring($_.ToString().IndexOf(':') + 1) }) + $emailAddressesToAdd = $emailAddressesOnBadObject | Where-Object { -not $emailAddressesOnGoodObject.Contains($_) } + $emailAddressesToAdd = $emailAddressesToAdd | ForEach-Object { "`"" + $_ + "`"" } + if ($emailAddressesToAdd.Count -gt 0) { + $emailAddressesToAddString = [string]::Join(",", $emailAddressesToAdd) + $command = "Get-PublicFolder `"$($publicFolder.Identity)`" | Get-MailPublicFolder | Set-MailPublicFolder -EmailAddresses @{add=$emailAddressesToAddString}" + return $command + } else { + return $null + } + } + + function NewTestMailEnabledFolderResult { + [CmdletBinding()] + param ( + [Parameter(Position = 0)] + [string] + $Identity, + + [Parameter(Position = 1)] + [string] + $EntryId, + + [Parameter(Position = 2)] + [ValidateSet("Duration", "MailEnabledSystemFolder", "MailEnabledWithNoADObject", "MailDisabledWithProxyGuid", "OrphanedMPF", "OrphanedMPFDuplicate", "OrphanedMPFDisconnected")] + [string] + $ResultType, + + [Parameter(Position = 3)] + [string] + $ResultData + ) + + $params = @{ + TestName = "MailEnabledFolder" + ResultType = $ResultType + Severity = "Error" + FolderIdentity = $Identity + FolderEntryId = $EntryId + } + + if ($null -ne $ResultData) { + $params.ResultData = $ResultData + } + + New-TestResult @params + } + $startTime = Get-Date $progressCount = 0 $sw = New-Object System.Diagnostics.Stopwatch @@ -23,12 +76,20 @@ function Get-BadMailEnabledFolder { } process { - $nonIpmSubtreeMailEnabled = @($FolderData.NonIpmSubtree | Where-Object { $_.MailEnabled -eq $true }) + $FolderData.NonIpmSubtree | Where-Object { $_.MailEnabled -eq $true } | ForEach-Object { NewTestMailEnabledFolderResult -Identity $_.Identity -EntryId $_.EntryId -ResultType "MailEnabledSystemFolder" } $ipmSubtreeMailEnabled = @($FolderData.IpmSubtree | Where-Object { $_.MailEnabled -eq $true }) $mailDisabledWithProxyGuid = @($FolderData.IpmSubtree | Where-Object { $_.MailEnabled -ne $true -and -not [string]::IsNullOrEmpty($_.MailRecipientGuid) -and [Guid]::Empty -ne $_.MailRecipientGuid } | ForEach-Object { $_.Identity.ToString() }) + $mailDisabledWithProxyGuid | ForEach-Object { + $params = @{ + Identity = $_.Identity + EntryId = $_.EntryId + ResultType = "MailDisabledWithProxyGuid" + } + + NewTestMailEnabledFolderResult @params + } - $mailEnabledFoldersWithNoADObject = @() $mailPublicFoldersLinked = New-Object 'System.Collections.Generic.Dictionary[string, object]' $progressParams.CurrentOperation = "Checking for missing AD objects" $startTimeForThisCheck = Get-Date @@ -42,7 +103,13 @@ function Get-BadMailEnabledFolder { } $result = Get-MailPublicFolder $ipmSubtreeMailEnabled[$i].Identity -ErrorAction SilentlyContinue if ($null -eq $result) { - $mailEnabledFoldersWithNoADObject += $ipmSubtreeMailEnabled[$i] + $params = @{ + Identity = $ipmSubtreeMailEnabled[$i].Identity + EntryId = $ipmSubtreeMailEnabled[$i].EntryId + ResultType = "MailEnabledWithNoADObject" + } + + NewTestMailEnabledFolderResult @params } else { $guidString = $result.Guid.ToString() if (-not $mailPublicFoldersLinked.ContainsKey($guidString)) { @@ -86,11 +153,6 @@ function Get-BadMailEnabledFolder { $byPartialEntryId = New-Object 'System.Collections.Generic.Dictionary[string, object]' $FolderData.IpmSubtree | ForEach-Object { $byPartialEntryId.Add($_.EntryId.ToString().Substring(44), $_) } - - $orphanedMPFsThatPointToAMailDisabledFolder = @() - $orphanedMPFsThatPointToAMailEnabledFolder = @() - $orphanedMPFsThatPointToNothing = @() - $emailAddressMergeCommands = @() $progressParams.CurrentOperation = "Checking for orphans that point to a valid folder" for ($i = 0; $i -lt $orphanedMailPublicFolders.Count; $i++) { if ($sw.ElapsedMilliseconds -gt 1000) { @@ -107,13 +169,23 @@ function Get-BadMailEnabledFolder { if ($pf.MailEnabled -eq $true) { $command = GetCommandToMergeEmailAddresses $pf $thisMPF - if ($null -ne $command) { - $emailAddressMergeCommands += $command + + $params = @{ + Identity = $thisMPF.DistinguishedName.Replace("/", "\/") + EntryId = $pf.EntryId + ResultType = "OrphanedMPFDuplicate" + ResultData = $command } - $orphanedMPFsThatPointToAMailEnabledFolder += $thisMPF + NewTestMailEnabledFolderResult @params } else { - $orphanedMPFsThatPointToAMailDisabledFolder += $thisMPF + $params = @{ + Identity = $thisMPF.DistinguishedName.Replace("/", "\/") + EntryId = $pf.EntryId + ResultType = "OrphanedMPFDisconnected" + } + + NewTestMailEnabledFolderResult @params } continue @@ -124,58 +196,51 @@ function Get-BadMailEnabledFolder { if ($pf.MailEnabled -eq $true) { $command = GetCommandToMergeEmailAddresses $pf $thisMPF + + $params = @{ + Identity = $thisMPF.DistinguishedName.Replace("/", "\/") + EntryId = $pf.EntryId + ResultType = "OrphanedMPFDuplicate" + } + if ($null -ne $command) { - $emailAddressMergeCommands += $command + $params.ResultData = $command } - $orphanedMPFsThatPointToAMailEnabledFolder += $thisMPF + NewTestMailEnabledFolderResult @params } else { - $orphanedMPFsThatPointToAMailDisabledFolder += $thisMPF + $params = @{ + Identity = $thisMPF.DistinguishedName.Replace("/", "\/") + EntryId = $pf.EntryId + ResultType = "OrphanedMPFDisconnected" + } + + NewTestMailEnabledFolderResult @params } } else { - $orphanedMPFsThatPointToNothing += $thisMPF + $params = @{ + Identity = $thisMPF.DistinguishedName.Replace("/", "\/") + EntryId = "" + ResultType = "OrphanedMPF" + } + + NewTestMailEnabledFolderResult @params } } } end { - Write-Verbose "$($ipmSubtreeMailEnabled.Count) public folders are mail-enabled." - Write-Verbose "$($mailPublicFoldersLinked.Keys.Count) folders are mail-enabled and are properly linked to an existing AD object." - Write-Verbose "$($nonIpmSubtreeMailEnabled.Count) System folders are mail-enabled." - Write-Verbose "$($mailEnabledFoldersWithNoADObject.Count) folders are mail-enabled with no AD object." - Write-Verbose "$($orphanedMailPublicFolders.Count) MailPublicFolders are orphaned." - Write-Verbose "$($orphanedMPFsThatPointToAMailEnabledFolder.Count) of those orphans point to mail-enabled folders that point to some other object." - Write-Verbose "$($orphanedMPFsThatPointToAMailDisabledFolder.Count) of those orphans point to mail-disabled folders." - - $foldersToMailDisable = @() - $nonIpmSubtreeMailEnabled | ForEach-Object { $foldersToMailDisable += $_.Identity.ToString() } - $mailEnabledFoldersWithNoADObject | ForEach-Object { $foldersToMailDisable += $_.Identity } - - [PSCustomObject]@{ - FoldersToMailDisable = $foldersToMailDisable - MailPublicFoldersToDelete = $orphanedMPFsThatPointToNothing | ForEach-Object { $_.DistinguishedName.Replace("/", "\/") } - MailPublicFolderDuplicates = $orphanedMPFsThatPointToAMailEnabledFolder | ForEach-Object { $mailPublicFolderDuplicates += $_.DistinguishedName } - EmailAddressMergeCommands = $emailAddressMergeCommands - MailDisabledWithProxyGuid = $mailDisabledWithProxyGuid - MailPublicFoldersDisconnected = $orphanedMPFsThatPointToAMailDisabledFolder | ForEach-Object { $mailPublicFoldersDisconnected += $_.DistinguishedName } - } - - Write-Host "Get-BadMailEnabledFolder duration" ((Get-Date) - $startTime) Write-Progress @progressParams -Completed - } -} -function GetCommandToMergeEmailAddresses($publicFolder, $orphanedMailPublicFolder) { - $linkedMailPublicFolder = Get-PublicFolder $publicFolder.Identity | Get-MailPublicFolder - $emailAddressesOnGoodObject = @($linkedMailPublicFolder.EmailAddresses | Where-Object { $_.ToString().StartsWith("smtp:", "OrdinalIgnoreCase") } | ForEach-Object { $_.ToString().Substring($_.ToString().IndexOf(':') + 1) }) - $emailAddressesOnBadObject = @($orphanedMailPublicFolder.EmailAddresses | Where-Object { $_.ToString().StartsWith("smtp:", "OrdinalIgnoreCase") } | ForEach-Object { $_.ToString().Substring($_.ToString().IndexOf(':') + 1) }) - $emailAddressesToAdd = $emailAddressesOnBadObject | Where-Object { -not $emailAddressesOnGoodObject.Contains($_) } - $emailAddressesToAdd = $emailAddressesToAdd | ForEach-Object { "`"" + $_ + "`"" } - if ($emailAddressesToAdd.Count -gt 0) { - $emailAddressesToAddString = [string]::Join(",", $emailAddressesToAdd) - $command = "Get-PublicFolder `"$($publicFolder.Identity)`" | Get-MailPublicFolder | Set-MailPublicFolder -EmailAddresses @{add=$emailAddressesToAddString}" - return $command - } else { - return $null + $params = @{ + TestName = "MailEnabledFolder" + ResultType = "Duration" + Severity = "Information" + FolderIdentity = "" + FolderEntryId = "" + ResultData = ((Get-Date) - $startTime) + } + + New-TestResult @params } } diff --git a/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Write-TestMailEnabledFolderResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Write-TestMailEnabledFolderResult.ps1 new file mode 100644 index 0000000000..06904c4bda --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/MailEnabledFolder/Write-TestMailEnabledFolderResult.ps1 @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Write-TestMailEnabledFolderResult { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $mailEnabledSystemFolder = 0 + $mailEnabledWithNoADObject = 0 + $mailDisabledWithProxyGuid = 0 + $orphanedMPF = 0 + $orphanedMPFDuplicate = 0 + $orphanedMPFDisconnected = 0 + } + + process { + if ($TestResult.TestName -eq "MailEnabledFolder") { + switch ($TestResult.ResultType) { + "MailEnabledSystemFolder" { $mailEnabledSystemFolder++ } + "MailEnabledWithNoADObject" { $mailEnabledWithNoADObject++ } + "MailDisabledWithProxyGuid" { $mailDisabledWithProxyGuid++ } + "OrphanedMPF" { $orphanedMPF++ } + "OrphanedMPFDuplicate" { $orphanedMPFDuplicate++ } + "OrphanedMPFDisconnected" { $orphanedMPFDisconnected++ } + } + } + } + + end { + if ($mailEnabledSystemFolder -gt 0) { + Write-Host + Write-Host $mailEnabledSystemFolder "system folders are mail-enabled. These folders should be mail-disabled." + Write-Host "These folders are shown in the results CSV with a result type of MailEnabledSystemFolder." + Write-Host "After confirming the accuracy of the results, you can mail-disable them with the following command:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq MailEnabledSystemFolder | % { Disable-MailPublicFolder $_.FolderIdentity }" -ForegroundColor Green + } + + if ($mailEnabledWithNoADObject -gt 0) { + Write-Host + Write-Host $mailEnabledWithNoADObject "folders are mail-enabled, but have no AD object. These folders should be mail-disabled." + Write-Host "These folders are shown in the results CSV with a result type of MailEnabledWithNoADObject." + Write-Host "After confirming the accuracy of the results, you can mail-disable them with the following command:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq MailEnabledWithNoADObject | % { Disable-MailPublicFolder $_.FolderIdentity }" -ForegroundColor Green + } + + if ($mailDisabledWithProxyGuid -gt 0) { + Write-Host + Write-Host $mailDisabledWithProxyGuid "folders are mail-disabled, but have proxy GUID values. These folders should be mail-enabled." + Write-Host "These folders are shown in the results CSV with a result type of MailDisabledWithProxyGuid." + Write-Host "After confirming the accuracy of the results, you can mail-enable them with the following command:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq MailDisabledWithProxyGuid | % { Enable-MailPublicFolder $_.FolderIdentity }" -ForegroundColor Green + } + + if ($orphanedMPF -gt 0) { + Write-Host + Write-Host $orphanedMPF "mail public folders are orphaned. They exist in Active Directory" + Write-Host "but are not linked to any public folder. Therefore, they should be deleted." + Write-Host "These folders are shown in the results CSV with a result type of OrphanedMPF." + Write-Host "After confirming the accuracy of the results, you can delete them manually," + Write-Host "or use a command like this:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq OrphanedMPF | % { `$folder = ([ADSI](`"LDAP://`$_`")); `$parent = ([ADSI]`"`$(`$folder.Parent)`"); `$parent.Children.Remove(`$folder) }" -ForegroundColor Green + } + + if ($orphanedMPFDuplicate -gt 0) { + Write-Host + Write-Host $orphanedMPFDuplicate "mail public folders point to public folders that point to a different directory object." + Write-Host "These folders are shown in the results CSV with a result type of OrphanedMPFDuplicate." + Write-Host "These should be deleted. Their email addresses may be merged onto the linked object." + Write-Host "After confirming the accuracy of the results, you can delete them manually," + Write-Host "or use a command like this:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq OrphanedMPFDuplicate | % { `$folder = ([ADSI](`"LDAP://`$(`$_.FolderIdentity)`")); `$parent = ([ADSI]`"`$(`$folder.Parent)`"); `$parent.Children.Remove(`$folder) }" -ForegroundColor Green + Write-Host "After these objects are deleted, the email addresses can be merged onto the linked objects:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq OrphanedMPFDuplicate | % { Invoke-Expression `$_.ResultData }" -ForegroundColor Green + } + + if ($orphanedMPFDisconnected -gt 0) { + Write-Host + Write-Host $orphanedMPFDisconnected "mail public folders point to public folders that are mail-disabled." + Write-Host "These require manual intervention. Either the directory object should be deleted, or the folder should be mail-enabled, or both." + Write-Host "Open the ValidationResults.csv and filter for ResultType of OrphanedMPFDisconnected to identify these folders." + Write-Host "The FolderIdentity provides the DN of the mail object. The FolderEntryId provides the EntryId of the folder." + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/Name/AllFunctions.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Name/AllFunctions.ps1 new file mode 100644 index 0000000000..245075fd6f --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Name/AllFunctions.ps1 @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\Test-FolderName.ps1 +. $PSScriptRoot\Write-TestFolderNameResult.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Get-LimitsExceeded.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Name/Test-FolderName.ps1 similarity index 50% rename from PublicFolders/src/SourceSideValidations/Get-LimitsExceeded.ps1 rename to PublicFolders/src/SourceSideValidations/Tests/Name/Test-FolderName.ps1 index e40ee19c3e..d4b28bf743 100644 --- a/PublicFolders/src/SourceSideValidations/Get-LimitsExceeded.ps1 +++ b/PublicFolders/src/SourceSideValidations/Tests/Name/Test-FolderName.ps1 @@ -1,12 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -function Get-LimitsExceeded { - <# - .SYNOPSIS - Flags folders that exceed the child count limit, depth limit, - or item limit. - #> +. $PSScriptRoot\..\New-TestResult.ps1 + +function Test-FolderName { [CmdletBinding()] param ( [Parameter()] @@ -17,18 +14,18 @@ function Get-LimitsExceeded { begin { $startTime = Get-Date $progressCount = 0 - $limitsExceeded = [PSCustomObject]@{ - ChildCount = @() - FolderPathDepth = @() - ItemCount = @() - } $sw = New-Object System.Diagnostics.Stopwatch $sw.Start() $progressParams = @{ - Activity = "Checking limits" + Activity = "Checking names" Id = 2 ParentId = 1 } + $testResultParams = @{ + TestName = "FolderName" + Severity = "Error" + ResultType = "SpecialCharacters" + } } process { @@ -39,23 +36,27 @@ function Get-LimitsExceeded { Write-Progress @progressParams -Status $progressCount -PercentComplete ($progressCount * 100 / $FolderData.IpmSubtree.Count) } - if ($FolderData.ParentEntryIdCounts[$_.EntryId] -gt 10000) { - $limitsExceeded.ChildCount += $_.Identity.ToString() - } - - if ([int]$_.FolderPathDepth -gt 299) { - $limitsExceeded.FolderPathDepth += $_.Identity.ToString() - } - - if ($_.ItemCount -gt 1000000) { - $limitsExceeded.ItemCount += $_.Identity.ToString() + if ($_.Name -match "@|/|\\") { + $testResultParams.FolderIdentity = $_.Identity.ToString() + $testResultParams.FolderEntryId = $_.EntryId.ToString() + $testResultParams.ResultData = $_.Name + New-TestResult @testResultParams } } } end { Write-Progress @progressParams -Completed - Write-Host "Get-LimitsExceeded duration" ((Get-Date) - $startTime) - return $limitsExceeded + + $params = @{ + TestName = $testResultParams.TestName + ResultType = "Duration" + Severity = "Information" + FolderIdentity = "" + FolderEntryId = "" + ResultData = ((Get-Date) - $startTime) + } + + New-TestResult @params } } diff --git a/PublicFolders/src/SourceSideValidations/Tests/Name/Write-TestFolderNameResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Name/Write-TestFolderNameResult.ps1 new file mode 100644 index 0000000000..39bb06fb44 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Name/Write-TestFolderNameResult.ps1 @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Write-TestFolderNameResult { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $badNames = New-Object System.Collections.ArrayList + } + + process { + if ($TestResult.TestName -eq "FolderName" -and $TestResult.ResultType -eq "SpecialCharacters") { + $badNames += $TestResult + } + } + + end { + if ($badNames.Count -gt 0) { + Write-Host + Write-Host $badNames.Count "folders have characters @, /, or \ in the folder name." + Write-Host "These are shown in the results CSV with a result type of SpecialCharacters." + Write-Host "These folders should be renamed prior to migrating. The following command" + Write-Host "can be used:" + Write-Host "Import-Csv .\ValidationResults.csv | ? ResultType -eq SpecialCharacters | % { Set-PublicFolder `$_.FolderEntryId -Name (`$_.ResultData -replace `"@|/|\\`", `" `").Trim() }" -ForegroundColor Green + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/New-TestResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/New-TestResult.ps1 new file mode 100644 index 0000000000..defed0fc12 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/New-TestResult.ps1 @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function New-TestResult { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'No state change.')] + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $TestName, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [string] + $ResultType, + + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)] + [ValidateSet("Information", "Warning", "Error")] + [string] + $Severity, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $FolderIdentity, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $FolderEntryId, + + [Parameter(ValueFromPipelineByPropertyName = $true)] + [string] + $ResultData + ) + + process { + [PSCustomObject]@{ + TestName = $TestName + ResultType = $ResultType + Severity = $Severity + FolderIdentity = $FolderIdentity + FolderEntryId = $FolderEntryId + ResultData = $ResultData + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Tests/Permission/AllFunctions.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Permission/AllFunctions.ps1 new file mode 100644 index 0000000000..df045b2ec7 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Permission/AllFunctions.ps1 @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +. $PSScriptRoot\Test-Permission.ps1 +. $PSScriptRoot\Test-PermissionJob.ps1 +. $PSScriptRoot\Write-TestPermissionResult.ps1 +. $PSScriptRoot\Remove-InvalidPermission.ps1 diff --git a/PublicFolders/src/SourceSideValidations/Tests/Permission/Remove-InvalidPermission.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Permission/Remove-InvalidPermission.ps1 new file mode 100644 index 0000000000..ac4edcfde4 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Permission/Remove-InvalidPermission.ps1 @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Remove-InvalidPermission { + [CmdletBinding(SupportsShouldProcess)] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $progressParams = @{ + Activity = "Repairing folder permissions" + } + + $progressCount = 0 + $entryIdsProcessed = New-Object 'System.Collections.Generic.HashSet[string]' + $badPermissions = New-Object System.Collections.ArrayList + + $sw = New-Object System.Diagnostics.Stopwatch + $sw.Start() + } + + process { + if ($TestResult.TestName -eq "Permission" -and $TestResult.ResultType -eq "BadPermission") { + [void]$badPermissions.Add($TestResult) + } + } + + end { + foreach ($result in $badPermissions) { + $progressCount++ + + if ($sw.ElapsedMilliseconds -gt 1000) { + $sw.Restart() + Write-Progress @progressParams -Status "$progressCount / $($badPermissions.Count)" -PercentComplete ($progressCount * 100 / $badPermissions.Count) -CurrentOperation $permission.Identity + } + + if ($entryIdsProcessed.Add($result.FolderEntryId)) { + $permsOnFolder = Get-PublicFolderClientPermission -Identity $result.FolderEntryId + foreach ($perm in $permsOnFolder) { + if ( + ($perm.User.DisplayName -ne "Default") -and + ($perm.User.DisplayName -ne "Anonymous") -and + ($null -eq $perm.User.ADRecipient) -and + ($perm.User.UserType -eq "Unknown") + ) { + if ($PSCmdlet.ShouldProcess("$($result.FolderIdentity)", "Remove $($perm.User.DisplayName)")) { + Write-Host "Removing $($perm.User.DisplayName) from folder $($result.FolderIdentity)" + $perm | Remove-PublicFolderClientPermission -Confirm:$false + } + } + } + } + } + } +} diff --git a/PublicFolders/src/SourceSideValidations/Get-BadPermission.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Permission/Test-Permission.ps1 similarity index 57% rename from PublicFolders/src/SourceSideValidations/Get-BadPermission.ps1 rename to PublicFolders/src/SourceSideValidations/Tests/Permission/Test-Permission.ps1 index 5ddbd11f4d..80fecf141f 100644 --- a/PublicFolders/src/SourceSideValidations/Get-BadPermission.ps1 +++ b/PublicFolders/src/SourceSideValidations/Tests/Permission/Test-Permission.ps1 @@ -1,7 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -function Get-BadPermission { +. $PSScriptRoot\Test-PermissionJob.ps1 +. $PSScriptRoot\..\New-TestResult.ps1 + +function Test-Permission { [CmdletBinding()] param ( [Parameter()] @@ -11,14 +14,13 @@ function Get-BadPermission { begin { $startTime = Get-Date - $badPermissions = @() } process { $folderData.IpmSubtreeByMailbox | ForEach-Object { $argumentList = $FolderData.MailboxToServerMap[$_.Name], $_.Name, $_.Group $name = $_.Name - $scriptBlock = ${Function:Get-BadPermissionJob} + $scriptBlock = ${Function:Test-BadPermissionJob} Add-JobQueueJob @{ ArgumentList = $argumentList Name = "$name Permissions Check" @@ -26,16 +28,19 @@ function Get-BadPermission { } } - $completedJobs = Wait-QueuedJob - foreach ($job in $completedJobs) { - if ($job.BadPermissions.Count -gt 0) { - $badPermissions = $badPermissions + $job.BadPermissions - } - } + Wait-QueuedJob } end { - Write-Host "Get-BadPermission duration" ((Get-Date) - $startTime) - return $badPermissions + $params = @{ + TestName = "Permission" + ResultType = "Duration" + Severity = "Information" + FolderIdentity = "" + FolderEntryId = "" + ResultData = ((Get-Date) - $startTime) + } + + New-TestResult @params } } diff --git a/PublicFolders/src/SourceSideValidations/Get-BadPermissionJob.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Permission/Test-PermissionJob.ps1 similarity index 72% rename from PublicFolders/src/SourceSideValidations/Get-BadPermissionJob.ps1 rename to PublicFolders/src/SourceSideValidations/Tests/Permission/Test-PermissionJob.ps1 index 27c354f57a..970fbe66d6 100644 --- a/PublicFolders/src/SourceSideValidations/Get-BadPermissionJob.ps1 +++ b/PublicFolders/src/SourceSideValidations/Tests/Permission/Test-PermissionJob.ps1 @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -function Get-BadPermissionJob { +function Test-BadPermissionJob { [CmdletBinding()] param ( [Parameter(Position = 0)] @@ -22,7 +22,6 @@ function Get-BadPermissionJob { Import-PSSession (New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "http://$Server/powershell" -Authentication Kerberos) | Out-Null $startTime = Get-Date $progressCount = 0 - $badPermissions = @() $sw = New-Object System.Diagnostics.Stopwatch $sw.Start() $progressParams = @{ @@ -49,10 +48,14 @@ function Get-BadPermissionJob { ($null -eq $_.User.ADRecipient) -and ($_.User.UserType.ToString() -eq "Unknown") ) { - $badPermissions += [PSCustomObject]@{ - Identity = $identity - EntryId = $entryId - User = $_.User.DisplayName + # We can't use New-TestResult here since we are inside a job + [PSCustomObject]@{ + TestName = "Permission" + ResultType = "BadPermission" + Severity = "Error" + FolderIdentity = $identity + FolderEntryId = $entryId + ResultData = $_.User.DisplayName } } } @@ -61,11 +64,13 @@ function Get-BadPermissionJob { end { Write-Progress @progressParams -Completed - $duration = ((Get-Date) - $startTime) - return [PSCustomObject]@{ - Count = $progressCount - Duration = $duration - BadPermissions = $badPermissions + [PSCustomObject]@{ + TestName = "Permission" + ResultType = "$Mailbox Duration" + Severity = "Information" + FolderIdentity = "" + FolderEntryId = "" + ResultData = ((Get-Date) - $startTime) } } } diff --git a/PublicFolders/src/SourceSideValidations/Tests/Permission/Write-TestPermissionResult.ps1 b/PublicFolders/src/SourceSideValidations/Tests/Permission/Write-TestPermissionResult.ps1 new file mode 100644 index 0000000000..7efa254248 --- /dev/null +++ b/PublicFolders/src/SourceSideValidations/Tests/Permission/Write-TestPermissionResult.ps1 @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +function Write-TestPermissionResult { + [CmdletBinding()] + param ( + [Parameter(ValueFromPipeline = $true)] + [object] + $TestResult + ) + + begin { + $badPermissionCount = 0 + } + + process { + if ($TestResult.TestName -eq "Permission" -and $TestResult.ResultType -eq "BadPermission") { + $badPermissionCount++ + } + } + + end { + if ($badPermissionCount -gt 0) { + Write-Host + Write-Host $badPermissionCount "invalid permissions were found." + Write-Host "These are shown in the results CSV with a result type of BadPermission." + Write-Host "The invalid permissions can be removed using the RemoveInvalidPermissions switch as follows:" + Write-Host ".\SourceSideValidations.ps1 -RemoveInvalidPermissions" -ForegroundColor Green + } + } +} diff --git a/docs/PublicFolders/SourceSideValidations.md b/docs/PublicFolders/SourceSideValidations.md index 6838eea043..f777f330d8 100644 --- a/docs/PublicFolders/SourceSideValidations.md +++ b/docs/PublicFolders/SourceSideValidations.md @@ -11,28 +11,92 @@ This script performs pre-migration public folder checks for Exchange 2013, 2016, ### Syntax -Typically, the script should be run with no parameters: - -`.\SourceSideValidations.ps1` +```powershell +SourceSideValidations.ps1 + [-StartFresh ] + [-SlowTraversal] + [-ResultsFile ] + [-SkipVersionCheck] + [-Tests ] + [] +SourceSideValidations.ps1 -RemoveInvalidPermissions + [-ResultsFile ] + [-SkipVersionCheck] + [] +SourceSideValidations.ps1 -SummarizePreviousResults + [-ResultsFile ] + [-SkipVersionCheck] + [] +``` ### Output -The script will generate one more of the following files, and it will display -examples that show how to use them. Examine the script output for those details. +The script will generate the following files. Usually the only one we care about is ValidationResults.csv. The others are purely for saving time on subsequent runs. File Name|Content|Use -|-|- IpmSubtree.csv|A subset of properties of all Public Folders|Running with -StartFresh $false loads this file instead of retrieving fresh data ItemCounts.csv|EntryID and item count of every folder|Running with -StartFresh $false loads this file instead of retrieving fresh data NonIpmSubtree.csv|A subset of properties of all System Folders|Running with -StartFresh $false loads this file instead of retrieving fresh data -FoldersToMailDisable.txt|Folders that should be mail-disabled, because they are system folders or because their mail objects are missing|Use with the command displayed in the script output to disable them -MailPublicFolderOrphans.txt|Mail objects that are not linked to any existing folder|Use with the command displayed in the script output to delete them -MailPublicFolderDuplicates.txt|Mail objects that point to folders which are linked to some other mail object|Use with the command displayed in the script output to delete them -AddAddressesFromDuplicates.ps1|Commands that add the email addresses from the folders listed in MailPublicFolderDuplicates.txt onto the mail objects currently linked to the folders|Run after deleting the duplicates to preserve the email addresses on the remaining valid mail object -MailDisabledWithProxyGuid.txt|Folders that are mail-disabled but have a mail object stamped on them|Pipe to Enable-MailPublicFolder using the syntax example shown in the script output to enable these -MailPublicFoldersDisconnected.txt|Mail objects that correspond to a valid, but mail-disabled, folder|These must be examined and corrected manually -BadDumpsterMappings.txt|Folders with invalid dumpster mappings|These folders can be deleted or the -ExcludeDumpsters switch can be used to skip the dumpsters during migration -TooManyChildFolders.txt|Folders that have too many child folders|Examine the list and manually reduce the number of child folders -PathTooDeep.txt|Folders that exceed the path depth limit|Examine the list and reduce the depth of these paths by moving or deleting folders -TooManyItems.txt|Folders that have too many items|Examine the list and manually reduce the number of items in these folders -InvalidPermissions.csv|Any invalid ACEs that were found|Use with -RemoveInvalidPermissions parameter to remove these +ValidationResults.csv|Information about any issues found. This is file we want to examine to understand any issues found.|The script will display a summary of what it found, and in many cases it will provide an example command that uses input from this file to fix the problem. + +### Examples + +Typically, the script should be run with no parameters: + +`.\SourceSideValidations.ps1` + +Run the script with no parameters. Progress indicators are displayed as it collects data and validates the results. + +![Picture of progress bars](ssv1.png) + +The final test, which checks permissions, will usually take much longer than the other tests. + +When all the tests are done, the script will provide a summary of what it found, along with example commands that will fix some issues. + +![Picture of summary](ssv2.png) + +In this example output, the script calls out four issues. + +First, it points out that we have 111,124 folders that are completely empty (this is a lab). Note that it says to look for a result type of EmptyFolder in the CSV. If we want to see the list of empty folders, we can open up ValidationResults.csv in Excel, filter for a ResultType of EmptyFolder, and then we see all those results: + +![Picture of empty folders in Excel](ssv3.png) + +For these folders, no action is required. The script is just giving us information. + +The next thing it calls out is that 4 folders have problematic characters in the name. The output tells us these have a ResultType of SpecialCharacters. Filtering for that in the CSV, we see the folders. + +![Picture of folders with bad characters in Excel](ssv4.png) + +Fortunately, the script gives us a command we can run to fix all the names. We can copy and paste the command it gave us, let it run, and then spot check the result. + +![Picture of folders with bad characters in Excel](ssv5.png) + +Now that the names are fixed, we move on to the next item. + +The script tells us we have a mail public folder object for a public folder that is mail-disabled. For this type of problem, we need to examine the folder and figure out what we want to do. The CSV file gives us the DN of the mail object and the entry ID of the folder, which we can use to examine the two objects. + +![Picture of folders with bad characters in Excel](ssv6.png) + +![Picture of folders with bad characters in Excel](ssv7.png) + +Well, the folder says MailEnabled is False, yet we have a MailPublicFolder which points to it. We need to decide whether we want the folder to receive email or not. For this lab, I decide I _do_ want the folder to be mail-enabled, so I remove the orphaned MailPublicFolder and then mail-enable the folder. + +![Picture of folders with bad characters in Excel](ssv8.png) + +I also confirm the new object has the same email address as the old one. This might need to be adjusted manually in some cases, but here I didn't have to. + +Finally, the script says I have 9,850 invalid permissions. Fortunately, this is another one that is easy to fix, as the script provides a command. + +![Picture of folders with bad characters in Excel](ssv9.png) + +This one is going to take a while. Once completed, I can rerun SourceSideValidations to make sure all the issues are resolved. + +If you close the shell and you need to see the summary results again, use the *-SummarizePreviousResults* switch. + +```powershell +.\SourceSideValidations -SummarizePreviousResults +``` +![Picture of folders with bad characters in Excel](ssv10.png) + +The script reads the output file and repeats the instructions on what to do. You can also summarize the results from previous runs, or point to files in other locations, by providing the *-ResultsFile* parameter. diff --git a/docs/PublicFolders/ssv1.png b/docs/PublicFolders/ssv1.png new file mode 100644 index 0000000000..0c4b053632 Binary files /dev/null and b/docs/PublicFolders/ssv1.png differ diff --git a/docs/PublicFolders/ssv10.png b/docs/PublicFolders/ssv10.png new file mode 100644 index 0000000000..919a9d3a25 Binary files /dev/null and b/docs/PublicFolders/ssv10.png differ diff --git a/docs/PublicFolders/ssv2.png b/docs/PublicFolders/ssv2.png new file mode 100644 index 0000000000..6ad967fc03 Binary files /dev/null and b/docs/PublicFolders/ssv2.png differ diff --git a/docs/PublicFolders/ssv3.png b/docs/PublicFolders/ssv3.png new file mode 100644 index 0000000000..d771937342 Binary files /dev/null and b/docs/PublicFolders/ssv3.png differ diff --git a/docs/PublicFolders/ssv4.png b/docs/PublicFolders/ssv4.png new file mode 100644 index 0000000000..b85a38af15 Binary files /dev/null and b/docs/PublicFolders/ssv4.png differ diff --git a/docs/PublicFolders/ssv5.png b/docs/PublicFolders/ssv5.png new file mode 100644 index 0000000000..0b135597ca Binary files /dev/null and b/docs/PublicFolders/ssv5.png differ diff --git a/docs/PublicFolders/ssv6.png b/docs/PublicFolders/ssv6.png new file mode 100644 index 0000000000..26589d6ec0 Binary files /dev/null and b/docs/PublicFolders/ssv6.png differ diff --git a/docs/PublicFolders/ssv7.png b/docs/PublicFolders/ssv7.png new file mode 100644 index 0000000000..6b0fd1982b Binary files /dev/null and b/docs/PublicFolders/ssv7.png differ diff --git a/docs/PublicFolders/ssv8.png b/docs/PublicFolders/ssv8.png new file mode 100644 index 0000000000..744db67987 Binary files /dev/null and b/docs/PublicFolders/ssv8.png differ diff --git a/docs/PublicFolders/ssv9.png b/docs/PublicFolders/ssv9.png new file mode 100644 index 0000000000..a3962b60d1 Binary files /dev/null and b/docs/PublicFolders/ssv9.png differ