modules/Devolutions.CIEM.Notifications/Public/Send-CIEMNotification.ps1

function Send-CIEMNotification {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [int]$CurrentDiscoveryRunId,

        [Parameter(Mandatory)]
        [ValidateSet('Manual', 'ScheduledDiscovery')]
        [string]$InvocationSource,

        [Parameter()]
        [switch]$Test
    )

    $ErrorActionPreference = 'Stop'

    if (-not $Test -and -not $PSBoundParameters.ContainsKey('CurrentDiscoveryRunId')) {
        throw 'CurrentDiscoveryRunId is required unless -Test is specified.'
    }

    $enabledNotifications = @(Get-CIEMNotification | Where-Object { $_.Enabled -and $_.Type -eq 'ExposureChange' })
    if ($enabledNotifications.Count -eq 0) {
        return [PSCustomObject]@{
            SentCount   = 0
            FailedCount = 0
            SkippedCount = 0
        }
    }
    if ($enabledNotifications.Count -ne 1) {
        throw 'CIEM notifications V1 supports exactly one enabled ExposureChange notification.'
    }
    $notification = $enabledNotifications[0]

    $scopeAllowsSend = switch ($notification.AutoSendScope) {
        'AnyDiscovery' { $true }
        'ScheduledDiscovery' { $InvocationSource -eq 'ScheduledDiscovery' }
        'ManualOnly' { $InvocationSource -eq 'Manual' }
    }
    if (-not $scopeAllowsSend) {
        return [PSCustomObject]@{
            SentCount   = 0
            FailedCount = 0
            SkippedCount = 0
        }
    }

    $enabledChannels = @(Get-CIEMNotificationChannel | Where-Object { $_.Enabled -and $_.Type -eq 'Email' })
    if ($enabledChannels.Count -eq 0) {
        return [PSCustomObject]@{
            SentCount   = 0
            FailedCount = 0
            SkippedCount = 0
        }
    }
    if ($enabledChannels.Count -ne 1) {
        throw 'CIEM notifications V1 supports exactly one enabled Email notification channel.'
    }
    $channel = $enabledChannels[0]

    $profile = GetCIEMAssignedAuthenticationProfile -UsageType 'NotificationChannel' -UsageId 'email-default'

    $changes = if ($Test) {
        @([PSCustomObject]@{
            Id                    = 'test-notification'
            CurrentDiscoveryRunId = 0
            ChangeType            = 'NewRisk'
            Severity              = 'Critical'
            SeverityRank          = 1
            Title                 = 'Test exposure change notification'
            Evidence              = 'This is a test notification from the CIEM configuration page.'
            ImpactedIdentityName  = 'Test Identity'
            ImpactedResourceName  = 'Test Resource'
        })
    }
    else {
        @(Get-CIEMExposureChange -CurrentDiscoveryRunId $CurrentDiscoveryRunId)
    }

    $minimumSeverityRank = GetCIEMNotificationSeverityRank -Severity $notification.MinimumSeverity
    $matchingChanges = @($changes | Where-Object {
        $notification.ChangeTypes -contains $_.ChangeType -and [int]$_.SeverityRank -le $minimumSeverityRank
    })
    $notificationGroups = @($matchingChanges | Group-Object -Property ChangeType, ExposureType, Title | ForEach-Object {
            $groupChanges = @($_.Group | Sort-Object @{ Expression = { [int]$_.SeverityRank } }, ImpactedIdentityName, ImpactedResourceName, Id)
            $primaryChange = $groupChanges[0]
            $identities = @($groupChanges | ForEach-Object { [string]$_.ImpactedIdentityName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
            $targets = @($groupChanges | ForEach-Object { [string]$_.ImpactedResourceName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
            $signalIds = @($groupChanges | ForEach-Object { [string]$_.Id })
            $groupLines = @(foreach ($groupChange in $groupChanges) {
                    $lineParts = [System.Collections.Generic.List[string]]::new()
                    if (-not [string]::IsNullOrWhiteSpace([string]$groupChange.ImpactedIdentityName)) {
                        $lineParts.Add("Identity: $($groupChange.ImpactedIdentityName)")
                    }
                    if (-not [string]::IsNullOrWhiteSpace([string]$groupChange.ImpactedResourceName)) {
                        $lineParts.Add("Target: $($groupChange.ImpactedResourceName)")
                    }
                    if (-not [string]::IsNullOrWhiteSpace([string]$groupChange.Evidence)) {
                        $lineParts.Add("Evidence: $($groupChange.Evidence)")
                    }
                    $lineParts.Add("Signal: $($groupChange.Id)")
                    "- $($lineParts -join '; ')"
                })
            $changeCountLabel = if ($groupChanges.Count -eq 1) { '1 matching exposure change' } else { "$($groupChanges.Count) matching exposure changes" }

            [PSCustomObject]@{
                Id                    = $signalIds -join ','
                CurrentDiscoveryRunId = $primaryChange.CurrentDiscoveryRunId
                ChangeType            = $primaryChange.ChangeType
                Severity              = $primaryChange.Severity
                SeverityRank          = $primaryChange.SeverityRank
                Title                 = $primaryChange.Title
                Evidence              = "$changeCountLabel`n$($groupLines -join "`n")"
                ImpactedIdentityName  = $identities -join ', '
                ImpactedResourceName  = $targets -join ', '
            }
        })

    $sentCount = 0
    $failedCount = 0
    $recipientSummary = "To: $($channel.ToRecipients -join ', ')"
    if ($channel.CcRecipients.Count -gt 0) {
        $recipientSummary += "; Cc: $($channel.CcRecipients -join ', ')"
    }
    if ($channel.BccRecipients.Count -gt 0) {
        $recipientSummary += "; Bcc: $($channel.BccRecipients -join ', ')"
    }

    foreach ($change in $notificationGroups) {
        $templateValues = @{
            Severity              = $change.Severity
            Title                 = $change.Title
            Evidence              = $change.Evidence
            ChangeType            = $change.ChangeType
            Identity              = $change.ImpactedIdentityName
            Target                = $change.ImpactedResourceName
            CurrentDiscoveryRunId = $change.CurrentDiscoveryRunId
            SourceSignalId        = $change.Id
        }

        $subject = FormatCIEMNotificationTemplate -Template $notification.SubjectTemplate -Values $templateValues
        $textBody = FormatCIEMNotificationTemplate -Template $notification.TextBodyTemplate -Values $templateValues
        $htmlBody = FormatCIEMNotificationTemplate -Template $notification.HtmlBodyTemplate -Values $templateValues
        $attemptedAt = (Get-Date).ToString('o')

        try {
            $sendResult = SendCIEMEmailMessage -AuthenticationProfile $profile -Channel $channel -Subject $subject -TextBody $textBody -HtmlBody $htmlBody
            SaveCIEMNotificationHistory `
                -NotificationId $notification.Id `
                -ChannelId $channel.Id `
                -SourceSignalId ([string]$change.Id) `
                -SourceSignalType 'ExposureChange' `
                -InvocationSource $InvocationSource `
                -Status 'Succeeded' `
                -AttemptedAt $attemptedAt `
                -MessageId $sendResult.MessageId `
                -RecipientSummary $recipientSummary
            $sentCount++
        }
        catch {
            $failedCount++
            SaveCIEMNotificationHistory `
                -NotificationId $notification.Id `
                -ChannelId $channel.Id `
                -SourceSignalId ([string]$change.Id) `
                -SourceSignalType 'ExposureChange' `
                -InvocationSource $InvocationSource `
                -Status 'Failed' `
                -AttemptedAt $attemptedAt `
                -RecipientSummary $recipientSummary `
                -ErrorMessage $_.Exception.Message
        }
    }

    if ($failedCount -gt 0) {
        throw "notification send failed: $failedCount failure(s)"
    }

    [PSCustomObject]@{
        SentCount    = $sentCount
        FailedCount  = $failedCount
        SkippedCount = $changes.Count - $matchingChanges.Count
    }
}