Public/NC.Compliance.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: Compliance helpers =================================================================================================================== function Get-MboxMrmCleanup { <# .SYNOPSIS Lists retention tags, policies, and mailbox assignments for MRM cleanup workflows. .DESCRIPTION Connects to Exchange Online and inventories retention policies. The output defaults to the temporary Nebula.Core cleanup objects created by Set-MboxMrmCleanup. Use -AllTenantObjects to list every retention policy in the tenant. Each row includes the linked tag details inline and the mailbox count, so you can spot temporary cleanup objects and decide what can be removed safely. .PARAMETER Identity Retention policy name or linked tag name to inspect. When omitted, temporary cleanup policies are returned. .PARAMETER AllTenantObjects Include every retention policy in the tenant instead of limiting the inventory to temporary Nebula.Core cleanup objects. .PARAMETER Detailed Include the linked tag names and mailbox lists in the output. .EXAMPLE Get-MboxMrmCleanup .EXAMPLE Get-MboxMrmCleanup -Detailed .EXAMPLE Get-MboxMrmCleanup -AllTenantObjects .EXAMPLE Get-MboxMrmCleanup -Identity OneShot_PreCutoff_20250101 #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Name', 'TagName', 'PolicyName')] [string[]]$Identity, [switch]$AllTenantObjects, [switch]$Detailed ) begin { Set-ProgressAndInfoPreferences $targets = [System.Collections.Generic.List[string]]::new() } process { foreach ($entry in $Identity) { if (-not [string]::IsNullOrWhiteSpace($entry)) { $targets.Add($entry.Trim()) | Out-Null } } } end { try { if (-not (Test-EOLConnection)) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Exchange Online Management module. Please check logs." -Level ERROR return } $tagObjects = @() $policyObjects = @() try { $tagObjects = @(Get-RetentionPolicyTag -ErrorAction Stop) } catch { Write-NCMessage "Unable to retrieve retention policy tags. $($_.Exception.Message)" -Level ERROR return } try { $policyObjects = @(Get-RetentionPolicy -ErrorAction Stop) } catch { Write-NCMessage "Unable to retrieve retention policies. $($_.Exception.Message)" -Level ERROR return } $mailboxObjects = @() try { if (Get-Command -Name Get-EXOMailbox -ErrorAction SilentlyContinue) { $mailboxObjects = @(Get-EXOMailbox -ResultSize Unlimited -Properties RetentionPolicy, RecipientTypeDetails, DisplayName, PrimarySmtpAddress -ErrorAction Stop) } else { $mailboxObjects = @(Get-Mailbox -ResultSize Unlimited -WarningAction SilentlyContinue -ErrorAction Stop) } } catch { Write-NCMessage "Unable to retrieve mailbox assignments for retention policies. $($_.Exception.Message)" -Level WARNING $mailboxObjects = @() } $mailboxesByPolicy = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[object]]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($mailbox in $mailboxObjects) { $retentionPolicyName = [string]$mailbox.RetentionPolicy if ([string]::IsNullOrWhiteSpace($retentionPolicyName)) { continue } if (-not $mailboxesByPolicy.ContainsKey($retentionPolicyName)) { $mailboxesByPolicy[$retentionPolicyName] = [System.Collections.Generic.List[object]]::new() } $mailboxesByPolicy[$retentionPolicyName].Add($mailbox) | Out-Null } $policyLinksByTag = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.List[string]]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($policy in $policyObjects) { foreach ($tagLink in @($policy.RetentionPolicyTagLinks)) { if ([string]::IsNullOrWhiteSpace([string]$tagLink)) { continue } $tagName = [string]$tagLink if (-not $policyLinksByTag.ContainsKey($tagName)) { $policyLinksByTag[$tagName] = [System.Collections.Generic.List[string]]::new() } $policyLinksByTag[$tagName].Add([string]$policy.Name) | Out-Null } } $policyResults = foreach ($policy in $policyObjects) { if ($null -eq $policy) { continue } $policyName = [string]$policy.Name $linkedTags = @($policy.RetentionPolicyTagLinks | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $primaryTagName = $linkedTags | Select-Object -First 1 $primaryTag = $null if (-not [string]::IsNullOrWhiteSpace($primaryTagName)) { $primaryTag = $tagObjects | Where-Object { [string]$_.Name -ieq $primaryTagName } | Select-Object -First 1 } if (-not $AllTenantObjects.IsPresent -and $policyName -notlike 'OneShot_PreCutoff_*') { continue } if ($targets.Count -gt 0) { $matchesPolicy = $targets -contains $policyName $matchesLinkedTag = $false foreach ($tagName in $linkedTags) { if ($targets -contains $tagName) { $matchesLinkedTag = $true break } } if (-not $matchesPolicy -and -not $matchesLinkedTag) { continue } } $assignedMailboxes = @() if ($mailboxesByPolicy.ContainsKey($policyName)) { $assignedMailboxes = @($mailboxesByPolicy[$policyName] | ForEach-Object { if ($_.PrimarySmtpAddress) { [string]$_.PrimarySmtpAddress } elseif ($_.UserPrincipalName) { [string]$_.UserPrincipalName } else { [string]$_.Identity } } | Sort-Object -Unique) } $ageLimitDays = if ($primaryTag -and ($primaryTag.AgeLimitForRetention -is [TimeSpan])) { [int][math]::Floor($primaryTag.AgeLimitForRetention.TotalDays) } elseif ($primaryTag) { $primaryTag.AgeLimitForRetention } else { $null } $row = [ordered]@{ ObjectType = 'Policy' Identity = $policy.Identity Name = $policyName LinkedTagName = $primaryTagName TagType = $primaryTag.Type RetentionEnabled = $primaryTag.RetentionEnabled AgeLimitForRetentionDays = $ageLimitDays RetentionAction = $primaryTag.RetentionAction ConditionSummary = if ($primaryTag) { "Type={0}; AgeLimit={1}d; Action={2}; Enabled={3}" -f $primaryTag.Type, $ageLimitDays, $primaryTag.RetentionAction, $primaryTag.RetentionEnabled } elseif ($linkedTags.Count -gt 0) { "TagLinks={0}" -f ($linkedTags -join ', ') } else { 'TagLinks=' } LinkedTagCount = $linkedTags.Count TagLinkCount = $linkedTags.Count AssignedMailboxCount = $assignedMailboxes.Count } if ($Detailed.IsPresent) { $row.LinkedTagNames = $linkedTags $row.AssignedMailboxes = $assignedMailboxes } [pscustomobject]$row } $results = @($policyResults) if ($results.Count -eq 0) { Write-NCMessage "No retention policies matched the requested filters." -Level WARNING return } $results | Sort-Object ObjectType, Name } finally { Restore-ProgressAndInfoPreferences } } } function Remove-MboxMrmCleanup { <# .SYNOPSIS Removes temporary MRM cleanup tags and policies. .DESCRIPTION Finds the specified retention policy tag and policy, moves the affected mailboxes back to the default retention policy or to a specific standard policy, and then removes the temporary policy and tag when they are no longer needed. .PARAMETER Identity Retention tag or retention policy name to remove. When omitted, all Nebula.Core temporary cleanup objects (names starting with OneShot_PreCutoff_) are targeted. .PARAMETER Mailbox Optional mailbox list to restore before deleting the cleanup policy. When omitted, every mailbox currently using the target policy is restored. .PARAMETER RestorePolicyName Explicit standard retention policy to assign back to the mailbox or mailboxes. .PARAMETER ClearRetentionPolicy Clear the mailbox retention policy instead of assigning a specific standard policy. .PARAMETER RemoveTag Remove the retention policy tag after the policy has been cleaned up. .PARAMETER RemovePolicy Remove the retention policy after the target mailboxes have been restored. .EXAMPLE Remove-MboxMrmCleanup -Identity OneShot_PreCutoff_20250101 .EXAMPLE Remove-MboxMrmCleanup -PolicyName OneShot_PreCutoff_20250101 -RestorePolicyName 'Default MRM Policy' .EXAMPLE 'user@contoso.com' | Remove-MboxMrmCleanup -Identity OneShot_PreCutoff_20250101 -ClearRetentionPolicy #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Name', 'TagName', 'PolicyName')] [string[]]$Identity, [Parameter(ValueFromPipelineByPropertyName = $true)] [Alias('UserPrincipalName', 'IdentityMailbox', 'SourceMailbox')] [string[]]$Mailbox, [string]$RestorePolicyName, [switch]$ClearRetentionPolicy, [bool]$RemoveTag = $true, [bool]$RemovePolicy = $true ) begin { Set-ProgressAndInfoPreferences $targets = [System.Collections.Generic.List[string]]::new() $mailboxTargets = [System.Collections.Generic.List[string]]::new() } process { foreach ($entry in $Identity) { if (-not [string]::IsNullOrWhiteSpace($entry)) { $targets.Add($entry.Trim()) | Out-Null } } foreach ($entry in $Mailbox) { if (-not [string]::IsNullOrWhiteSpace($entry)) { $mailboxTargets.Add($entry.Trim()) | Out-Null } } } end { try { if (-not (Test-EOLConnection)) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Exchange Online Management module. Please check logs." -Level ERROR return } if (-not [string]::IsNullOrWhiteSpace($RestorePolicyName) -and $ClearRetentionPolicy.IsPresent) { Write-NCMessage "Use either -RestorePolicyName or -ClearRetentionPolicy, not both." -Level ERROR return } if (-not [string]::IsNullOrWhiteSpace($RestorePolicyName)) { $restorePolicyCheck = Get-RetentionPolicy -Identity $RestorePolicyName -ErrorAction SilentlyContinue if (-not $restorePolicyCheck) { Write-NCMessage "Restore policy '$RestorePolicyName' was not found. Use the exact retention policy name." -Level ERROR return } } $tagObjects = @() $policyObjects = @() try { $tagObjects = @(Get-RetentionPolicyTag -ErrorAction Stop) } catch { Write-NCMessage "Unable to retrieve retention policy tags. $($_.Exception.Message)" -Level ERROR return } try { $policyObjects = @(Get-RetentionPolicy -ErrorAction Stop) } catch { Write-NCMessage "Unable to retrieve retention policies. $($_.Exception.Message)" -Level ERROR return } $targetTagNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $targetPolicyNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) if ($targets.Count -eq 0) { foreach ($tag in $tagObjects) { if ($tag.Name -like 'OneShot_PreCutoff_*') { $null = $targetTagNames.Add([string]$tag.Name) } } foreach ($policy in $policyObjects) { if ($policy.Name -like 'OneShot_PreCutoff_*') { $null = $targetPolicyNames.Add([string]$policy.Name) } } } else { foreach ($entry in $targets) { $tagMatch = $tagObjects | Where-Object { [string]$_.Name -ieq $entry } | Select-Object -First 1 if ($tagMatch) { $null = $targetTagNames.Add([string]$tagMatch.Name) } $policyMatch = $policyObjects | Where-Object { [string]$_.Name -ieq $entry } | Select-Object -First 1 if ($policyMatch) { $null = $targetPolicyNames.Add([string]$policyMatch.Name) } } } if ($targetTagNames.Count -eq 0 -and $targetPolicyNames.Count -eq 0) { Write-NCMessage "No matching retention tags or policies were found." -Level WARNING return } $mailboxesToRestore = [System.Collections.Generic.List[object]]::new() $policyNamesToRemove = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($policyName in $targetPolicyNames) { $policy = $policyObjects | Where-Object { [string]$_.Name -ieq $policyName } | Select-Object -First 1 if ($policy) { $null = $policyNamesToRemove.Add([string]$policy.Name) } } foreach ($tagName in $targetTagNames) { $tagLinkedPolicies = @($policyObjects | Where-Object { @($_.RetentionPolicyTagLinks) -contains $tagName }) foreach ($policy in $tagLinkedPolicies) { $null = $policyNamesToRemove.Add([string]$policy.Name) } } if ($mailboxTargets.Count -gt 0) { foreach ($mailboxIdentity in $mailboxTargets) { try { $mailboxObj = Get-Mailbox -Identity $mailboxIdentity -ErrorAction Stop $mailboxesToRestore.Add($mailboxObj) | Out-Null } catch { Write-NCMessage "Unable to read mailbox '$mailboxIdentity'. $($_.Exception.Message)" -Level ERROR } } } else { try { $allMailboxes = @(Get-Mailbox -ResultSize Unlimited -WarningAction SilentlyContinue -ErrorAction Stop) } catch { Write-NCMessage "Unable to enumerate mailboxes using retention policies. $($_.Exception.Message)" -Level WARNING $allMailboxes = @() } foreach ($mailbox in $allMailboxes) { if ($policyNamesToRemove.Contains([string]$mailbox.RetentionPolicy)) { $mailboxesToRestore.Add($mailbox) | Out-Null } } } $restoreTargets = [System.Collections.Generic.List[object]]::new() foreach ($mailbox in $mailboxesToRestore) { if ($null -eq $mailbox) { continue } $mailboxName = if ($mailbox.PrimarySmtpAddress) { [string]$mailbox.PrimarySmtpAddress } else { [string]$mailbox.Identity } $targetPolicy = if (-not [string]::IsNullOrWhiteSpace($RestorePolicyName)) { $RestorePolicyName } else { $null } $action = if ($null -eq $targetPolicy) { 'Clear retention policy back to default' } else { "Restore retention policy '$targetPolicy'" } if (-not $PSCmdlet.ShouldProcess($mailboxName, $action)) { continue } try { Set-Mailbox -Identity $mailbox.Identity -RetentionPolicy $targetPolicy -ErrorAction Stop | Out-Null $restoreTargets.Add([pscustomobject]@{ Mailbox = $mailboxName PreviousPolicy = [string]$mailbox.RetentionPolicy NewPolicy = $targetPolicy Action = if ($null -eq $targetPolicy) { 'Cleared' } else { 'Restored' } }) | Out-Null } catch { Write-NCMessage "Unable to restore retention policy for '$mailboxName'. $($_.Exception.Message)" -Level ERROR } } foreach ($policyName in $policyNamesToRemove) { $policy = $policyObjects | Where-Object { [string]$_.Name -ieq $policyName } | Select-Object -First 1 if (-not $policy) { continue } $linkedTags = @($policy.RetentionPolicyTagLinks) if ($linkedTags.Count -gt 1) { Write-NCMessage ("Policy '{0}' has multiple tag links. Review it manually before deletion." -f $policyName) -Level WARNING continue } if ($RemovePolicy -and $PSCmdlet.ShouldProcess($policyName, "Remove retention policy")) { try { Remove-RetentionPolicy -Identity $policyName -ErrorAction Stop Write-NCMessage "Retention policy '$policyName' removed." -Level SUCCESS } catch { Write-NCMessage "Unable to remove retention policy '$policyName'. $($_.Exception.Message)" -Level ERROR } } } if ($RemoveTag) { foreach ($tagName in $targetTagNames) { $tag = $tagObjects | Where-Object { [string]$_.Name -ieq $tagName } | Select-Object -First 1 if (-not $tag) { continue } $linkedPolicies = @($policyObjects | Where-Object { @($_.RetentionPolicyTagLinks) -contains $tagName }) if ($linkedPolicies.Count -gt 0) { foreach ($policy in $linkedPolicies) { if ($policyNamesToRemove.Contains([string]$policy.Name)) { continue } Write-NCMessage ("Tag '{0}' is still linked to policy '{1}'. Review that policy before deleting the tag." -f $tagName, $policy.Name) -Level WARNING } if (($linkedPolicies | Where-Object { -not $policyNamesToRemove.Contains([string]$_.Name) }).Count -gt 0) { continue } } if ($PSCmdlet.ShouldProcess($tagName, "Remove retention policy tag")) { try { Remove-RetentionPolicyTag -Identity $tagName -ErrorAction Stop Write-NCMessage "Retention policy tag '$tagName' removed." -Level SUCCESS } catch { Write-NCMessage "Unable to remove retention policy tag '$tagName'. $($_.Exception.Message)" -Level ERROR } } } } if ($restoreTargets.Count -gt 0) { $restoreCount = $restoreTargets.Count Write-NCMessage ("Restored retention policy for {0} mailbox(es)." -f $restoreCount) -Level SUCCESS } if ($restoreTargets.Count -gt 0 -or $policyNamesToRemove.Count -gt 0 -or $targetTagNames.Count -gt 0) { [pscustomobject]@{ RestoredMailboxes = @($restoreTargets) RemovedPolicies = @($policyNamesToRemove | Sort-Object) RemovedTags = @($targetTagNames | Sort-Object) RestorePolicyName = $RestorePolicyName ClearedToDefault = $ClearRetentionPolicy.IsPresent -or [string]::IsNullOrWhiteSpace($RestorePolicyName) } } } finally { Restore-ProgressAndInfoPreferences } } } function Search-MboxCutoffWindow { <# .SYNOPSIS Creates or reuses a Purview Compliance Search to isolate mailbox items by date criteria. .DESCRIPTION Builds a content query for a target mailbox (items before a cutoff date, or within a fixed date range), runs a compliance estimate, and can optionally run a Preview action with sampled output lines. Useful to isolate candidate items before export/cleanup workflows. .PARAMETER Mailbox Target mailbox (UPN or SMTP address). Accepts pipeline input. .PARAMETER Mode Query mode: - BeforeCutoff: items older than CutoffDate - Range: items in [StartDate, EndDate) (end exclusive) .PARAMETER CutoffDate Cutoff date used when Mode is BeforeCutoff. .PARAMETER StartDate Start date used when Mode is Range. .PARAMETER EndDate End date (exclusive) used when Mode is Range. .PARAMETER Preview Create a Preview action and return a limited sample of preview items. .PARAMETER PreviewCount Number of preview entries to sample. .PARAMETER ExistingSearchName Explicit compliance search name to reuse. .PARAMETER UseExistingOnly Do not create/modify search definition; only run estimate/preview on ExistingSearchName. .PARAMETER PollingSeconds Polling interval in seconds while waiting for Compliance Search/Action completion. .PARAMETER MaxWaitMinutes Maximum wait time before aborting search/action polling. .EXAMPLE Search-MboxCutoffWindow -Mailbox 'user@contoso.com' -Mode BeforeCutoff -CutoffDate '2025-01-01' .EXAMPLE Search-MboxCutoffWindow -Mailbox 'user@contoso.com' -Mode Range -StartDate '2025-01-01' -EndDate '2025-02-01' -Preview -PreviewCount 25 .EXAMPLE Search-MboxCutoffWindow -Mailbox 'user@contoso.com' -ExistingSearchName 'Isolate_Pre_20250101_140530' -UseExistingOnly #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Identity', 'UserPrincipalName', 'SourceMailbox')] [string]$Mailbox, [ValidateSet('BeforeCutoff', 'Range')] [string]$Mode = 'BeforeCutoff', [datetime]$CutoffDate = [datetime]'2025-01-01', [datetime]$StartDate, [datetime]$EndDate, [switch]$Preview, [ValidateRange(1, 500)] [int]$PreviewCount = 50, [string]$ExistingSearchName, [switch]$UseExistingOnly, [ValidateRange(5, 300)] [int]$PollingSeconds = 10, [ValidateRange(1, 240)] [int]$MaxWaitMinutes = 60 ) begin { Set-ProgressAndInfoPreferences } process { if (-not (Test-EOLConnection)) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Exchange Online Management module. Please check logs." -Level ERROR return } try { if (-not (Get-Command -Name Get-ComplianceSearch -ErrorAction SilentlyContinue)) { Connect-IPPSSession -EnableSearchOnlySession -ErrorAction Stop | Out-Null } else { try { Get-ComplianceSearch -ErrorAction Stop | Select-Object -First 1 | Out-Null } catch { Connect-IPPSSession -EnableSearchOnlySession -ErrorAction Stop | Out-Null } } } catch { Write-NCMessage "Unable to connect to Microsoft Purview Compliance PowerShell. $($_.Exception.Message)" -Level ERROR return } if ($Mode -eq 'Range') { if (-not $PSBoundParameters.ContainsKey('StartDate') -or -not $PSBoundParameters.ContainsKey('EndDate')) { Write-NCMessage "Range mode requires both -StartDate and -EndDate." -Level ERROR return } if ($EndDate -le $StartDate) { Write-NCMessage "EndDate must be greater than StartDate." -Level ERROR return } } $query = if ($Mode -eq 'BeforeCutoff') { $cutoff = $CutoffDate.ToString('MM/dd/yyyy') "(Received<$cutoff) OR (Sent<$cutoff)" } else { $start = $StartDate.ToString('MM/dd/yyyy') $end = $EndDate.ToString('MM/dd/yyyy') "((Received>=$start AND Received<$end) OR (Sent>=$start AND Sent<$end))" } $searchName = if (-not [string]::IsNullOrWhiteSpace($ExistingSearchName)) { $ExistingSearchName } else { $prefix = if ($Mode -eq 'BeforeCutoff') { "Isolate_Pre_$($CutoffDate.ToString('yyyyMMdd'))" } else { 'Isolate_Range' } "{0}_{1}" -f $prefix, (Get-Date -Format 'yyyyMMdd_HHmmss') } Write-NCMessage "Mailbox: $Mailbox" -Level INFO Write-NCMessage "Mode: $Mode" -Level INFO Write-NCMessage "Query: $query" -Level INFO Write-NCMessage "Search: $searchName" -Level INFO if ($UseExistingOnly.IsPresent -and [string]::IsNullOrWhiteSpace($ExistingSearchName)) { Write-NCMessage "UseExistingOnly requires -ExistingSearchName." -Level ERROR return } if (-not $UseExistingOnly.IsPresent) { $existing = Get-ComplianceSearch -Identity $searchName -ErrorAction SilentlyContinue if (-not $existing) { if ($PSCmdlet.ShouldProcess($searchName, "Create Compliance Search for mailbox '$Mailbox'")) { try { New-ComplianceSearch -Name $searchName -ExchangeLocation $Mailbox -ContentMatchQuery $query -ErrorAction Stop | Out-Null } catch { Write-NCMessage "Unable to create compliance search '$searchName'. $($_.Exception.Message)" -Level ERROR return } } else { return } } else { Write-NCMessage "Compliance search '$searchName' already exists. Reusing it." -Level WARNING } } else { $existing = Get-ComplianceSearch -Identity $searchName -ErrorAction SilentlyContinue if (-not $existing) { Write-NCMessage "Existing search '$searchName' not found." -Level ERROR return } } if (-not $PSCmdlet.ShouldProcess($searchName, "Run compliance estimate")) { return } try { Start-ComplianceSearch -Identity $searchName -ErrorAction Stop | Out-Null } catch { Write-NCMessage "Unable to start compliance search '$searchName'. $($_.Exception.Message)" -Level ERROR return } $deadline = (Get-Date).AddMinutes($MaxWaitMinutes) $searchStatus = $null $search = $null while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $PollingSeconds $search = Get-ComplianceSearch -Identity $searchName -ErrorAction SilentlyContinue if (-not $search) { continue } $searchStatus = [string]$search.Status if ($searchStatus -in @('Completed', 'PartiallySucceeded', 'PartiallyCompleted', 'Failed')) { break } } if (-not $search) { Write-NCMessage "Unable to read compliance search '$searchName' status." -Level ERROR return } if ($searchStatus -notin @('Completed', 'PartiallySucceeded', 'PartiallyCompleted')) { if ($searchStatus -eq 'Failed') { Write-NCMessage "Compliance search '$searchName' failed." -Level ERROR } else { Write-NCMessage "Timeout while waiting for compliance search '$searchName' completion." -Level ERROR } return } $estimatedItems = [int]$search.Items $unindexedItems = $search.UnindexedItems Write-NCMessage ("Search completed. Estimated items: {0}" -f $estimatedItems) -Level SUCCESS if ($null -ne $unindexedItems) { Write-NCMessage ("Estimated unindexed items: {0}" -f $unindexedItems) -Level WARNING } $previewStatus = $null $previewSample = @() if ($Preview.IsPresent) { if (-not $PSCmdlet.ShouldProcess($searchName, "Create Preview action")) { return } try { $previewAction = New-ComplianceSearchAction -SearchName $searchName -Preview -Force -Confirm:$false -ErrorAction Stop } catch { Write-NCMessage "Unable to create preview action for '$searchName'. $($_.Exception.Message)" -Level ERROR return } $actionDeadline = (Get-Date).AddMinutes($MaxWaitMinutes) $actionResult = $null while ((Get-Date) -lt $actionDeadline) { Start-Sleep -Seconds ([Math]::Max($PollingSeconds, 10)) $actionResult = Get-ComplianceSearchAction -Identity $previewAction.Identity -ErrorAction SilentlyContinue if (-not $actionResult) { continue } $previewStatus = [string]$actionResult.Status if ($previewStatus -in @('Completed', 'PartiallyCompleted', 'Failed')) { break } } if (-not $actionResult) { Write-NCMessage "Unable to read preview action status for '$searchName'." -Level ERROR return } if ($previewStatus -eq 'Failed') { Write-NCMessage ("Preview action failed for '{0}'. {1}" -f $searchName, [string]$actionResult.Errors) -Level ERROR return } if ($previewStatus -notin @('Completed', 'PartiallyCompleted')) { Write-NCMessage "Timeout while waiting for preview action completion for '$searchName'." -Level ERROR return } $rawResults = [string]$actionResult.Results if (-not [string]::IsNullOrWhiteSpace($rawResults)) { $previewSample = @($rawResults -split ",\s*(?=Location:)" | Select-Object -First $PreviewCount) } Write-NCMessage ("Preview action status: {0}" -f $previewStatus) -Level SUCCESS if ($previewSample.Count -gt 0) { Write-NCMessage ("Preview sample lines returned: {0}" -f $previewSample.Count) -Level INFO } else { Write-NCMessage "Preview completed, but no sample lines were returned in PowerShell output. Check Purview portal for details." -Level WARNING } } Write-NCMessage "Purview portal: https://purview.microsoft.com" -Level INFO Write-NCMessage "Path: eDiscovery -> Content search -> open the search -> Actions/Export" -Level INFO [pscustomobject]@{ Mailbox = $Mailbox Mode = $Mode Query = $query SearchName = $searchName SearchStatus = $searchStatus EstimatedItems = $estimatedItems UnindexedItems = $unindexedItems PreviewStatus = $previewStatus PreviewSample = $previewSample } } end { Restore-ProgressAndInfoPreferences } } function Set-MboxMrmCleanup { <# .SYNOPSIS Applies a one-shot MRM cleanup policy to a mailbox. .DESCRIPTION Computes a safe retention age from a fixed cutoff date plus a safety buffer, then creates/updates a retention tag and policy, assigns the policy to the mailbox, and optionally triggers Managed Folder Assistant. Intended for temporary cleanup workflows where older items should be targeted while preserving recent data. .PARAMETER Mailbox Target mailbox identity (UPN or SMTP). Accepts pipeline input. .PARAMETER FixedCutoffDate Fixed cutoff date used to compute AgeLimitForRetention in days. .PARAMETER SafetyBufferDays Additional safety days added to the computed retention age. .PARAMETER RetentionAction Retention action for the tag (`DeleteAndAllowRecovery` or `PermanentlyDelete`). .PARAMETER TagName Retention tag name. If omitted, an automatic name based on cutoff date is used. .PARAMETER PolicyName Retention policy name. If omitted, an automatic name based on cutoff date is used. .PARAMETER RunAssistant Trigger Managed Folder Assistant (FullCrawl) after policy assignment. .EXAMPLE Set-MboxMrmCleanup -Mailbox 'user@contoso.com' -FixedCutoffDate '2025-01-01' -SafetyBufferDays 7 .EXAMPLE Set-MboxMrmCleanup -Mailbox 'user@contoso.com' -RetentionAction PermanentlyDelete -RunAssistant -WhatIf #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('Identity', 'UserPrincipalName', 'SourceMailbox')] [string]$Mailbox, [datetime]$FixedCutoffDate = [datetime]'2025-01-01', [ValidateRange(0, 60)] [int]$SafetyBufferDays = 7, [ValidateSet('DeleteAndAllowRecovery', 'PermanentlyDelete')] [string]$RetentionAction = 'DeleteAndAllowRecovery', [string]$TagName, [string]$PolicyName, [switch]$RunAssistant ) begin { Set-ProgressAndInfoPreferences } process { if (-not (Test-EOLConnection)) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Exchange Online Management module. Please check logs." -Level ERROR return } $now = Get-Date $ageDays = [int]([math]::Ceiling(($now - $FixedCutoffDate).TotalDays)) + $SafetyBufferDays if ($ageDays -lt 1) { $ageDays = 1 } if ([string]::IsNullOrWhiteSpace($TagName)) { $TagName = "OneShot_PreCutoff_$($FixedCutoffDate.ToString('yyyyMMdd'))" } if ([string]::IsNullOrWhiteSpace($PolicyName)) { $PolicyName = "OneShot_PreCutoff_$($FixedCutoffDate.ToString('yyyyMMdd'))" } Write-NCMessage "Mailbox: $Mailbox" -Level INFO Write-NCMessage ("Fixed cutoff date: {0:yyyy-MM-dd}" -f $FixedCutoffDate) -Level INFO Write-NCMessage ("Safety buffer (days): {0}" -f $SafetyBufferDays) -Level INFO Write-NCMessage ("Computed AgeLimitForRetention (days): {0}" -f $ageDays) -Level SUCCESS Write-NCMessage ("Retention action: {0}" -f $RetentionAction) -Level INFO Write-NCMessage ("Tag name: {0}" -f $TagName) -Level INFO Write-NCMessage ("Policy name: {0}" -f $PolicyName) -Level INFO $existingMailboxRetentionPolicy = $null try { $mailboxState = Get-Mailbox -Identity $Mailbox -ErrorAction Stop $existingMailboxRetentionPolicy = [string]$mailboxState.RetentionPolicy } catch { Write-NCMessage "Unable to read current retention policy for '$Mailbox'. Cleanup hints will assume the default policy." -Level WARNING } try { $tag = Get-RetentionPolicyTag -Identity $TagName -ErrorAction SilentlyContinue if (-not $tag) { if ($PSCmdlet.ShouldProcess($TagName, "Create retention policy tag")) { New-RetentionPolicyTag -Name $TagName -Type All -RetentionEnabled $true -AgeLimitForRetention $ageDays -RetentionAction $RetentionAction -ErrorAction Stop | Out-Null Write-NCMessage "Retention policy tag '$TagName' created." -Level SUCCESS } } else { if ($PSCmdlet.ShouldProcess($TagName, "Update retention policy tag settings")) { Set-RetentionPolicyTag -Identity $TagName -RetentionEnabled $true -AgeLimitForRetention $ageDays -RetentionAction $RetentionAction -ErrorAction Stop | Out-Null Write-NCMessage "Retention policy tag '$TagName' updated." -Level SUCCESS } } } catch { Write-NCMessage "Unable to create/update retention policy tag '$TagName'. $($_.Exception.Message)" -Level ERROR return } try { $policy = Get-RetentionPolicy -Identity $PolicyName -ErrorAction SilentlyContinue if (-not $policy) { if ($PSCmdlet.ShouldProcess($PolicyName, "Create retention policy with tag '$TagName'")) { New-RetentionPolicy -Name $PolicyName -RetentionPolicyTagLinks $TagName -ErrorAction Stop | Out-Null Write-NCMessage "Retention policy '$PolicyName' created." -Level SUCCESS } } else { $links = @($policy.RetentionPolicyTagLinks) if ($links -notcontains $TagName) { if ($PSCmdlet.ShouldProcess($PolicyName, "Add retention policy tag link '$TagName'")) { Set-RetentionPolicy -Identity $PolicyName -RetentionPolicyTagLinks ($links + $TagName) -ErrorAction Stop | Out-Null Write-NCMessage "Retention policy '$PolicyName' updated with tag '$TagName'." -Level SUCCESS } } else { Write-NCMessage "Retention policy '$PolicyName' already includes '$TagName'." -Level INFO } } } catch { Write-NCMessage "Unable to create/update retention policy '$PolicyName'. $($_.Exception.Message)" -Level ERROR return } try { if ($PSCmdlet.ShouldProcess($Mailbox, "Assign retention policy '$PolicyName'")) { Set-Mailbox -Identity $Mailbox -RetentionPolicy $PolicyName -ErrorAction Stop | Out-Null Write-NCMessage "Retention policy '$PolicyName' assigned to '$Mailbox'." -Level SUCCESS } } catch { Write-NCMessage "Unable to assign retention policy '$PolicyName' to '$Mailbox'. $($_.Exception.Message)" -Level ERROR return } if ($RunAssistant.IsPresent) { try { if ($PSCmdlet.ShouldProcess($Mailbox, 'Trigger Managed Folder Assistant (FullCrawl)')) { Start-ManagedFolderAssistant -Identity $Mailbox -FullCrawl -ErrorAction Stop Write-NCMessage "Managed Folder Assistant triggered for '$Mailbox'." -Level SUCCESS } } catch { Write-NCMessage "Unable to trigger Managed Folder Assistant for '$Mailbox'. $($_.Exception.Message)" -Level ERROR return } } else { Write-NCMessage "Managed Folder Assistant not triggered. Use -RunAssistant to start it." -Level INFO } $escapedPolicyName = $PolicyName -replace "'", "''" $escapedTagName = $TagName -replace "'", "''" $escapedExistingPolicy = if ([string]::IsNullOrWhiteSpace($existingMailboxRetentionPolicy)) { $null } else { $existingMailboxRetentionPolicy -replace "'", "''" } [pscustomobject]@{ Mailbox = $Mailbox FixedCutoffDate = $FixedCutoffDate SafetyBufferDays = $SafetyBufferDays AgeLimitDays = $ageDays RetentionAction = $RetentionAction TagName = $TagName PolicyName = $PolicyName ExistingPolicy = $existingMailboxRetentionPolicy RollbackCommand = if ([string]::IsNullOrWhiteSpace($existingMailboxRetentionPolicy)) { "Set-Mailbox -Identity '$Mailbox' -RetentionPolicy `$null" } else { "Set-Mailbox -Identity '$Mailbox' -RetentionPolicy '$escapedExistingPolicy'" } RemovePolicyHint = "Remove-RetentionPolicy -Identity '$escapedPolicyName'" RemoveTagHint = "Remove-RetentionPolicyTag -Identity '$escapedTagName'" } } end { Restore-ProgressAndInfoPreferences } } |