Public/NC.Compliance.ps1

#Requires -Version 5.0
using namespace System.Management.Automation

# Nebula.Core: Compliance ===========================================================================================================================

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

        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
        }

        [pscustomobject]@{
            Mailbox            = $Mailbox
            FixedCutoffDate    = $FixedCutoffDate
            SafetyBufferDays   = $SafetyBufferDays
            AgeLimitDays       = $ageDays
            RetentionAction    = $RetentionAction
            TagName            = $TagName
            PolicyName         = $PolicyName
            RollbackCommand    = "Set-Mailbox -Identity '$Mailbox' -RetentionPolicy `$null"
            RemovePolicyHint   = "Remove-RetentionPolicy -Identity '$PolicyName'"
            RemoveTagHint      = "Remove-RetentionPolicyTag -Identity '$TagName'"
        }
    }

    end {
        Restore-ProgressAndInfoPreferences
    }
}