Notifier.psm1

$script:ModuleRoot = $PSScriptRoot

function Get-Parameter {
    <#
    .SYNOPSIS
        Parses out all parameters on a provided scriptblock.
     
    .DESCRIPTION
        Parses out all parameters on a provided scriptblock.
        Useful when building plugin-based code, as it allows having plugin code selfdocument.
     
    .PARAMETER ScriptBlock
        The scriptblock to process / parse for parameters.
     
    .PARAMETER AsHashtable
        Return the results as a hashtable, rather than as individual objects.
     
    .EXAMPLE
        PS C:\> Get-Parameter -Scriptblock $code -AsHashtable
 
        Returns a hashtable containing all parameters of the provided scriptblock.
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [scriptblock]
        $ScriptBlock,

        [switch]
        $AsHashtable
    )
    process {
        $help = $ScriptBlock.Ast.GetHelpContent()
        $parameters = foreach ($parameter in $ScriptBlock.Ast.ParamBlock.Parameters) {
            $description = ''
            if ($help.Parameters.$($parameter.Name.VariablePath.UserPath.ToUpper())) {
                $description = $help.Parameters.$($parameter.Name.VariablePath.UserPath.ToUpper()).Trim()
            }

            $mandatoryAttribute = $parameter.Attributes.Where{ $_.TypeName.Name -eq 'Parameter' }.NamedArguments.Where{ $_.ArgumentName -eq 'Mandatory' }[0]

            [PSCustomObject]@{
                PSTypeName   = 'Notifier.Parameter'
                Name         = $parameter.Name.VariablePath.UserPath
                Mandatory    = $mandatoryAttribute.Argument.VariablePath.UserPath -eq 'true' -or $mandatoryAttribute.ExpressionOmitted
                Type         = $parameter.Attributes.Where{ $_ -is [System.Management.Automation.Language.TypeConstraintAst] }.TypeName.FullName
                DefaultValue = $parameter.DefaultValue.Value
                Description  = $description
            }
        }

        if (-not $AsHashtable) { return $parameters }

        $results = @{ }
        foreach ($parameter in $parameters) {
            $results[$parameter.Name] = $parameter
        }
        $results
    }
}

function Import-NotificationWorkflow {
    <#
    .SYNOPSIS
        Imports the settings defining whom to notify when.
     
    .DESCRIPTION
        Imports the settings defining whom to notify when.
        This defines under what condition, what provider gets triggered in what way, based on the object triggering the notification.
 
        A workflow file is a psd1 file that will be loaded in an unsafe manner (code gets executed).
        The resulting configuration sets / hashtables will be processed into settings.
 
        Expected Layout of each workflow configuration set:
 
        @{
            WorkflowID = '<workflowid>'
            Subscriptions = @(
                @{ <subscription1> }
                @{ <subscription2> }
                @{ <subscriptionN> }
            )
            SubscriptionPath = '<path>'
            ProviderPath = '<path>'
        }
 
        WorkflowID <string>: The ID of the workflow. When importing multiple workflows, the last with a defined value wins. Providing it is optional, but when invoking the notifications, a WorkflowID is required, either by previous configuration or by parameter.
        Subscriptions <hashtable[]>: A list of subscription configurations. Each subscription configuration needs to match the parameters on Register-NotificationSubscription. For more details on this, see the help on Register-NotificationSubscription.
        SubscriptionPath <string>: The path where additional configuration files for subscriptions are expected and loaded if present. Can be an absolute path or a path relative to the current file's location. We expect psd1 files with one or more hashtables, as if passed via the Subscriptions property.
        ProviderPath <string>: The path where additional notification providers are defined. Can be an absolute path or a path relative to the current file's location. We expect ps1 files that execute their own Register-NotificationProvider - but in effect, you can make it run _any_ kind of code as part of the import.
 
        Each setting is optional, especially as multiple workflow definitions can be merged.
        However, a Workflow ID is necessary for the overall execution and without _any_ subscriptions, nothing happens.
     
    .PARAMETER Path
        Path to the file(s) defining a notification workflow.
     
    .PARAMETER Append
        Whether any loaded settings should be added to existing settings.
        By default, they will replace them instead.
     
    .EXAMPLE
        PS C:\> Import-NotificationWorkflow -Path C:\monitoring\certexpiration\workflow.psd1
 
        Loads the workflow configuration in "C:\monitoring\certexpiration\workflow.psd1"
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PSFFile]
        $Path,

        [switch]
        $Append
    )
    begin {
        if (-not $Append) {
            # Reset all registered settings
            $script:_NotificationSubscriptions = @{ }
            $script:_CurrentWorkflowID = $null
        }

        $fileLoader = [PsfScriptBlock] { & $_ }
    }
    process {
        foreach ($file in $Path) {
            Write-PSFMessage -Message 'Importing Notification Workflow from {0}' -StringValues $file -Target $file
            $data = Import-PSFPowerShellDataFile -Path $file -Psd1Mode Unsafe
            foreach ($datum in $data) {
                if ($datum.WorkflowID) {
                    Write-PSFMessage -Message 'Workflow ID: {0}' -StringValues $datum.WorkflowID -Target $file
                    $script:_CurrentWorkflowID = $datum.WorkflowID
                }
                foreach ($subscription in $datum.Subscriptions) {
                    Invoke-PSFProtectedCommand -Action 'Importing Subscription' -Target $subscription.Name -ScriptBlock {
                        Register-NotificationSubscription @subscription -ErrorAction Stop
                    } -PSCmdlet $PSCmdlet -EnableException $true
                }

                #region Loading additional Subscriptions
                if ($datum.SubscriptionPath) {
                    $uri = $datum.SubscriptionPath -as [uri]
                    if ($uri.IsAbsoluteUri) { $root = $datum.SubscriptionPath }
                    else { $root = Join-Path -Path (Split-Path -Path $file) -ChildPath $datum.SubscriptionPath }

                    Get-ChildItem -Path $root -Recurse | Where-Object Extension -In '.ps1', '.psd1' | ForEach-Object {
                        $currentItem = $_
                        Write-PSFMessage -Message 'Importing subscriptions from: {0}' -StringValues $currentItem.FullName -Target $file
                        try {
                            if ($_.Extension -eq '.ps1') {
                                $fileLoader.InvokeGlobal($_.FullName)
                                return
                            }
    
                            $subscriptions = Import-PSFPowerShellDataFile -LiteralPath $currentItem.FullName -Psd1Mode Unsafe
                            foreach ($subscription in $subscriptions) {
                                Register-NotificationSubscription @subscription
                            }
                        }
                        catch {
                            Write-PSFMessage -Level Error -Message 'Failed to import subscriptions from: {0}' -StringValues $currentItem.FullName -ErrorRecord $_
                            $PSCmdlet.ThrowTerminatingError($_)
                        }
                    }
                }
                #endregion Loading additional Subscriptions

                #region Loading additional Providers
                if ($datum.ProviderPath) {
                    $uri = $datum.ProviderPath -as [uri]
                    if ($uri.IsAbsoluteUri) { $root = $datum.ProviderPath }
                    else { $root = Join-Path -Path (Split-Path -Path $file) -ChildPath $datum.ProviderPath }

                    Get-ChildItem -Path $root -Recurse | Where-Object Extension -In '.ps1' | ForEach-Object {
                        $currentItem = $_
                        Write-PSFMessage -Message 'Importing providers from: {0}' -StringValues $currentItem.FullName -Target $file
                        try {
                            $fileLoader.InvokeGlobal($_.FullName)
                            return
                        }
                        catch {
                            Write-PSFMessage -Level Error -Message 'Failed to import providers from: {0}' -StringValues $currentItem.FullName -ErrorRecord $_
                            $PSCmdlet.ThrowTerminatingError($_)
                        }
                    }
                }
                #endregion Loading additional Providers
            }
        }
    }
}

function Invoke-NotificationWorkflow {
    <#
    .SYNOPSIS
        Executes a configured notification workflow agains the provided object.
     
    .DESCRIPTION
        Executes a configured notification workflow agains the provided object.
        This command expects items by pipeline - providing multiple objects without use of the pipeline will consider them all together as one entry.
 
        Either use "Import-NotificationWorkflow" first to load the notification configuration or provide the path to the notification via Parameter.
 
        See the documentation in "Import-NotificationWorkflow" for how to define workflow configuration files.
     
    .PARAMETER InputObject
        The item to notify over.
     
    .PARAMETER ID
        The ID of the workflow to execute.
        This ID is used to track when last an item was notified over, preventing spam.
        It must be available, but can also be provided via the configuration previously imported.
     
    .PARAMETER Path
        Path to the notification workflow configuration to import.
        Use in case you do not want to import the file as a separate step before calling this command.
     
    .EXAMPLE
        PS C:\> Get-ADUser -Filter * -Properties pwdlastSet | Invoke-NotificationWorkflow
         
        Send notifications for all users that match the previously configured workflows notification settings.
        This assumes you previously called "Import-NotificationWorkflow" to define what should be notified for (and how).
         
    .EXAMPLE
        PS C:\> Get-ADUser -Filter * -Properties pwdlastSet | Invoke-NotificationWorkflow -Path C:\monitoring\passwordexpiration\workflow.psd1
         
        Send notifications for all users that match the workflows notification settings defined in "workflow.psd1".
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object]
        $InputObject,

        [string]
        $ID,

        [PSFFile]
        $Path
    )
    begin {
        if ($Path) {
            Invoke-PSFProtectedCommand -Action 'Loading Workflow configuration from file' -Target ($Path -join ', ') -ScriptBlock {
                Import-NotificationWorkflow -Path $Path
            } -PSCmdlet $PSCmdlet -EnableException $true
        }

        if (-not $ID) { $ID = $script:_CurrentWorkflowID }
        if (-not $ID) { Stop-PSFFunction -Message 'No Workflow ID Provided, cannot notify!' -Cmdlet $PSCmdlet -EnableException $true -Category InvalidOperation }

        $subscriptions = Get-NotificationSubscription
    }
    process {
        Write-PSFMessage -Level SomewhatVerbose -Message 'Processing Item: {0}' -StringValues $InputObject -Target $InputObject -Tag start
        trap {
            Write-PSFMessage -Level Warning -Message 'Failed to process Item: {0}' -StringValues $InputObject -Target $InputObject -Tag fail
            return
        }

        #region Process Subscriptions
        foreach ($subscription in $subscriptions) {
            Write-PSFMessage -Level Debug -Message 'Start processing subscription {0} for {1}' -StringValues $subscription.Name, $InputObject -Target $InputObject -Tag subscription, start

            #region Validation
            # Test Condition
            try { $applies = $subscription.Condition.InvokeGlobal($InputObject) }
            catch {
                Write-PSFMessage -Level Error -Message 'Failed to validate condition on subscription {0} against {1}' -StringValues $subscription.Name, $InputObject -Tag fail, validate -Target $InputObject -ErrorRecord $_ -EnableException $true
                continue
            }

            if (-not $applies) {
                Write-PSFMessage -Level Debug -Message 'Subscription {0} does not apply to {1}' -StringValues $subscription.Name, $InputObject -Tag validate, skip -Target $InputObject
                continue
            }

            # Resolve Identity
            if ($subscription.Identity -is [string]) { $identityValue = $InputObject.$($subscription.Identity) }
            else {
                try { $identityValue = $subscription.Identity.InvokeGlobal($InputObject) -as [string] }
                catch {
                    Write-PSFMessage -Level Error -Message 'Failed to resolve Identity for subscription {0} against {1}' -StringValues $subscription.Name, $InputObject -Tag fail, identity -Target $InputObject -ErrorRecord $_ -EnableException $true
                    continue
                }
            }
            if (-not $identityValue) {
                Write-PSFMessage -Level Error -Message 'Failed to resolve Identity for subscription {0} against {1} | Resolved to NULL' -StringValues $subscription.Name, $InputObject -Tag fail, identity -Target $InputObject
                continue
            }
            $resolvedIdentityValue = $identityValue.ToBase64()

            # Resolve Provider & Execute Parameter resolution
            $providerObject = $script:_NotificationProviders[$subscription.Provider]
            if (-not $providerObject) {
                Write-PSFMessage -Level Error -Message 'Provider {0} specified in subscription {1} was not found!' -StringValues $subscription.Provider, $subscription.Name -Tag fail, provider -Target $InputObject
                continue
            }

            try { $parameters = $providerObject.Resolver.Resolve($InputObject, $false, $subscription.Parameters) }
            catch {
                Write-PSFMessage -Level Error -Message 'Failed to resolve Parameters for subscription {0} against {1} | Resolved to NULL' -StringValues $subscription.Name, $InputObject -Tag fail, parameters -Target $InputObject -ErrorRecord $_ -EnableException $true
                continue
            }
            #endregion Validation

            Send-Notification -Provider $ProviderObject -Subscription $subscription -Identity $resolvedIdentityValue -Parameters $parameters -WorkflowID $ID
        }
        #endregion Process Subscriptions
    }
}

function Send-Notification {
    <#
    .SYNOPSIS
        Sends out a notification as configured, tracking schedule and success.
     
    .DESCRIPTION
        Sends out a notification as configured, tracking schedule and success.
        This function assumes validation and parameter resolution _already have happened._
 
        This is purely for executing the notification flow, bringing together subscription, provider and item.
     
    .PARAMETER Provider
        The provider object implementing the message execution.
        As returned by Get-NotificationProvider.
     
    .PARAMETER Subscription
        The subscription object triggering the notification.
        As returned by Get-NotificationSubscription.
     
    .PARAMETER Identity
        The Identity of the item being notified upon.
        Already resolved and converted into base64.
        Note: With this module loaded, all strings have the "ToBase64" method.
     
    .PARAMETER Parameters
        The fully resolved parameters to use when sending this notification.
     
    .PARAMETER WorkflowID
        The ID of the Workflow this is part of.
        Each notification must have an ID.
     
    .PARAMETER Force
        Force sending a notification, even though no message is due.
        By default, messages are only sent according to the configuration in the Subscription, to prevent spamming notifications.
 
    .EXAMPLE
        PS C:\> Send-Notification -Provider $ProviderObject -Subscription $subscription -Identity $resolvedIdentityValue -Parameters $parameters -WorkflowID $ID
 
        Sends the notification as configured.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PSObject]
        $Provider,

        [Parameter(Mandatory = $true)]
        [PSObject]
        $Subscription,

        [Parameter(Mandatory = $true)]
        [string]
        $Identity,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Parameters,

        [Parameter(Mandatory = $true)]
        [string]
        $WorkflowID,

        [switch]
        $Force
    )
    Begin {
        $rootPath = Get-PSFConfigValue -FullName 'PSFramework.Path.Notifier'
        $workflowPath = Join-Path -Path $rootPath -ChildPath $WorkflowID
        if (-not (Test-Path -Path $workflowPath)) {
            Invoke-PSFProtectedCommand -Action 'Creating Workflow Tracking Folder' -Target $WorkflowID -ScriptBlock {
                $null = New-Item -Path $workflowPath -ItemType Directory -Force -ErrorAction Stop
            } -PSCmdlet $PSCmdlet -EnableException $true
        }

        $msgCommon = @{
            PSCmdlet = $PSCmdlet
            Target   = $Identity
        }
    }
    process {
        $itemPath = Join-Path -Path $workflowPath -ChildPath $Identity
        try { $itemConfig = Import-PSFClixml -Path $itemPath -ErrorAction Stop }
        catch {
            $itemConfig = @{
                Identity    = $Identity
                LastAttempt = $null
                LastError   = $null
                LastSuccess = $null
            }
        }

        if (-not $Force -and $Subscription.OnceOnly -and $itemConfig.LastSuccess) {
            Write-PSFMessage @msgCommon -Level Debug -Message "Notification already sent for {0} on subscription {1} to provider {2}. Skipping" -StringValues $Identity.FromBase64(), $Subscription.Name, $Provider.Name -Tag skip, send, notify
            return
        }
        if (-not $Force -and $Subscription.Interval -and $itemConfig.LastSuccess -and $itemConfig.LastSuccess.Add($Subscription.Interval) -gt (Get-Date)) {
            Write-PSFMessage @msgCommon -Level Debug -Message "Notification recently sent for {0} on subscription {1} to provider {2}. Skipping until {3:yyyy-MM-dd HH:mm}" -StringValues $Identity.FromBase64(), $Subscription.Name, $Provider.Name, $itemConfig.LastSuccess.Add($Subscription.Interval) -Tag skip, send, notify
            return
        }

        try {
            $itemConfig.LastAttempt = Get-Date
            & $Provider.ScriptBlock @Parameters
            $itemConfig.LastSuccess = Get-Date
        }
        catch {
            Write-PSFMessage @msgCommon -Level Warning -Message "Failed to write send notification for {0} on subscription {1} to provider {2}" -StringValues $Identity.FromBase64(), $Subscription.Name, $Provider.Name -Tag fail, send, notify -ErrorRecord $_
            $itemConfig.LastError = $_
        }

        try { $itemConfig | Export-PSFClixml -Path $itemPath -ErrorAction Stop }
        catch {
            Write-PSFMessage @msgCommon -Level Warning -Message "Failed to write status update for {0} on subscription {1} to provider {2}" -StringValues $Identity.FromBase64(), $Subscription.Name, $Provider.Name -Tag fail, export, status -ErrorRecord $_ -EnableException $true
        }
    }
}

function Get-NotificationProvider {
    <#
    .SYNOPSIS
        Lists registered notification providers.
     
    .DESCRIPTION
        Lists registered notification providers.
        Notification providers offer the implenting logic that performs the actual notification.
 
        For more details on how to define one, see the help on "Register-NotificationProvider"
     
    .PARAMETER Name
        The name of the provider to search for.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-NotificationProvider
     
        Lists all registered notification providers.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*'
    )
    process {
        $script:_NotificationProviders.Values | Where-Object Name -Like $Name
    }
}

function Register-NotificationProvider {
    <#
    .SYNOPSIS
        Registers a new notification provider, implementing the logic to send notifications.
     
    .DESCRIPTION
        Registers a new notification provider, implementing the logic to send notifications.
        This is the part that enables sending notifications to email, eventlog, console, or wherever else.
 
        The code should include a param block defining all the input the provider needs.
        Providing parameter help will have that included in the finished provider for better user experience.
 
        Note: This provider does NOT receive the full object being notified over!
        Mapping item properties to parameters happens in the subscription.
 
        In other words, the provider should be fully agnostic over what is being notified about.
     
    .PARAMETER Name
        The name of the provider.
     
    .PARAMETER Description
        A description for the provider.
        Should tell a user what this provider is all about.
     
    .PARAMETER ScriptBlock
        The code implementing the provider.
        Should not be specific to the item being notified over.
        Should include parameters that give it all the information it needs.
     
    .EXAMPLE
        PS C:\> Register-NotificationProvider -Name Mail -Description 'A blank, direct, SMTP send' -ScriptBlock $code
 
        Registers an email-sending provider.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        [string]
        $Description,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $ScriptBlock
    )
    process {
        $script:_NotificationProviders[$Name] = [PSCustomObject]@{
            PSTypeName  = 'Notifier.Provider'
            Name        = $Name
            Description = $Description
            Parameters  = Get-Parameter -ScriptBlock $ScriptBlock -AsHashtable
            ScriptBlock = $ScriptBlock
            Resolver    = $null
        }
        $script:_NotificationProviders[$Name].Resolver = [ParameterResolver]::new($script:_NotificationProviders[$Name].Parameters)
    }
}

function Get-NotificationSubscription {
    <#
    .SYNOPSIS
        Lists registered notification subscriptions.
     
    .DESCRIPTION
        Lists registered notification subscriptions.
        See "Register-NotificationSubscription" on how to define a subscription.
     
    .PARAMETER Name
        The name of the subscription to filter by.
        Defaults to: *
     
    .PARAMETER Provider
        The name of the provider the subscriptions use to filter by.
        Defaults to: *
     
    .EXAMPLE
        PS C:\> Get-NotificationSubscription
 
        Lists all registered notification subscriptions.
    #>

    [CmdletBinding()]
    param (
        [string]
        $Name = '*',

        [string]
        $Provider = '*'
    )
    process {
        $script:_NotificationSubscriptions.Values.Values | Where-Object {
            $_.Name -like $Name -and
            $_.Provider -like $Provider
        }
    }
}

function Register-NotificationSubscription {
    <#
    .SYNOPSIS
        Registers a subscription to get notified, when an applicable object is processed.
     
    .DESCRIPTION
        Registers a subscription to get notified, when an applicable object is processed.
     
    .PARAMETER Name
        Name of the subscription.
        Name must be unique within the subscription for this provider.
     
    .PARAMETER Provider
        Name of the provider doing the notifying.
        Providers implement the notification logic, such as sending emails, pushing message queues, etc.
     
    .PARAMETER Identity
        For each processed object, what is considered its Identity?
        Provide either ...
        - A Property Name as a string
        - A Scriptblock that returns a single string.
        In case of the former, that property from the input object will be used as Identity.
        If the property does not exist, a random GUID will be used.
        In case of the latter, that scriptblock will be executed with a single argument - the object.
 
        The Identity is used to apply notification interval settings, to prevent undesired spamming.
     
    .PARAMETER Description
        Describe your notification subscription.
        Allows including context of WHY you subscribed.
     
    .PARAMETER Parameters
        Parameters to provide to the Provider when sending a subscription.
        Use "Get-NotificationProvider" to check what parameters it requires.
        This hashtable maps parameter name to the value to assign to it, and there are three ways to provide values:
 
        - Plain, static values, that are the same for each notification triggered.
        - "%PropertyName%" - A string enclosed in % symbols uses a property from the object triggering the notification (without the '%')
        - A scriptblock will be dynamically executed for each object processed (unless the parameter on the provider expects a scriptblock, in which case it will be treated as a static value)
 
        Example:
        @{
            Sender = 'monitoring@contoso.com'
            Recipient = '%UserMail%'
            Body = $mailBodyScript
        }
     
    .PARAMETER Condition
        A filter condition to apply to each object potentially notified upon.
        Equivalent to how you would filter using where-Object.
     
    .PARAMETER Interval
        How frequently we are willing to notify.
        This is tracked per identity and prevents spamming a target too frequently.
     
    .PARAMETER OnceOnly
        Whether an identity is notified once only, no matter how often it meets the condition.
     
    .PARAMETER Force
        Overwrite pre-existing subscriptions.
        By default, this command will refuse to accept a new subscription under a provider/name combination already registered.
     
    .EXAMPLE
        PS C:\> Register-NotificationSubscription -Provider mail -Name ExpiringUserCert -Identity Thumbprint -Parameters $param -Condition $certExpiring -Intervall '7.00:00:00'
         
        Registers the subscription "ExpiringUserCert" under the mail provider.
        It will not notify more frequently than once per week.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        [string]
        $Provider,

        [Parameter(Mandatory = $true)]
        $Identity,

        [string]
        $Description,

        [hashtable]
        $Parameters = @{ },

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $Condition,

        [timespan]
        $Interval,

        [switch]
        $OnceOnly,

        [switch]
        $Force
    )
    begin {
        $killIt = $ErrorActionPreference -eq 'Stop'
    }
    process {
        if (-not $script:_NotificationSubscriptions[$Provider]) {
            $script:_NotificationSubscriptions[$Provider] = @{ }
        }

        if (-not $Force -and $script:_NotificationSubscriptions[$Provider][$Name]) {
            Stop-PSFFunction -Message "Notification Subscription exists already: $Provider > $Name" -EnableException $killIt -Cmdlet $PSCmdlet
            return
        }

        if ($Identity -is [scriptblock]) { $effectiveIdentity = [PsfScriptBlock]$Identity }
        elseif ($Identity -is [PsfScriptBlock]) { $effectiveIdentity = $Identity }
        else { $effectiveIdentity = $Identity -as [string] }

        $paramClone = $Parameters.Clone()
        foreach ($key in $($paramClone.Keys)) {
            if ($paramClone[$key] -isnot [scriptblock]) { continue }
            $paramClone[$key] = [PsfScriptBlock]$paramClone[$key]
        }

        $script:_NotificationSubscriptions[$Provider][$Name] = [PSCustomObject]@{
            PSTypeName  = 'Notifier.Subscription'
            Name        = $Name
            Provider    = $Provider
            Identity    = $effectiveIdentity
            Description = $Description
            Parameters  = $paramClone
            Condition   = [PsfScriptblock]$Condition
            Interval    = $Interval
            OnceOnly    = $OnceOnly.ToBool()
        }
    }
}

Set-PSFConfig -FullName 'PSFramework.Path.Notifier' -Value (Join-Path -Path (Get-PSFPath -Name AppData) -ChildPath 'PowerShell/Notifier') -Initialize -Validation string -Description 'Path pointing at where the Notifier module stores its notification history. Used with Get-PSFPath'

# Simplify String Operations
if ("".PSObject.Methods.Name -notcontains 'ToBase64') {
    Update-TypeData -TypeName 'System.String' -MemberType ScriptMethod -MemberName ToBase64 -Value { [convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($this)) }
}
if ("".PSObject.Methods.Name -notcontains 'FromBase64') {
    Update-TypeData -TypeName 'System.String' -MemberType ScriptMethod -MemberName FromBase64 -Value { [System.Text.Encoding]::UTF8.GetString([convert]::FromBase64String($this)) }
}

class ParameterResolver {
    [hashtable]$Parameters

    ParameterResolver([hashtable]$Parameters) {
        $this.Parameters = $Parameters
    }

    [hashtable]Resolve([object]$Data, [bool]$Force, [hashtable]$Parameters) {
        $newHash = $Parameters.Clone()

        foreach ($key in $($newHash.Keys)) {
            $value = $newHash[$key]
            if ($value -is [PsfScriptblock] -and $Parameters.$key.Type -notin 'scriptblock', 'psfscriptblock', 'System.Management.Automation.ScriptBlock', 'PSFramework.Utility.PsfScriptBlock') {
                $newHash[$key] = $value.InvokeEx($true, $Data, $Data, $null, $false, $false, @($Data))
                continue
            }
            if ($value -notlike '%*%') { continue }

            $property = $value -replace '^%|%$'
            if ($Data.PSObject.Properties.Name -contains $property) {
                $newHash[$key] = $Data.$property
            }
            else {
                if ($Force) { continue }
                throw "Parameter not found on input: '$property' (Input: $Data)"
            }
        }

        # Validate Mandatory Parameters
        foreach ($parameter in $this.Parameters.Values) {
            if (-not $parameter.Mandatory) { continue }
            if ($newHash.Keys -contains $parameter.Name) { continue }

            throw "Mandatory Parameter $($parameter.Name) not specified!"
        }

        return $newHash
    }
}

# Providers used to send notifications
$script:_NotificationProviders = @{ }

# Subscriptions that govern what data entry triggers what kind of notification (and when)
$script:_NotificationSubscriptions = @{ }

# The ID of the current workflow
# Workflows are used to persistently identify, track, and manage notification... workflows.
# The module needs to track, what notifications were sent when, and how soon to resend them.
# This information is tracked separately per Workflow, in order to not accidentally overlap.
$script:_CurrentWorkflowID = $null

Register-NotificationProvider -Name Mail -Description 'A blank, direct, SMTP send' -ScriptBlock {
    <#
    .SYNOPSIS
        A blank, direct, SMTP send.
 
    .DESCRIPTION
        A blank, direct, SMTP send.
 
    .PARAMETER From
        The From parameter is required. This parameter specifies the sender's email address. Enter a name (optional) and email address, such as `Name <someone@fabrikam.com>`.
 
    .PARAMETER To
        This parameter specifies the recipient's email address. Enter names (optional) and the email address, such as `Name <someone@fabrikam.com>`.
 
    .PARAMETER Subject
        This parameter specifies the subject of the email message.
 
    .PARAMETER Body
        Specifies the content of the email message.
 
    .PARAMETER SmtpServer
        Specifies the name of the SMTP server that sends the email message.
 
    .PARAMETER Credential
        Who to send as. Defaults to current user.
 
    .PARAMETER Cc
        Specifies the email addresses to which a carbon copy (CC) of the email message is sent. Enter names (optional) and the email address, such as `Name <someone@fabrikam.com>`.
 
    .PARAMETER Bcc
        Specifies the email addresses that receive a copy of the mail but aren't listed as recipients of the message. Enter names (optional) and the email address, such as `Name <someone@fabrikam.com>`.
 
    .PARAMETER BodyAsHtml
        Specifies that the value of the Body parameter contains HTML.
 
    .PARAMETER UseSsl
        The Secure Sockets Layer (SSL) protocol is used to establish a secure connection to the remote computer to send mail. By default, SSL isn't used.
 
    .PARAMETER Port
        Specifies an alternate port on the SMTP server. The default value is 25, which is the default SMTP port.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $From,

        [Parameter(Mandatory = $true)]
        [string[]]
        $To,

        [Parameter(Mandatory = $true)]
        [string]
        $Subject,

        [Parameter(Mandatory = $true)]
        [string]
        $Body,

        [Parameter(Mandatory = $true)]
        [string]
        $SmtpServer,

        [pscredential]
        $Credential,

        [string[]]
        $Cc,

        [string[]]
        $Bcc,

        [bool]
        $BodyAsHtml,

        [bool]
        $UseSsl,

        [int]
        $Port
    )

    $param = $PSBoundParameters | ConvertTo-PSFHashtable
    Send-MailMessage @param -WarningAction SilentlyContinue
}

Register-NotificationProvider -Name MDMail -Description 'Send emails using the MailDaemon module. Install and configure module separately!' -ScriptBlock {
    <#
    .SYNOPSIS
        A superior way to send email, using the MailDaemon module.
 
    .DESCRIPTION
        A superior way to send email, using the MailDaemon module.
 
    .PARAMETER TaskName
        Name of the task that is sending the email.
        For ease of tracking in the logs.
 
    .PARAMETER From
        The email address of the sender.
 
    .PARAMETER To
        The email address to send to.
 
    .PARAMETER Subject
        The subject to send the email under.
 
    .PARAMETER Body
        The body of the email to send.
 
    .PARAMETER Cc
        Additional addresses to keep in the information flow.
 
    .PARAMETER Bcc
        Additional addresses to keep silently informed.
 
    .PARAMETER BodyAsHtml
        Whether the body is to be understood as html text.
    #>

    [CmdletBinding()]
    param (
        [string]
        $TaskName = (New-Guid),

        [string]
        $From,

        [Parameter(Mandatory = $true)]
        [string[]]
        $To,

        [Parameter(Mandatory = $true)]
        [string]
        $Subject,

        [Parameter(Mandatory = $true)]
        [string]
        $Body,

        [string[]]
        $Cc,

        [string[]]
        $Bcc,

        [bool]
        $BodyAsHtml
    )

    $param = $PSBoundParameters | ConvertTo-PSFHashtable -ReferenceCommand Set-MDMail
    Set-MDMail @param
    Send-MDMail -TaskName $TaskName
}