Modules/MDOP2.Actions.psm1

Set-StrictMode -Version Latest

# Remediation actions (app-only Graph move to inbox, archive, junkemail, deleteditems or recoverableitemsdeletions) and status refresh.
# Soft delete only: nothing here hard deletes or purges.

$script:WellKnownCache = @{}
$script:HoldCache = @{}

function Get-MDOP2WellKnownFolderId {
    param([Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][string]$Name)
    $key = "$UserId|$Name"
    if (-not $script:WellKnownCache.ContainsKey($key)) {
        $uid = [uri]::EscapeDataString($UserId)
        $f = Invoke-MDOP2AppGraph -Method GET -Path "/users/$uid/mailFolders/$Name`?`$select=id"
        $script:WellKnownCache[$key] = [string]$f.id
    }
    $script:WellKnownCache[$key]
}

function Get-MDOP2WellKnownFolderMap {
    <#
    .SYNOPSIS
    Returns the IDs of well-known folders for several mailboxes as UserId -> folder name -> ID. Folders that aren't cached
    yet are read with Graph JSON batching; folders that can't be read are left out.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$UserId,
        [Parameter(Mandatory)][string[]]$Name
    )
    $users = @($UserId | Where-Object { $_ } | Select-Object -Unique)
    $Name = @($Name | Select-Object -Unique)
    $requests = [Collections.Generic.List[object]]::new()
    $keys = @{}
    foreach ($u in $users) {
        foreach ($n in $Name) {
            $key = "$u|$n"
            if ($script:WellKnownCache.ContainsKey($key)) { continue }
            $id = "w$($requests.Count)"
            $keys[$id] = $key
            $requests.Add(@{ Id = $id; Url = "/users/$([uri]::EscapeDataString($u))/mailFolders/$n`?`$select=id" })
        }
    }
    if ($requests.Count) {
        $res = Invoke-MDOP2AppGraphBatch -Request $requests.ToArray()
        foreach ($id in $keys.get_Keys()) {
            $r = $res[$id]
            if ($r -and -not $r.Error -and $r.Body -and $r.Body.PSObject.Properties['id']) { $script:WellKnownCache[$keys[$id]] = [string]$r.Body.id }
            elseif ($r) { Write-Verbose "Well-known folder $($keys[$id]): $($r.Error)" }
        }
    }
    $map = @{}
    foreach ($u in $users) {
        $ids = @{}
        foreach ($n in $Name) { if ($script:WellKnownCache.ContainsKey("$u|$n")) { $ids[$n] = $script:WellKnownCache["$u|$n"] } }
        $map[$u] = $ids
    }
    $map
}

function Get-MDOP2MailboxHold {
    <#
    .SYNOPSIS
    Reports whether a mailbox is on hold. Holds don't block moves, but held mail stays recoverable after a soft delete.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Address)
    $key = $Address.ToLowerInvariant()
    if ($script:HoldCache.ContainsKey($key)) { return $script:HoldCache[$key] }
    $result = $null
    try {
        $m = Get-EXOMailbox -Identity $Address -PropertySets Hold -ErrorAction Stop
        $holds = [Collections.Generic.List[string]]::new()
        if ($m.LitigationHoldEnabled) { $holds.Add('Litigation Hold') }
        # @($null) counts as one item, so drop empty values before counting
        $inPlace = @($m.InPlaceHolds | Where-Object { $_ }).Count
        if ($inPlace) { $holds.Add("$inPlace in-place or retention hold(s)") }
        if ($m.ComplianceTagHoldApplied) { $holds.Add('Retention label hold') }
        if ($m.DelayHoldApplied) { $holds.Add('Delay hold') }
        $result = [pscustomobject]@{ OnHold = $holds.Count -gt 0; Detail = $holds -join ', ' }
    }
    catch { Write-Verbose "Hold lookup for $Address failed: $($_.Exception.Message)" }
    $script:HoldCache[$key] = $result
    $result
}

function Invoke-MDOP2MessageAction {
    <#
    .SYNOPSIS
    Runs an action plan from New-MDOP2ActionPlan. Dry run is on unless -DryRun:$false is passed.
    Every item is written to the audit log, including dry runs and skips.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Plan,
        [bool]$DryRun = $true
    )
    $i = 0
    $activity = if ($DryRun) { 'Dry run' } else { 'Moving messages' }
    $total = [Math]::Max(@($Plan).Count, 1)
    foreach ($p in @($Plan)) {
        $i++
        Write-Progress -Activity $activity -Status $p.Address -PercentComplete (100 * $i / $total)
        $result = [ordered]@{
            InternetMessageId = $p.InternetMessageId
            Address           = $p.Address
            UserId            = $p.UserId
            Action            = $p.Action
            DryRun            = $DryRun
            FromFolder        = $p.FolderName
            Result            = $null
            NewMessageId      = $null
            Hold              = $null
            Error             = $null
        }
        if ($p.Plan -ne 'Move') {
            $result.Result = 'Skipped'
            $result.Error = $p.Reason
        }
        else {
            try {
                $target = Get-MDOP2WellKnownFolderId -UserId $p.UserId -Name $p.Destination
                $ri = Get-MDOP2WellKnownFolderId -UserId $p.UserId -Name 'recoverableitemsdeletions'
                $decision = Get-MDOP2MoveDecision -CurrentFolderId $p.FolderId -TargetFolderId $target -RecoverableItemsFolderId $ri -Action $p.Action -AllowRestore:([bool]$p.AllowRestore)
                $hold = Get-MDOP2MailboxHold -Address $p.Address
                if ($hold -and $hold.OnHold) { $result.Hold = $hold.Detail }
                if (-not $decision.Move) {
                    $result.Result = $decision.Result
                    $result.Error = $decision.Reason
                }
                elseif ($DryRun) {
                    $result.Result = 'WouldMove'
                }
                else {
                    $uid = [uri]::EscapeDataString($p.UserId)
                    $mid = [uri]::EscapeDataString($p.GraphMessageId)
                    $moved = Invoke-MDOP2AppGraph -Method POST -Path "/users/$uid/messages/$mid/move" -Body @{ destinationId = $p.Destination }
                    $result.Result = 'Moved'
                    $result.NewMessageId = [string]$moved.id
                }
            }
            catch {
                $sc = [int]($_.Exception.Data['StatusCode'] ?? 0)
                $ec = [string]$_.Exception.Data['ErrorCode']
                $result.Result = if ($sc -eq 404 -and $ec -eq 'ErrorItemNotFound') { 'NotFound' } else { 'Failed' }
                $result.Error = $_.Exception.Message
            }
        }
        Write-MDOP2Audit -Type Action -Data ([ordered]@{
            internetMessageId = $result.InternetMessageId
            mailbox           = $result.Address
            userId            = $result.UserId
            action            = $result.Action
            destination       = $p.Destination
            dryRun            = $DryRun
            fromFolder        = $result.FromFolder
            result            = $result.Result
            hold              = $result.Hold
            error             = $result.Error
        })
        [pscustomobject]$result
    }
    Write-Progress -Activity $activity -Completed
}

function Get-MDOP2MessageStatus {
    <#
    .SYNOPSIS
    Re-checks where a message is now in each mailbox. Message IDs change on every move, so this always searches by Message-ID.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$InternetMessageId,
        [Parameter(Mandatory)][object[]]$Mailbox
    )
    $found = @(Find-MDOP2MessageCopy -Lookup @($Mailbox | ForEach-Object { @{ UserId = $_.UserId; InternetMessageId = $InternetMessageId } }))
    for ($i = 0; $i -lt $Mailbox.Count; $i++) {
        $r = $found[$i]
        [pscustomobject]@{
            InternetMessageId = $InternetMessageId
            Address           = $Mailbox[$i].Address
            UserId            = $Mailbox[$i].UserId
            State             = $r.State
            Folders           = @($r.Locations | ForEach-Object FolderName)
            Locations         = $r.Locations
            Error             = $r.Error
        }
    }
}

Export-ModuleMember -Function Get-MDOP2WellKnownFolderId, Get-MDOP2WellKnownFolderMap, Get-MDOP2MailboxHold, Invoke-MDOP2MessageAction, Get-MDOP2MessageStatus