GraphAppToolkit.psm1

#Region '.\Classes\TkEmailAppParams.ps1' -1

class TkEmailAppParams {
    [string]$AppId
    [string]$Id
    [string]$AppName
    [string]$AppRestrictedSendGroup
    [string]$CertExpires
    [string]$CertThumbprint
    [string]$ConsentUrl
    [string]$DefaultDomain
    [string]$SendAsUser
    [string]$SendAsUserEmail
    [string]$TenantID
    # Constructor
    TkEmailAppParams(
        [string]$AppId,
        [string]$Id,
        [string]$AppName,
        [string]$AppRestrictedSendGroup,
        [string]$CertExpires,
        [string]$CertThumbprint,
        [string]$ConsentUrl,
        [string]$DefaultDomain,
        [string]$SendAsUser,
        [string]$SendAsUserEmail,
        [string]$TenantID
    ) {
        $this.AppId                  = $AppId
        $this.Id                     = $Id
        $this.AppName                = $AppName
        $this.AppRestrictedSendGroup = $AppRestrictedSendGroup
        $this.CertExpires            = $CertExpires
        $this.CertThumbprint         = $CertThumbprint
        $this.ConsentUrl             = $ConsentUrl
        $this.DefaultDomain          = $DefaultDomain
        $this.SendAsUser             = $SendAsUser
        $this.SendAsUserEmail        = $SendAsUserEmail
        $this.TenantID               = $TenantID
    }
    # (Optional) A helper method that converts CertExpires to a DateTime object
    [DateTime] GetCertExpiresAsDateTime() {
        return [DateTime]::Parse($this.CertExpires)
    }
}
#EndRegion '.\Classes\TkEmailAppParams.ps1' 44
#Region '.\Classes\TkM365AuditAppParams.ps1' -1

class TkM365AuditAppParams {
    [string]$AppName
    [string]$AppId
    [string]$ObjectId
    [string]$TenantId
    [string]$CertThumbprint
    [string]$CertExpires
    [string]$ConsentUrl
    [string]$MgGraphPermissions
    [string]$SharePointPermissions
    [string]$ExchangePermissions
    # Constructor
    TkM365AuditAppParams(
        [string]$AppName,
        [string]$AppId,
        [string]$ObjectId,
        [string]$TenantId,
        [string]$CertThumbprint,
        [string]$CertExpires,
        [string]$ConsentUrl,
        [string]$MgGraphPermissions,
        [string]$SharePointPermissions,
        [string]$ExchangePermissions
    ) {
        $this.AppName               = $AppName
        $this.AppId                 = $AppId
        $this.ObjectId              = $ObjectId
        $this.TenantId              = $TenantId
        $this.CertThumbprint        = $CertThumbprint
        $this.CertExpires           = $CertExpires
        $this.ConsentUrl            = $ConsentUrl
        $this.MgGraphPermissions    = $MgGraphPermissions
        $this.SharePointPermissions = $SharePointPermissions
        $this.ExchangePermissions   = $ExchangePermissions
    }
    [DateTime] GetCertExpiresAsDateTime() {
        return [DateTime]::Parse($this.CertExpires)
    }
    # (Optional) Helper methods to split space-delimited permissions into arrays:
    [string[]]GetMgGraphPermissionsArray() {
        return $this.MgGraphPermissions -split '\s+'
    }
    [string[]]GetSharePointPermissionsArray() {
        return $this.SharePointPermissions -split '\s+'
    }
    [string[]]GetExchangePermissionsArray() {
        return $this.ExchangePermissions -split '\s+'
    }
}
#EndRegion '.\Classes\TkM365AuditAppParams.ps1' 50
#Region '.\Classes\TkMemPolicyManagerAppParams .ps1' -1

class TkMemPolicyManagerAppParams  {
    [string]$AppId
    [string]$AppName
    [string]$CertThumbprint
    [string]$ClientId
    [string]$ConsentUrl
    [string]$PermissionSet
    [string]$Permissions
    [string]$TenantId
    # Constructor
    TkMemPolicyManagerAppParams (
        [string]$AppId,
        [string]$AppName,
        [string]$CertThumbprint,
        [string]$ClientId,
        [string]$ConsentUrl,
        [string]$PermissionSet,
        [string]$Permissions,
        [string]$TenantId
    ) {
        $this.AppId          = $AppId
        $this.AppName        = $AppName
        $this.CertThumbprint = $CertThumbprint
        $this.ClientId       = $ClientId
        $this.ConsentUrl     = $ConsentUrl
        $this.PermissionSet  = $PermissionSet
        $this.Permissions    = $Permissions
        $this.TenantId       = $TenantId
    }

    # (Optional) Helper method to split the Permissions string into an array:
    [string[]] GetPermissionsArray() {
        return $this.Permissions -split '\s+'
    }
}
#EndRegion '.\Classes\TkMemPolicyManagerAppParams .ps1' 36
#Region '.\Private\Connect-TkMsService.ps1' -1

function Connect-TkMsService {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param (
        [Parameter(
            HelpMessage = 'Connect to Microsoft Graph.'
        )]
        [Switch]
        $MgGraph,
        [Parameter(
            HelpMessage = 'Connect to Exchange Online.'
        )]
        [Switch]
        $ExchangeOnline
    )
    # Begin Logging
    if (-not $script:LogString) {
        Write-AuditLog -Start
    }
    else {
        Write-AuditLog -BeginFunction
    }
    Write-AuditLog '###############################################'
    #----------------------------------------------
    # Section 1: Microsoft Graph
    #----------------------------------------------
    if ($MgGraph) {
        if ($PSCmdlet.ShouldProcess(
                'Microsoft Graph',
                'Connecting with scopes: Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory'
            )) {
            try {
                # 1) Attempt to see if we have a valid Graph session
                $graphIsValid = $false
                try {
                    # If this succeeds, presumably we have a valid token/context
                    Get-MgUser -Top 1 -ErrorAction Stop | Out-Null
                    $ContextMg = Get-MgContext -ErrorAction Stop
                    # Check required scopes
                    $scopesNeeded = @(
                        'Application.ReadWrite.All',
                        'DelegatedPermissionGrant.ReadWrite.All',
                        'Directory.ReadWrite.All',
                        'RoleManagement.ReadWrite.Directory'
                    )
                    $missing = $scopesNeeded | Where-Object { $ContextMg.Scopes -notcontains $_ }
                    if ($missing) {
                        Write-AuditLog "The following needed scopes are missing: $($missing -join ', ')"
                    }
                    else {
                        Write-AuditLog 'An active Microsoft Graph session is detected and all required scopes are present.'
                        $graphIsValid = $true
                    }
                }
                catch {
                    # Either no session or it's invalid/expired
                    $graphIsValid = $false
                }
                # 2) If valid session, ask user if they want to reuse it
                if ($graphIsValid) {
                    $null = $useExisting = Read-Host 'Do you want to use the existing Microsoft Graph session? (Y/N)'
                    if ($useExisting -match '^[Yy]') {
                        Write-AuditLog 'Using existing Microsoft Graph session.'
                    }
                    else {
                        # Remove the old context so we can connect fresh
                        Remove-MgContext -ErrorAction SilentlyContinue
                        Write-AuditLog 'Creating a new Microsoft Graph session.'
                        Connect-MgGraph -Scopes `
                            'Application.ReadWrite.All', `
                            'DelegatedPermissionGrant.ReadWrite.All', `
                            'Directory.ReadWrite.All', `
                            'RoleManagement.ReadWrite.Directory' `
                            -ErrorAction Stop
                        Write-AuditLog 'Connected to Microsoft Graph.'
                    }
                }
                else {
                    # No valid session, so just connect
                    Write-AuditLog 'No valid Microsoft Graph session found. Connecting...'
                    Connect-MgGraph -Scopes `
                        'Application.ReadWrite.All', `
                        'DelegatedPermissionGrant.ReadWrite.All', `
                        'Directory.ReadWrite.All', `
                        'RoleManagement.ReadWrite.Directory' `
                        -ErrorAction Stop
                    Write-AuditLog 'Connected to Microsoft Graph.'
                }
            }
            catch {
                Write-AuditLog -Severity Error -Message "Error connecting to Microsoft Graph. Error: $($_.Exception.Message)"
                throw
            }
        }
    }
    #----------------------------------------------
    # Section 2: Exchange Online
    #----------------------------------------------
    if ($ExchangeOnline) {
        if ($PSCmdlet.ShouldProcess(
                'Exchange Online',
                'Connecting to ExchangeOnline using modern authentication pop-up.'
            )) {
            try {
                # 1) Attempt to see if we have a valid Exchange session
                $exoIsValid = $false
                try {
                    $Org = (Get-OrganizationConfig -ErrorAction Stop).Identity
                    $exoIsValid = $true
                }
                catch {
                    # Either no session or it's invalid/expired
                    $exoIsValid = $false
                }
                # 2) If valid session, ask user if they want to reuse it
                if ($exoIsValid) {
                    Write-AuditLog 'An active Exchange Online session is detected.'
                    Write-AuditLog "Tenant: `n$Org`n"
                    $null = $useExisting = Read-Host 'Do you want to use the existing Exchange Online session? (Y/N)'
                    if ($useExisting -match '^[Yy]') {
                        Write-AuditLog 'Using existing Exchange Online session.'
                    }
                    else {
                        # Disconnect old session
                        Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
                        Write-AuditLog 'Creating new Exchange Online session.'
                        Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
                        Write-AuditLog 'Connected to Exchange Online.'
                    }
                }
                else {
                    Write-AuditLog 'No valid Exchange Online session found. Connecting...'
                    Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
                    Write-AuditLog 'Connected to Exchange Online.'
                }
            }
            catch {
                Write-AuditLog -Severity Error -Message "Error connecting to Exchange Online. Error: $($_.Exception.Message)"
                throw
            }
        }
    }
    Write-AuditLog -EndFunction
}
#EndRegion '.\Private\Connect-TkMsService.ps1' 144
#Region '.\Private\ConvertTo-ParameterSplat.ps1' -1

function ConvertTo-ParameterSplat {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [PSObject]$InputObject
    )
    process {
        $splatScript = "`$params = @{`n"
        $InputObject.psobject.Properties | ForEach-Object {
            $value = $_.Value
            if ($value -is [string]) {
                $value = "`"$value`""
            }
            $splatScript += " $($_.Name) = $value`n"
        }
        $splatScript += "}"
        Write-Output $splatScript
    }
}
#EndRegion '.\Private\ConvertTo-ParameterSplat.ps1' 21
#Region '.\Private\Initialize-TkAppAuthCertificate.ps1' -1

<#
    .SYNOPSIS
        Retrieves or creates a self-signed certificate in the specified store.
    .DESCRIPTION
        The Initialize-TkAppAuthCertificate function either retrieves a certificate by thumbprint from
        the specified store or creates a new self-signed certificate if no thumbprint is provided.
        It returns a PSCustomObject containing the certificate's thumbprint, expiration date,
        and an optional AppName (to maintain compatibility with existing usage).
    .PARAMETER Thumbprint
        The thumbprint of the certificate to retrieve. If omitted, a new self-signed certificate
        is created.
    .PARAMETER AppName
        An optional name for the application or usage context of this certificate.
        This is used to populate the "AppName" property in the returned object if needed.
    .PARAMETER Subject
        The certificate subject, for example: "CN=MyNewAppCert". Defaults to "CN=DefaultSelfSignedCert"
        if no thumbprint is provided.
    .PARAMETER CertStoreLocation
        The certificate store path (e.g., "Cert:\CurrentUser\My" or "Cert:\LocalMachine\My").
        Defaults to "Cert:\CurrentUser\My".
    .EXAMPLE
        # Retrieve an existing cert by thumbprint
        Initialize-TkAppAuthCertificate -Thumbprint "9B8B40C5F148B710AD5C0E5CC8D0B71B5A30DB0C"
    .EXAMPLE
        # Create a new self-signed cert for a specific application name
        Initialize-TkAppAuthCertificate -AppName "MyGraphApp" -Subject "CN=MyGraphAppCert"
        Returns an object containing AppName, CertThumbprint, and expiration info.
    .OUTPUTS
        PSCustomObject with:
            - CertThumbprint
            - CertExpires
            - AppName (if provided)
    .NOTES
        Author: DrIOSx
        Requires: Write-AuditLog
        The user must have permission to create or retrieve certificates from the specified store.
#>

function Initialize-TkAppAuthCertificate {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The thumbprint of the certificate to retrieve. If omitted, a new self-signed certificate is created.'
        )]
        [string]
        $Thumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'An optional name to store in the output object (e.g., the associated app name).'
        )]
        [string]
        $AppName,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The subject name for the new certificate if no thumbprint is provided.'
        )]
        [string]
        $Subject = 'CN=TkDefaultSelfSignedCert',
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The certificate store location (e.g., "Cert:\CurrentUser\My").'
        )]
        [string]
        $CertStoreLocation = 'Cert:\CurrentUser\My',
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Exportable key policy for the new certificate.'
        )]
        [ValidateSet('Exportable', 'NonExportable')]
        [string]
        $KeyExportPolicy = 'NonExportable'
    )
    if (-not $script:LogString) {
        Write-AuditLog -Start
    }
    else {
        Write-AuditLog -BeginFunction
    }
    Write-AuditLog '###############################################'
    try {
        if ($Thumbprint) {
            # Retrieve an existing certificate
            $Cert = Get-ChildItem -Path $CertStoreLocation | Where-Object { $_.Thumbprint -eq $Thumbprint }
            if (-not $Cert) {
                throw "Certificate with thumbprint $Thumbprint not found in $CertStoreLocation."
            }
            Write-AuditLog "Retrieved certificate with thumbprint $Thumbprint from $CertStoreLocation."
        }
        else {
            # Prompt before creating a new certificate
            if ($PSCmdlet.ShouldProcess($Subject, "Create new self-signed certificate in $CertStoreLocation")) {
                $Cert = New-SelfSignedCertificate -Subject $Subject -CertStoreLocation $CertStoreLocation `
                    -KeyExportPolicy $KeyExportPolicy -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256
                Write-AuditLog "Created new self-signed certificate with subject '$Subject' in $CertStoreLocation."
            }
            else {
                Write-AuditLog "Certificate creation was skipped by user confirmation."
                throw 'Certificate creation was skipped by user confirmation.'
            }
        }
        $output = [PSCustomObject]@{
            CertThumbprint = $Cert.Thumbprint
            CertExpires    = $Cert.NotAfter.ToString('yyyy-MM-dd HH:mm:ss')
        }
        if ($AppName) {
            $output | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $AppName
        }
        return $output
    }
    catch {
        throw
    }
    finally {
        Write-AuditLog -EndFunction
    }
}

#EndRegion '.\Private\Initialize-TkAppAuthCertificate.ps1' 118
#Region '.\Private\Initialize-TkAppSpRegistration.ps1' -1

function Initialize-TkAppSpRegistration {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The App Registration object.'
        )]
        $AppRegistration,
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The Graph Service Principal Id.'
        )]
        [PSCustomObject[]]$RequiredResourceAccessList,
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The Azure context.'
        )]
        [PSCustomObject]$Context,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'One or more OAuth2 scopes to grant. Defaults to Mail.Send.'
        )]
        [psobject[]]$Scopes = [PSCustomObject]@{
            Graph = @('Mail.Send')
        },
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Auth method (placeholder). Currently only "Certificate" is used.'
        )]
        [ValidateSet('Certificate', 'ClientSecret', 'ManagedIdentity', 'None')]
        [string]$AuthMethod = 'Certificate',
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Certificate thumbprint if using Certificate-based auth.'
        )]
        [string]$CertThumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The certificate store location (e.g., "Cert:\CurrentUser\My").'
        )]
        [string]
        $CertStoreLocation = 'Cert:\CurrentUser\My'
    )
    begin {
        if (-not $script:LogString) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        Write-AuditLog '###############################################'
        if ($AuthMethod -eq 'Certificate' -and -not $CertThumbprint) {
            throw "CertThumbprint is required when AuthMethod is 'Certificate'."
        }
    }
    process {
        try {
            # 1. If using certificate auth, retrieve the certificate
            $Cert = $null
            if ($AuthMethod -eq 'Certificate') {
                Write-AuditLog "Retrieving certificate with thumbprint $CertThumbprint."
                $Cert = Get-ChildItem -Path $CertStoreLocation | Where-Object { $_.Thumbprint -eq $CertThumbprint }
                if (-not $Cert) {
                    throw "Certificate with thumbprint $CertThumbprint not found in $CertStoreLocation."
                }
            }
            # 2. Create a Service Principal for the app (if not existing).
            Write-AuditLog "Creating service principal for app with AppId $($AppRegistration.AppId)."
            [void](New-MgServicePrincipal -AppId $AppRegistration.AppId -AdditionalProperties @{})
            # 3. Get the client Service Principal for the created app.
            $ClientSp = Get-MgServicePrincipal -Filter "appId eq '$($AppRegistration.AppId)'"
            if (-not $ClientSp) {
                Write-AuditLog "Client service principal not found for $($AppRegistration.AppId)." -Severity Error
                throw 'Unable to find client service principal.'
            }
            $i = 0
            foreach ($Resource in $RequiredResourceAccessList) {
                # 4. Combine all scopes into a single space-delimited string
                switch ($i) {
                    0 { $ScopesList = $Scopes.Graph
                        $ResourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'").Id
                    }
                    1 { $ScopesList = $Scopes.SharePoint
                        $ResourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Office 365 SharePoint Online'").Id
                    }
                    2 { $ScopesList = $Scopes.Exchange
                        $ResourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Office 365 Exchange Online'").Id
                    }
                    ($i > 2) { throw 'Too many resources in RequiredResourceAccessList.' }
                    Default { Write-AuditLog "No scopes found for $Resource." }
                }
                $combinedScopes = $ScopesList -join ' '
                # Foreach resource id start
                Write-AuditLog "Granting the following scope(s) to Service Principal $($ClientSp.DisplayName): $combinedScopes"
                $Params = @{
                    ClientId    = $ClientSp.Id
                    ConsentType = 'AllPrincipals'
                    ResourceId  = $ResourceId
                    Scope       = $combinedScopes
                }
                [void](New-MgOauth2PermissionGrant -BodyParameter $Params -Confirm:$false -ErrorAction Stop)
                Write-AuditLog "Admin consent granted for $ResourceId with scopes: $combinedScopes."
                Start-Sleep -Seconds 2
                $i++
            }
                # 5. Build the admin consent URL
                $adminConsentUrl = 'https://login.microsoftonline.com/' + $Context.TenantId + '/adminconsent?client_id=' + $AppRegistration.AppId
                Write-Verbose 'Please go to the following URL in your browser to provide admin consent:' -Verbose
                Write-AuditLog  "`n$adminConsentUrl`n" -Severity information
            # For each end
            Write-Verbose 'After providing admin consent, you can use the following command for certificate-based auth:' -Verbose
            if ($AuthMethod -eq 'Certificate') {
                $connectGraph = 'Connect-MgGraph -ClientId "' + $AppRegistration.AppId + '" -TenantId "' +
                $Context.TenantId + '" -CertificateName "' + $Cert.SubjectName.Name + '"'
                Write-AuditLog "`n$connectGraph`n" -Severity Information
            }
            else {
                # Placeholder for other auth methods
                Write-AuditLog "Future logic for $AuthMethod auth can go here."
                throw "AuthMethod $AuthMethod is not yet implemented."
            }
            return $adminConsentUrl
        }
        catch {
            throw
        }
    }
    end {
        Write-AuditLog -EndFunction
    }
}
#EndRegion '.\Private\Initialize-TkAppSpRegistration.ps1' 132
#Region '.\Private\Initialize-TkModuleEnv.ps1' -1

<#
    .SYNOPSIS
        Installs or updates required PowerShell modules, with support for stable or pre-release versions.
    .DESCRIPTION
        The Initialize-TkModuleEnv function handles module installation and importing in a flexible manner.
        It checks for PowerShellGet (and updates it if needed), adjusts the function limit if the Microsoft.Graph
        module is included, and can install modules for either the CurrentUser or AllUsers scope. It supports
        both stable (Public) and pre-release modules, and optionally imports specific modules by name.
        Logging is handled via Write-AuditLog, and administrative privileges are required for certain operations
        (e.g., installing modules for AllUsers).
    .PARAMETER PublicModuleNames
        An array of stable module names to install when using the 'Public' parameter set.
    .PARAMETER PublicRequiredVersions
        An array of required stable module versions corresponding to each name in PublicModuleNames.
    .PARAMETER PrereleaseModuleNames
        An array of pre-release module names to install when using the 'Prerelease' parameter set.
    .PARAMETER PrereleaseRequiredVersions
        An array of required pre-release module versions corresponding to each name in PrereleaseModuleNames.
    .PARAMETER Scope
        Specifies whether to install the modules for the CurrentUser or AllUsers.
        Accepts 'CurrentUser' or 'AllUsers'. Requires administrative privileges for 'AllUsers'.
    .PARAMETER ImportModuleNames
        An optional list of modules to selectively import after installation. If not specified, all installed modules
        are imported.
    .EXAMPLE
        Initialize-TkModuleEnv -PublicModuleNames "PsNmap", "Microsoft.Graph" -PublicRequiredVersions "1.3.1","1.23.0" -Scope AllUsers
        Installs PsNmap and Microsoft.Graph in the AllUsers scope with the specified versions.
    .EXAMPLE
        $params1 = @{
            PublicModuleNames = "PSnmap","Microsoft.Graph"
            PublicRequiredVersions = "1.3.1","1.23.0"
            ImportModuleNames = "Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.SignIns"
            Scope = "CurrentUser"
        }
        Initialize-TkModuleEnv @params1
        Installs and imports specific modules for Microsoft.Graph.
    .EXAMPLE
        $params2 = @{
            PrereleaseModuleNames = "Sampler", "Pester"
            PrereleaseRequiredVersions = "2.1.5", "4.10.1"
            Scope = "CurrentUser"
        }
        Initialize-TkModuleEnv @params2
        Installs the pre-release versions of Sampler and Pester in the CurrentUser scope.
    .INPUTS
        None. You cannot pipe input into this function.
    .OUTPUTS
        None. This function does not return objects to the pipeline.
    .NOTES
        Author: DrIOSx
        Requires: Write-AuditLog, Test-IsAdmin
        - This function checks for and updates PowerShellGet if needed.
        - It sets the function limit to 8192 if the Microsoft.Graph module is included and PowerShell is 5.1.
        - If the user lacks administrative privileges but tries to install to AllUsers, it throws an error.
#>

function Initialize-TkModuleEnv {
    [CmdletBinding(DefaultParameterSetName = 'Public')]
    param(
        [Parameter(
            ParameterSetName = 'Public',
            Mandatory
        )]
        [string[]]
        $PublicModuleNames,
        [Parameter(
            ParameterSetName = 'Public',
            Mandatory
        )]
        [string[]]
        $PublicRequiredVersions,
        [Parameter(
            ParameterSetName = 'Prerelease',
            Mandatory
        )]
        [string[]]
        $PrereleaseModuleNames,
        [Parameter(
            ParameterSetName = 'Prerelease',
            Mandatory
        )]
        [string[]]
        $PrereleaseRequiredVersions,
        [ValidateSet('AllUsers', 'CurrentUser')]
        [string]
        $Scope,
        [string[]]
        $ImportModuleNames = $null
    )
    if (-not $script:LogString) { Write-AuditLog -Start }else { Write-AuditLog -BeginFunction }
    Write-AuditLog '###########################################################'
    try {
        # If Microsoft.Graph is being installed, raise function limit if < 8192.
        if (($PublicModuleNames -match 'Microsoft.Graph') -or ($PrereleaseModuleNames -match 'Microsoft.Graph')) {
            if ($script:MaximumFunctionCount -lt 8192) {
                $script:MaximumFunctionCount = 8192
            }
        }
        # Step 1: Check/Update PowerShellGet if needed
        $psGetModules = Get-Module -Name PowerShellGet -ListAvailable
        $hasNonDefaultVer = $false
        foreach ($mod in $psGetModules) {
            if ($mod.Version -ne '1.0.0.1') {
                $hasNonDefaultVer = $true
                break
            }
        }
        if ($hasNonDefaultVer) {
            # Import the latest version
            $latestModule = $psGetModules | Sort-Object Version -Descending | Select-Object -First 1
            Import-Module -Name $latestModule.Name -RequiredVersion $latestModule.Version -ErrorAction Stop
        }
        else {
            if (-not(Test-IsAdmin)) {
                Write-AuditLog 'PowerShellGet is version 1.0.0.1. Please run once as admin to update PowerShellGet.' -Severity Error
                throw 'Elevation required to update PowerShellGet!'
            }
            else {
                Write-AuditLog 'Updating PowerShellGet...'
                [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
                Install-Module PowerShellGet -AllowClobber -Force -ErrorAction Stop
                $psGetModules = Get-Module -Name PowerShellGet -ListAvailable
                $latestModule = $psGetModules | Sort-Object Version -Descending | Select-Object -First 1
                Import-Module -Name $latestModule.Name -RequiredVersion $latestModule.Version -ErrorAction Stop
            }
        }
        # Step 2: Validate scope
        if ($Scope -eq 'AllUsers') {
            if (-not(Test-IsAdmin)) {
                Write-AuditLog "You must be an administrator to install in 'AllUsers' scope." -Severity Error
                throw "Elevation required for 'AllUsers' scope."
            }
            else {
                Write-AuditLog "Installing modules for 'AllUsers' scope."
            }
        }
        # Step 3: Determine module set
        $prerelease = $false
        if ($PSCmdlet.ParameterSetName -eq 'Public') {
            $modules = $PublicModuleNames
            $versions = $PublicRequiredVersions
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'Prerelease') {
            $modules = $PrereleaseModuleNames
            $versions = $PrereleaseRequiredVersions
            $prerelease = $true
        }
        # Step 4: Install/Import each module
        foreach ($m in $modules) {
            $requiredVersion = $versions[$modules.IndexOf($m)]
            $installed = Get-Module -Name $m -ListAvailable | Where-Object { [version]$_.Version -ge [version]$requiredVersion } | Sort-Object Version -Descending | Select-Object -First 1
            $SelectiveImports = $null
            if ($ImportModuleNames) {
                $SelectiveImports = $ImportModuleNames | Where-Object { $_ -match $m }
            }
            if (-not $installed) {
                $msgPrefix = if ($prerelease) { 'PreRelease' }else { 'stable' }
                Write-AuditLog "The $msgPrefix module $m version $requiredVersion (or higher) is not installed." -Severity Warning
                Write-AuditLog "Installing $m version $requiredVersion -AllowPrerelease:$prerelease."
                Install-Module $m -Scope $Scope -RequiredVersion $requiredVersion -AllowPrerelease:$prerelease -ErrorAction Stop
                Write-AuditLog "$m module successfully installed!"
                if ($SelectiveImports) {
                    foreach ($ModName in $SelectiveImports) {
                        Write-AuditLog "Importing $ModName."
                        Import-Module $ModName -ErrorAction Stop
                        Write-AuditLog "Successfully imported $ModName."
                    }
                }
                else {
                    Write-AuditLog "Importing $m"
                    Import-Module $m -ErrorAction Stop
                    Write-AuditLog "Successfully imported $m"
                }
            }
            else {
                Write-AuditLog "$m v$($installed.Version) exists."
                if ($SelectiveImports) {
                    foreach ($ModName in $SelectiveImports) {
                        Write-AuditLog "Importing SubModule: $ModName."
                        Import-Module $ModName -ErrorAction Stop
                        Write-AuditLog "Imported SubModule: $ModName."
                    }
                }
                else {
                    Write-AuditLog "Importing $m"
                    Import-Module $m -ErrorAction Stop
                    Write-AuditLog "Imported $m"
                }
            }
        }
    }
    catch {
        throw
    }
    finally {
        Write-AuditLog -EndFunction
    }
}
#EndRegion '.\Private\Initialize-TkModuleEnv.ps1' 198
#Region '.\Private\New-TkAppName.ps1' -1

function New-TkAppName {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory=$true,
            HelpMessage='A short prefix for your app name (2-4 alphanumeric chars).'
        )]
        [ValidatePattern('^[A-Z0-9]{2,4}$')]
        [string]
        $Prefix,
        [Parameter(
            Mandatory=$false,
            HelpMessage='Optional scenario name (e.g. AuditGraphEmail, MemPolicy, etc.).'
        )]
        [string]
        $ScenarioName = "GraphApp",
        [Parameter(
            Mandatory=$false,
            HelpMessage='Optional user email to append "As-[username]" suffix.'
        )]
        [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
        [string]
        $UserId
    )
    begin {
        if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction }
    }
    process {
        try {
            Write-AuditLog "Building app name..."
            # Build a user suffix if $UserId is provided
            $userSuffix = ""
            if ($UserId) {
                # e.g. "helpdesk@mydomain.com" -> "As-helpDesk"
                $userPrefix = ($UserId.Split('@')[0])
                $userSuffix = "-As-$userPrefix"
            }
            # Example final: GraphToolKit-MSN-GraphApp-MyDomain-As-helpDesk
            # But you can do anything you want with $env:USERDNSDOMAIN, etc.
            $domainSuffix = $env:USERDNSDOMAIN
            if (-not $domainSuffix) {
                # fallback if not set
                $domainSuffix = "MyDomain"
            }
            $appName = "GraphToolKit-$Prefix-$ScenarioName-$domainSuffix$userSuffix"
            Write-AuditLog "Returning app name: $appName"
            return $appName
        }
        catch {
            throw
        }
        finally {
            Write-AuditLog -EndFunction
        }
    }
}
#EndRegion '.\Private\New-TkAppName.ps1' 57
#Region '.\Private\New-TkAppRegistration.ps1' -1

<#
    .SYNOPSIS
        Creates a new enterprise application registration in Azure AD with a specified certificate.
    .DESCRIPTION
        The New-TkAppRegistration function creates a new Azure AD application registration (sometimes called
        an enterprise app) using Microsoft Graph. It sets the sign-in audience, attaches a certificate for authentication,
        and configures one or more application permission IDs for the specified resource (e.g., Microsoft Graph).
        Logging is handled by the Write-AuditLog function, and the newly created application object is returned.
    .PARAMETER DisplayName
        The display name for the new app registration.
    .PARAMETER CertThumbprint
        The thumbprint of the certificate used to secure this app, located in the CurrentUser certificate store.
    .PARAMETER ResourceAppId
        The Azure AD resource (for example, the Microsoft Graph app ID: 00000003-0000-0000-c000-000000000000).
    .PARAMETER PermissionIds
        One or more permission IDs (application permissions) to grant for the resource. For example, "Mail.Send".
    .PARAMETER SignInAudience
        The sign-in audience for the app registration. Valid values are "AzureADMyOrg", "AzureADMultipleOrgs",
        and "AzureADandPersonalMicrosoftAccount". Defaults to "AzureADMyOrg".
    .EXAMPLE
        PS C:\> New-TkAppRegistration -DisplayName "MyEnterpriseApp" -CertThumbprint "AABBCCDDEEFF1122" -ResourceAppId "00000003-0000-0000-c000-000000000000" -PermissionIds "Mail.Send"
        Creates a new Azure AD application named "MyEnterpriseApp", attaches the specified certificate, targets the Microsoft Graph
        resource (AppId 00000003-0000-0000-c000-000000000000), and grants the "Mail.Send" permission.
    .INPUTS
        None. You cannot pipe input to this function.
    .OUTPUTS
        Microsoft.Graph.PowerShell.Models.MicrosoftGraphApplication
        Returns the newly created Azure AD application registration object.
    .NOTES
        Author: DrIOSx
        Requires: Microsoft.Graph PowerShell module, Write-AuditLog function
        The user must have permissions in Azure AD to create and manage applications.
#>

function New-TkAppRegistration {
    [CmdletBinding()]
    param (
        [Parameter(
            Mandatory = $true,
            HelpMessage = `
                'The display name for the new app registration.'
        )]
        [string]
        $DisplayName,
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'Pass an array of MicrosoftGraphRequiredResourceAccess objects for multi-resource mode.'
        )]
        [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess[]]
        $RequiredResourceAccessList,
        [Parameter(
            HelpMessage = `
                'The sign-in audience for the app registration.'
        )]
        [ValidateSet('AzureADMyOrg', 'AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount')]
        [string]
        $SignInAudience = 'AzureADMyOrg',
        [Parameter(
            Mandatory = $true,
            HelpMessage = `
                'The thumbprint of the certificate used to secure this app.'
        )]
        [string]
        $CertThumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'The certificate store location (e.g., "Cert:\CurrentUser\My").'
        )]
        [string]
        $CertStoreLocation = 'Cert:\CurrentUser\My',
        [Parameter(
            Mandatory = $false,
            HelpMessage = "A descriptive note about this app's purpose or usage."
        )]
        [string]
        $Notes
    )
    # Begin Logging
    if (-not $script:LogString) {
        Write-AuditLog -Start
    }
    else {
        Write-AuditLog -BeginFunction
    }
    Write-AuditLog '###############################################'
    try {
        Write-AuditLog "Creating new enterprise app registration for '$DisplayName'."
        if ($CertThumbprint) {
            # 1) Retrieve the certificate from the CurrentUser store
            $Cert = Get-ChildItem -Path $CertStoreLocation |
            Where-Object { $_.Thumbprint -eq $CertThumbprint }
            if (-not $Cert) {
                throw "Certificate with thumbprint $CertThumbprint not found in $CertStoreLocation."
            }
            # 2) Create the new app registration
            $AppRegistration = New-MgApplication `
                -DisplayName $DisplayName `
                -Notes $Notes `
                -SignInAudience $SignInAudience `
                -RequiredResourceAccess $RequiredResourceAccessList `
                -AdditionalProperties @{} `
                -KeyCredentials @(
                @{
                    Type  = 'AsymmetricX509Cert'
                    Usage = 'Verify'
                    Key   = $Cert.RawData
                }
            )
            if (-not $AppRegistration) {
                throw "The app creation failed for '$DisplayName'."
            }
            Write-AuditLog "App registration created with app Object ID $($AppRegistration.Id)."
            return $AppRegistration
        }
        else {
            throw 'CertThumbprint is required to create an app registration. No other methods are supported yet.'
        }
    }
    catch {
        throw
    }
    finally {
        Write-AuditLog -EndFunction
    }
}
#EndRegion '.\Private\New-TkAppRegistration.ps1' 127
#Region '.\Private\New-TkExchangeEmailAppPolicy.ps1' -1

function New-TkExchangeEmailAppPolicy {
    [CmdletBinding()]
    param (
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The application registration object.'
        )]
        [PSObject]
        $AppRegistration,
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The Mail Enabled Sending Group.'
        )]
        [string]
        $MailEnabledSendingGroup
    )
    # Begin Logging
    if (!($script:LogString)) {
        Write-AuditLog -Start
    }
    else {
        Write-AuditLog -BeginFunction
    }
    try {
        Write-AuditLog -Message "Creating Exchange Application policy for $($MailEnabledSendingGroup) for AppId $($AppRegistration.AppId)."
        New-ApplicationAccessPolicy -AppId $AppRegistration.AppId `
            -PolicyScopeGroupId $MailEnabledSendingGroup -AccessRight RestrictAccess `
            -Description 'Limit MSG application to only send emails as a group of users' -ErrorAction Stop
        Write-AuditLog -Message "Created Exchange Application policy for $($MailEnabledSendingGroup)."
    }
    catch {
        throw
    }
    Write-AuditLog -EndFunction
}
#EndRegion '.\Private\New-TkExchangeEmailAppPolicy.ps1' 36
#Region '.\Private\New-TkRequiredResourcePermissionObject.ps1' -1

function New-TkRequiredResourcePermissionObject {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Application (app-only) permissions for Microsoft Graph.'
        )]
        [string[]]
        $GraphPermissions = @('Mail.Send'),
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Scenario app version.',
            ParameterSetName = 'Scenario'
        )]
        [ValidateSet('365Audit')]
        [string]
        $Scenario
    )
    process {
        if (-not $script:LogString) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        try {
            Write-AuditLog '###############################################'
            ## 1) Retrieve service principals by DisplayName
            Write-AuditLog 'Looking up service principals by display name...'
            $spGraph = Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"
            # 2) Build an array of [MicrosoftGraphRequiredResourceAccess] objects
            $requiredResourceAccessList = @()
            # Retrieve all application permissions
            $permissionList = Find-MgGraphPermission -PermissionType Application -All
            # region Graph perms
            [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $graphRra = $null
            # If GraphPermissions is not null or empty, process them
            if ($GraphPermissions -and $GraphPermissions.Count -gt 0) {
                if (-not $spGraph) {
                    throw 'Microsoft Graph Service Principal not found (by display name).'
                }
                Write-AuditLog "Gathering permissions: $($GraphPermissions -join ', ')"
                $graphRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new()
                $graphRra.ResourceAppId = $spGraph.AppId
                foreach ($permName in $GraphPermissions) {
                    $foundPerm = $permissionList | Where-Object { $_.Name -eq $permName } #Find-MgGraphPermission -PermissionType Application -All |
                    if ($foundPerm) {
                        # If multiple matches, pick the first
                        $graphRra.ResourceAccess += @{ Id = $foundPerm.Id; Type = 'Role' }
                        Write-AuditLog "Found Graph permission ID for '$permName': $($foundPerm[0].Id)"
                    }
                    else {
                        Write-AuditLog -Severity Warning -Message "Graph Permission '$permName' not found!"
                    }
                }
                if ($graphRra.ResourceAccess) {
                    $requiredResourceAccessList += $graphRra
                }
                else {
                    throw "No Graph permissions found for '$($GraphPermissions -join ', ')'. Check the permission names and try again."
                }
            }
            # endregion
            # region Scenario-specific permissions
            # Scenario 365Audit
            if ($Scenario -eq '365Audit') {
                # region SharePoint perms
                [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $spRra = $null
                $spRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new()
                $spRra.ResourceAppId = "00000003-0000-0ff1-ce00-000000000000" # SharePoint Online
                $spRra.ResourceAccess += @{ Id = 'd13f72ca-a275-4b96-b789-48ebcc4da984'; Type = 'Role' }
                $spRra.ResourceAccess += @{ Id = '678536fe-1083-478a-9c59-b99265e6b0d3'; Type = 'Role' }
                $requiredResourceAccessList += $spRra
                # endregion
                # region Exchange perms
                [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $spRra = $null
                $exRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new()
                $exRra.ResourceAppId = "00000002-0000-0ff1-ce00-000000000000" # Exchange Online
                $exRra.ResourceAccess += @{ Id = 'dc50a0fb-09a3-484d-be87-e023b12c6440'; Type = 'Role' }
                $requiredResourceAccessList += $exRra
            } # endregion Scenario 365Audit
            # endregion Scenario-specific permissions
            # 3) Build final result object
            $result = [PSCustomObject]@{
                RequiredResourceAccessList = $requiredResourceAccessList
            }
            # }
            Write-AuditLog 'Returning context object.'
            return $result
        }
        catch {
            throw
        }
        finally {
            Write-AuditLog -EndFunction
        }
    }
}
#EndRegion '.\Private\New-TkRequiredResourcePermissionObject.ps1' 99
#Region '.\Private\Set-TkJsonSecret.ps1' -1

function Set-TkJsonSecret {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory=$true,HelpMessage='The name under which to store the secret.'
        )]
        [string]
        $Name,
        [Parameter(
            Mandatory=$true,
            HelpMessage='The object to convert to JSON and store.'
        )]
        [PSObject]
        $InputObject,
        [Parameter(
            Mandatory=$false,
            HelpMessage='Name of the vault. Defaults to GraphEmailAppLocalStore.'
        )]
        [string]
        $VaultName='GraphEmailAppLocalStore',
        [Parameter(
            Mandatory=$false,
            HelpMessage='Name of the vault module to use if auto-registering. Defaults to SecretManagement.JustinGrote.CredMan.'
        )]
        [string]
        $VaultModuleName='SecretManagement.JustinGrote.CredMan',
        [Parameter(
            Mandatory=$false,
            HelpMessage='Overwrite existing secret of the same name without prompting.'
        )]
        [switch]
        $Overwrite
    )
    if(!($script:LogString)){Write-AuditLog -Start}else{Write-AuditLog -BeginFunction}
    try{
        Write-AuditLog "###############################################"
        # Auto-register vault if missing
        if(!(Get-SecretVault -Name $VaultName -ErrorAction SilentlyContinue)){
            Write-AuditLog -Message "Registering $VaultName using $VaultModuleName"
            Register-SecretVault -Name $VaultName -ModuleName $VaultModuleName -ErrorAction Stop
            Write-AuditLog -Message "Vault '$VaultName' registered."
        }
        else{
            Write-AuditLog "Vault '$VaultName' is already registered."
        }
        # Check if secret already exists
        $secretExists=(Get-SecretInfo -Name $Name -Vault $VaultName -ErrorAction SilentlyContinue)
        if($secretExists){
            if($Overwrite){
                Write-AuditLog -Message "Overwriting existing secret '$Name' in vault '$VaultName'."
                Remove-Secret -Name $Name -Vault $VaultName -Confirm:$false -ErrorAction Stop
            }
            else{
                Write-AuditLog -Message "Secret '$Name' already exists. Remove it or specify -Overwrite to overwrite." -Severity Warning
                return
            }
        }
        $json=($InputObject | ConvertTo-Json -Compress)
        Set-Secret -Name $Name -Secret $json -Vault $VaultName -ErrorAction Stop
        Write-AuditLog -Message "Secret '$Name' saved to vault '$VaultName'."
        Write-AuditLog -EndFunction
        return $Name
    }
    catch{
        throw
    }
}
#EndRegion '.\Private\Set-TkJsonSecret.ps1' 68
#Region '.\Private\Test-IsAdmin.ps1' -1

function Test-IsAdmin {
    <#
    .SYNOPSIS
    Checks if the current user is an administrator on the machine.
    .DESCRIPTION
    This private function returns a Boolean value indicating whether
    the current user has administrator privileges on the machine.
    It does this by creating a new WindowsPrincipal object, passing
    in a WindowsIdentity object representing the current user, and
    then checking if that principal is in the Administrator role.
    .INPUTS
    None.
    .OUTPUTS
    Boolean. Returns True if the current user is an administrator, and False otherwise.
    .EXAMPLE
    PS C:\> Test-IsAdmin
    True
    #>

    # Create a new WindowsPrincipal object for the current user and check if it is in the Administrator role
    (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)
}
#EndRegion '.\Private\Test-IsAdmin.ps1' 22
#Region '.\Private\Write-AuditLog.ps1' -1

<#
    .SYNOPSIS
        Writes log messages to the console and updates the script-wide log variable.
    .DESCRIPTION
        The Write-AuditLog function writes log messages to the console based on the severity (Verbose, Warning, or Error) and updates
        the script-wide log variable ($script:LogString) with the log entry. You can use the Start, End, and EndFunction switches to
        manage the lifecycle of the logging.
    .INPUTS
        System.String
        You can pipe a string to the Write-AuditLog function as the Message parameter.
        You can also pipe an object with a Severity property as the Severity parameter.
    .OUTPUTS
        None
        The Write-AuditLog function doesn't output any objects to the pipeline. It writes messages to the console and updates the
        script-wide log variable ($script:LogString).
    .PARAMETER BeginFunction
        Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .PARAMETER Message
        The message string to log.
    .PARAMETER Severity
        The severity of the log message. Accepted values are 'Information', 'Warning', and 'Error'. Defaults to 'Information'.
    .PARAMETER Start
        Initializes the script-wide log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function.
    .PARAMETER End
        Sets the message to "End Log" and exports the log to a CSV file if the OutputPath parameter is provided.
    .PARAMETER EndFunction
        Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .PARAMETER OutputPath
        The file path for exporting the log to a CSV file when using the End switch.
    .EXAMPLE
        Write-AuditLog -Message "This is a test message."
 
        Writes a test message with the default severity (Information) to the console and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -Message "This is a warning message." -Severity "Warning"
 
        Writes a warning message to the console and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -Start
 
        Initializes the log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function.
    .EXAMPLE
        Write-AuditLog -BeginFunction
 
        Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -EndFunction
 
        Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable.
    .EXAMPLE
        Write-AuditLog -End -OutputPath "C:\Logs\auditLog.csv"
 
        Sets the message to "End Log", adds it to the log variable, and exports the log to a CSV file.
    .NOTES
    Author: DrIOSx
#>

function Write-AuditLog {
    [CmdletBinding(
        DefaultParameterSetName = 'Default'
    )]
    param(
        ###
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Input a Message string.',
            Position = 0,
            ParameterSetName = 'Default',
            ValueFromPipeline = $true
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Message,
        ###
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Information, Warning or Error.',
            Position = 1,
            ParameterSetName = 'Default',
            ValueFromPipelineByPropertyName = $true
        )]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('Information', 'Warning', 'Error', 'Verbose')]
        [string]$Severity = 'Verbose',
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'End'
        )]
        [switch]$End,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'BeginFunction'
        )]
        [switch]$BeginFunction,
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'EndFunction'
        )]
        [switch]$EndFunction,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'Start'
        )]
        [switch]$Start,
        ###
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'End'
        )]
        [string]$OutputPath
    )
    begin {
        $ErrorActionPreference = 'SilentlyContinue'
        # Define variables to hold information about the command that was invoked.
        $ModuleName = $Script:MyInvocation.MyCommand.Name -replace '\..*'
        $callStack = Get-PSCallStack
        if ($callStack.Count -gt 1) {
            $FuncName = $callStack[1].Command
        }
        else {
            $FuncName = 'DirectCall'  # Or any other default name you prefer
        }
        $ModuleVer = $MyInvocation.MyCommand.Version.ToString()
        # Set the error action preference to continue.
        $ErrorActionPreference = 'Continue'
    }
    process {
        try {
            if (-not $Start -and -not (Test-Path variable:script:LogString)) {
                throw "The logging variable is not initialized. Please call Write-AuditLog with the -Start switch or ensure $script:LogString is set."
            }
            $Function = $($FuncName + '.v' + $ModuleVer)
            if ($Start) {
                $script:LogString = @()
                $Message = '+++ Begin Log +++ | ' + $Function + ' |'
            }
            elseif ($BeginFunction) {
                $Message = '>>> Begin Function Log >>> | ' + $Function + ' |'
            }
            $logEntry = [pscustomobject]@{
                Time      = ((Get-Date).ToString('yyyy-MM-dd hh:mmTss'))
                Module    = $ModuleName
                PSVersion = ($PSVersionTable.PSVersion).ToString()
                PSEdition = ($PSVersionTable.PSEdition).ToString()
                IsAdmin   = $(Test-IsAdmin)
                User      = "$Env:USERDOMAIN\$Env:USERNAME"
                HostName  = $Env:COMPUTERNAME
                InvokedBy = $Function
                Severity  = $Severity
                Message   = $Message
                RunID     = -1
            }
            if ($BeginFunction) {
                $maxRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Measure-Object -Property RunID -Maximum).Maximum
                if ($null -eq $maxRunID) { $maxRunID = -1 }
                $logEntry.RunID = $maxRunID + 1
            }
            else {
                $lastRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $Function } | Select-Object -Last 1).RunID
                if ($null -eq $lastRunID) { $lastRunID = 0 }
                $logEntry.RunID = $lastRunID
            }
            if ($EndFunction) {
                $FunctionStart = "$((($script:LogString | Where-Object {$_.InvokedBy -eq $Function -and $_.RunId -eq $lastRunID } | Sort-Object Time)[0]).Time)"
                $startTime = ([DateTime]::ParseExact("$FunctionStart", 'yyyy-MM-dd hh:mmTss', $null))
                $endTime = Get-Date
                $timeTaken = $endTime - $startTime
                $Message = '<<< End Function Log <<< | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec"
                $logEntry.Message = $Message
            }
            elseif ($End) {
                $startTime = ([DateTime]::ParseExact($($script:LogString[0].Time), 'yyyy-MM-dd hh:mmTss', $null))
                $endTime = Get-Date
                $timeTaken = $endTime - $startTime
                $Message = '--- End Log | ' + $Function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec"
                $logEntry.Message = $Message
            }
            $script:LogString += $logEntry
            switch ($Severity) {
                'Warning' {
                    Write-Warning ('[WARNING] ! ' + $Message)
                    $UserInput = Read-Host 'Warning encountered! Do you want to continue? (Y/N)'
                    if ($UserInput -eq 'N') {
                        throw 'Script execution stopped by user.'
                    }
                }
                'Error' { Write-Error ('[ERROR] X - ' + "[$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $FuncName + ' ' + $Message) -ErrorAction Continue }
                'Verbose' { Write-Verbose ('~ ' + "[$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $Message) }
                Default { Write-Information ("[NFO] [$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $Message) -InformationAction Continue }
            }
        }
        catch {
            throw
        }
    }
    end {
        try {
            if ($End) {
                if (-not [string]::IsNullOrEmpty($OutputPath)) {
                    $script:LogString | Export-Csv -Path $OutputPath -NoTypeInformation
                    Write-Verbose "LogPath: $(Split-Path -Path $OutputPath -Parent)"
                }
                else {
                    throw 'OutputPath is not specified for End action.'
                }
            }
        }
        catch {
            throw "Error in Write-AuditLog (end block): $($_.Exception.Message)"
        }
    }
}
#EndRegion '.\Private\Write-AuditLog.ps1' 214
#Region '.\Public\New-MailEnabledSendingGroup.ps1' -1

<#
    .SYNOPSIS
        Creates or retrieves a mail-enabled security group with a custom or default domain.
    .DESCRIPTION
        The New-MailEnabledSendingGroup function ensures that a mail-enabled security group is
        available for restricting email sending in Exchange Online. If a group of the specified
        name already exists and is security-enabled, the function returns that group. Otherwise,
        it creates a new security-enabled distribution group. You can specify either a custom
        primary SMTP address (via the 'CustomDomain' parameter set) or construct one using an
        alias and default domain (via the 'DefaultDomain' parameter set).
        By default, the 'CustomDomain' parameter set is used. If you wish to construct the SMTP
        address from the alias, switch to the 'DefaultDomain' parameter set.
    .PARAMETER Name
        The name of the mail-enabled security group to create or retrieve. This is also used as
        the alias if no separate Alias parameter is provided.
    .PARAMETER Alias
        An optional alias for the group. If omitted, the group name is used as the alias.
    .PARAMETER PrimarySmtpAddress
        (CustomDomain parameter set) The full SMTP address for the group (e.g. "MyGroup@contoso.com").
        This parameter is mandatory when using the 'CustomDomain' parameter set.
    .PARAMETER DefaultDomain
        (DefaultDomain parameter set) The domain portion to be appended to the group alias (e.g.
        "Alias@DefaultDomain"). This parameter is mandatory when using the 'DefaultDomain' parameter set.
    .EXAMPLE
        PS C:\> New-MailEnabledSendingGroup -Name "SecureSenders" -DefaultDomain "contoso.com"
        Creates a new mail-enabled security group named "SecureSenders" with a primary SMTP address
        of SecureSenders@contoso.com.
    .EXAMPLE
        PS C:\> New-MailEnabledSendingGroup -Name "SecureSenders" -Alias "Senders" -PrimarySmtpAddress "Senders@customdomain.org"
        Creates a new mail-enabled security group named "SecureSenders" with an alias "Senders"
        and a primary SMTP address of Senders@customdomain.org.
    .INPUTS
        None. This function does not accept pipeline input.
    .OUTPUTS
        Microsoft.Exchange.Data.Directory.Management.DistributionGroup
        Returns the newly created or existing mail-enabled security group object.
    .NOTES
        - Requires connectivity to Exchange Online (Connect-TkMsService -ExchangeOnline).
        - The caller must have sufficient privileges to create or modify distribution groups.
        - DefaultParameterSetName = 'CustomDomain'.
#>


function New-MailEnabledSendingGroup {
    [CmdletBinding(DefaultParameterSetName = 'CustomDomain')]
    param (
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'Specifies the name of the mail enabled sending group.'
        )]
        [string]
        $Name,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Optional alias for the group. If not provided, the group name will be used.'
        )]
        [string]
        $Alias,
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'CustomDomain',
            HelpMessage = 'Specifies the primary SMTP address for the group when using a custom domain.'
        )]
        [string]
        $PrimarySmtpAddress,
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'DefaultDomain',
            HelpMessage = 'Specifies the default domain to construct the primary SMTP address (alias@DefaultDomain) for the group.'
        )]
        [string]
        $DefaultDomain
    )
    if (!($script:LogString)) {
        Write-AuditLog -Start
    }
    else {
        Write-AuditLog -BeginFunction
    }
    try {
        Connect-TkMsService -ExchangeOnline
        if (-not $Alias) {
            $Alias = $Name
        }
        if ($PSCmdlet.ParameterSetName -eq 'DefaultDomain') {
            $PrimarySmtpAddress = "$Alias@$DefaultDomain"
        }
        # Check if the distribution group already exists
        $existingGroup = Get-DistributionGroup -Identity $Name -ErrorAction SilentlyContinue
        if ($existingGroup) {
            # Confirm the group is security-enabled
            if ($existingGroup.GroupType -notmatch 'SecurityEnabled') {
                throw "Group '$Name' exists but is not SecurityEnabled. Please provide a mail-enabled security group."
            }
            Write-AuditLog -Message "Distribution group '$Name' already exists. Returning existing group."
            return $existingGroup
        }
        # Create the distribution group
        $groupParams = @{
            Name               = $Name
            Alias              = $Alias
            PrimarySmtpAddress = $PrimarySmtpAddress
            Type               = 'security'
        }
        Write-AuditLog -Message "Creating distribution group with parameters: `n$($groupParams | Out-String)"
        $group = New-DistributionGroup @groupParams
        Write-AuditLog -Message "Distribution group created:`n$($group | Out-String)"
        return $group
    }
    catch {
        throw
    }
    finally {
        Write-AuditLog -EndFunction
    }
}
#EndRegion '.\Public\New-MailEnabledSendingGroup.ps1' 116
#Region '.\Public\Publish-TkEmailApp.ps1' -1

<#
    .SYNOPSIS
        Deploys a new Microsoft Graph Email app and associates it with a certificate for app-only authentication.
    .DESCRIPTION
        This cmdlet deploys a new Microsoft Graph Email app and associates it with a certificate for
        app-only authentication. It requires an AppPrefix for the app, an optional CertThumbprint, an
        AuthorizedSenderUserName, and a MailEnabledSendingGroup. Additionally, you can specify a
        KeyExportPolicy for the certificate, control how secrets are stored via VaultName and OverwriteVaultSecret,
        and optionally return a parameter splat instead of a PSCustomObject.
    .PARAMETER AppPrefix
        A unique prefix for the Graph Email App to initialize. Ensure it is used consistently for
        grouping purposes (2-4 alphanumeric characters).
    .PARAMETER AuthorizedSenderUserName
        The username of the authorized sender.
    .PARAMETER MailEnabledSendingGroup
        The mail-enabled group to which the sender belongs. This will be used to assign
        app policy restrictions.
    .PARAMETER CertThumbprint
        An optional parameter indicating the thumbprint of the certificate to be retrieved. If not
        specified, a self-signed certificate will be generated.
    .PARAMETER KeyExportPolicy
        Specifies the key export policy for the newly created certificate. Valid values are
        'Exportable' or 'NonExportable'. Defaults to 'NonExportable'.
    .PARAMETER VaultName
        If specified, the name of the vault to store the app's credentials. Otherwise,
        defaults to 'GraphEmailAppLocalStore'.
    .PARAMETER OverwriteVaultSecret
        If specified, the function overwrites an existing secret in the vault if it
        already exists.
    .PARAMETER ReturnParamSplat
        If specified, returns the parameter splat for use in other functions instead
        of the PSCustomObject.
    .EXAMPLE
        PS C:\> Publish-TkEmailApp -AppPrefix "ABC" -AuthorizedSenderUserName "jdoe@example.com" -MailEnabledSendingGroup "GraphAPIMailGroup@example.com" -CertThumbprint "AABBCCDDEEFF11223344556677889900"
    .INPUTS
        None
    .OUTPUTS
        By default, returns a PSCustomObject containing details such as AppId, CertThumbprint,
        TenantID, and CertExpires. If -ReturnParamSplat is specified, returns the parameter
        splat instead.
    .NOTES
        This cmdlet requires that the user running the cmdlet have the necessary permissions to
        create the app and connect to Exchange Online. In addition, a mail-enabled security group
        must already exist in Exchange Online for the MailEnabledSendingGroup parameter.
        Permissions required:
            'Application.ReadWrite.All',
            'DelegatedPermissionGrant.ReadWrite.All',
            'Directory.ReadWrite.All',
            'RoleManagement.ReadWrite.Directory'
#>


function Publish-TkEmailApp {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The prefix used to initialize the Graph Email App. 2-4 characters letters and numbers only.'
        )]
        [ValidatePattern('^[A-Z0-9]{2,4}$')]
        [string]
        $AppPrefix,
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The username of the authorized sender.'
        )]
        [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
        [string]
        $AuthorizedSenderUserName,
        [Parameter(
            Mandatory = $true,
            HelpMessage = 'The Mail Enabled Sending Group.'
        )]
        [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
        [string]
        $MailEnabledSendingGroup,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The thumbprint of the certificate to be retrieved.'
        )]
        [ValidatePattern('^[A-Fa-f0-9]{40}$')]
        [string]
        $CertThumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Key export policy for the certificate.'
        )]
        [ValidateSet('Exportable', 'NonExportable')]
        [string]
        $KeyExportPolicy = 'NonExportable',
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'If specified, use a custom vault name. Otherwise, use the default.'
        )]
        [string]
        $VaultName = 'GraphEmailAppLocalStore',
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'If specified, overwrite the vault secret if it already exists.'
        )]
        [switch]
        $OverwriteVaultSecret,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Return the parameter splat for use in other functions.'
        )]
        [switch]
        $ReturnParamSplat
    )
    begin {
        if (-not $script:LogString) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        try {
            Write-AuditLog '###############################################'
            # 1) Ensure required modules are installed
            $PublicMods = 'Microsoft.Graph', 'ExchangeOnlineManagement', 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan'
            $PublicVers = '1.22.0', '3.1.0', '1.1.2', '1.0.0'
            $ImportMods = 'Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications', 'Microsoft.Graph.Identity.SignIns', 'Microsoft.Graph.Users'
            $ModParams = @{
                PublicModuleNames      = $PublicMods
                PublicRequiredVersions = $PublicVers
                ImportModuleNames      = $ImportMods
                Scope                  = 'CurrentUser'
            }
            Initialize-TkModuleEnv @ModParams
            # 2) Connect to both Graph and Exchange
            Connect-TkMsService -MgGraph -ExchangeOnline
            # 3) Verify if the user (authorized sender) exists
            $user = Get-MgUser -Filter "Mail eq '$AuthorizedSenderUserName'"
            if (-not $user) {
                throw "User '$AuthorizedSenderUserName' not found in the tenant."
            }
            $Context = Get-MgContext -ErrorAction Stop
            # 4) Build the app context (Mail.Send permission, etc.)
            $AppSettings = New-TkRequiredResourcePermissionObject -GraphPermissions 'Mail.Send'
            $appName = New-TkAppName `
                -Prefix $AppPrefix `
                -ScenarioName 'AuditGraphEmail' `
                -UserId $AuthorizedSenderUserName
            # Add relevant properties to $AppSettings
            $AppSettings | Add-Member -NotePropertyName 'User' -NotePropertyValue $user
            $AppSettings | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $appName
            # 5) Create or retrieve the certificate
            $CertDetails = Initialize-TkAppAuthCertificate `
                -AppName $AppSettings.AppName `
                -Thumbprint $CertThumbprint `
                -Subject "CN=$($AppSettings.AppName)" `
                -KeyExportPolicy $KeyExportPolicy `
                -ErrorAction Stop
        }
        catch {
            throw
        }
    }
    process {
        $proposedObject = [PSCustomObject]@{
            ProposedAppName                 = $AppSettings.AppName
            CertificateThumbprintUsed       = $CertDetails.CertThumbprint
            CertExpires                     = $CertDetails.CertExpires
            UserPrincipalName               = $user.UserPrincipalName
            TenantID                        = $Context.TenantId
            Permissions                     = 'Mail.Send'
            PermissionType                  = 'Application'
            ConsentType                     = 'AllPrincipals'
            ExchangePolicyRestrictedToGroup = $MailEnabledSendingGroup
        }
        Write-AuditLog 'The following object will be created (or configured) in Azure AD:'
        Write-AuditLog "`n$($proposedObject | Format-List)`n"
        $permissionsObject = [PSCustomObject]@{
            Graph = 'Mail.Send'
        }
        if ($PSCmdlet.ShouldProcess(
                "GraphEmailApp '$($AppSettings.AppName)'",
                'Creating & configuring a new Graph Email App in Azure AD'
            )) {
            try {
                $Notes = @"
Graph Email App for: $AuthorizedSenderUserName
Restricted to group: '$MailEnabledSendingGroup'.
Certificate Thumbprint: $($CertDetails.CertThumbprint)
Certificate Expires: $($CertDetails.CertExpires)
Tenant ID: $($Context.TenantId)
App Permissions: $($permissionsObject.Graph)
Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim())
Client Hostname: $env:COMPUTERNAME
"@

                # 6) Register the new enterprise app for Graph
                $appRegistration = New-TkAppRegistration `
                    -DisplayName $AppSettings.AppName `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -SignInAudience 'AzureADMyOrg' `
                    -Notes $Notes `
                    -ErrorAction Stop
                # 7) Configure the service principal, permissions, etc.
                $ConsentUrl = Initialize-TkAppSpRegistration `
                    -AppRegistration $appRegistration `
                    -Context $Context `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -Scopes $permissionsObject `
                    -AuthMethod 'Certificate' `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -ErrorAction Stop
                [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.')
                # 8) Create the Exchange Online policy restricting send
                [void](New-TkExchangeEmailAppPolicy -AppRegistration $appRegistration -MailEnabledSendingGroup $MailEnabledSendingGroup)
                # 9) Build final output object
                $output = [PSCustomObject]@{
                    AppId                  = $appRegistration.AppId
                    Id                     = $appRegistration.Id
                    AppName                = "CN=$($AppSettings.AppName)"
                    AppRestrictedSendGroup = $MailEnabledSendingGroup
                    CertExpires            = $CertDetails.CertExpires
                    CertThumbprint         = $CertDetails.CertThumbprint
                    ConsentUrl             = $ConsentUrl
                    DefaultDomain          = $MailEnabledSendingGroup.Split('@')[1]
                    SendAsUser             = ($AppSettings.User.UserPrincipalName.Split('@')[0])
                    SendAsUserEmail        = $AppSettings.User.UserPrincipalName
                    TenantID               = $Context.TenantId
                }
                $graphEmailApp = [TkEmailAppParams]::new(
                    $output.AppId,
                    $output.Id,
                    $output.AppName,
                    $output.AppRestrictedSendGroup,
                    $output.CertExpires,
                    $output.CertThumbprint,
                    $output.ConsentUrl,
                    $output.DefaultDomain,
                    $output.SendAsUser,
                    $output.SendAsUserEmail,
                    $output.TenantID
                )
                # 10) Store it as JSON in the vault
                $secretName = "CN=$($AppSettings.AppName)"
                $savedSecretName = Set-TkJsonSecret -Name $secretName -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret
                Write-AuditLog "Secret '$savedSecretName' saved to vault '$VaultName'."
            }
            catch {
                throw
            }
        }
        else {
            Write-AuditLog 'User elected not to create or configure the Graph Email App. (ShouldProcess => false).'
        }
    }
    end {
        if ($ReturnParamSplat) {
            return ($graphEmailApp | ConvertTo-ParameterSplat)
        }
        else {
            return $graphEmailApp
        }
        Write-AuditLog -EndFunction
    }
}
#EndRegion '.\Public\Publish-TkEmailApp.ps1' 260
#Region '.\Public\Publish-TkM365AuditApp.ps1' -1

<#
    .SYNOPSIS
        Publishes (creates) a new M365 Audit App registration in Entra ID (Azure AD) with a specified certificate.
    .DESCRIPTION
        The Publish-TkM365AuditApp function creates a new Azure AD application used for M365 auditing.
        It connects to Microsoft Graph, gathers the required permissions for SharePoint and Exchange,
        and optionally creates a self-signed certificate if no thumbprint is provided. It also assigns
        the application to the Exchange Administrator and Global Reader roles. By default, the newly
        created application details are stored as a secret in the specified SecretManagement vault.
    .PARAMETER AppPrefix
        A short prefix (2-4 alphanumeric characters) used to build the app name. Defaults to "Gtk"
        if not specified.
    .PARAMETER CertThumbprint
        The thumbprint of an existing certificate in the current user's certificate store. If not
        provided, a new self-signed certificate is created.
    .PARAMETER KeyExportPolicy
        Specifies whether the newly created certificate (if no thumbprint is provided) is
        'Exportable' or 'NonExportable'. Defaults to 'NonExportable'.
    .PARAMETER VaultName
        The SecretManagement vault name in which to store the app credentials. Defaults to
        "M365AuditAppLocalStore" if not specified.
    .PARAMETER OverwriteVaultSecret
        If specified, overwrites an existing secret in the specified vault if it already exists.
    .PARAMETER ReturnParamSplat
        If specified, returns a parameter splat string for use in other functions, instead of the
        default PSCustomObject containing the app details.
    .EXAMPLE
        PS C:\> Publish-TkM365AuditApp -AppPrefix "CS12" -ReturnParamSplat
        Creates a new M365 Audit App with the prefix "CS12", returns a parameter splat, and stores
        the credentials in the default vault.
    .INPUTS
        None. This function does not accept pipeline input.
    .OUTPUTS
        By default, returns a PSCustomObject with details of the new app (AppId, ObjectId, TenantId,
        certificate thumbprint, expiration, etc.). If -ReturnParamSplat is used, returns a parameter
        splat string.
    .NOTES
        Requires the Microsoft.Graph and ExchangeOnlineManagement modules for app creation and
        role assignment. The user must have sufficient privileges to create and manage applications
        in Azure AD, and to assign roles. After creation, admin consent may be required for the
        assigned permissions.
        Permissions required:
            'Application.ReadWrite.All',
            'DelegatedPermissionGrant.ReadWrite.All',
            'Directory.ReadWrite.All',
            'RoleManagement.ReadWrite.Directory'
#>

function Publish-TkM365AuditApp {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
            'Prefix for the new M365 Audit app name (2-4 alphanumeric characters).'
        )]
        [ValidatePattern('^[A-Z0-9]{2,4}$')]
        [string]
        $AppPrefix = "Gtk",
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
            'Thumbprint of an existing certificate to use. If not provided, a self-signed cert will be created.'
        )]
        [ValidatePattern('^[A-Fa-f0-9]{40}$')]
        [string]
        $CertThumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Key export policy for the certificate.'
        )]
        [ValidateSet('Exportable', 'NonExportable')]
        [string]
        $KeyExportPolicy = 'NonExportable',
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
            'Name of the SecretManagement vault to store app credentials.'
        )]
        [string]
        $VaultName = 'M365AuditAppLocalStore',
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
            'If specified, overwrite the vault secret if it already exists.'
        )]
        [switch]
        $OverwriteVaultSecret,
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
            'Return output as a parameter splat string for use in other functions.'
        )]
        [switch]$ReturnParamSplat
    )
    begin {
        <#
            $uniqueSuffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 4)
            $TwoToFourLetterCompanyAbbreviation = "CS$($uniqueSuffix.Substring(0,2))"
            Publish-TkM365AuditApp -AppPrefix $TwoToFourLetterCompanyAbbreviation -ReturnParamSplat
        #>

        if (-not $script:LogString) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        Write-AuditLog '###############################################'
        Write-AuditLog 'Initializing M365 Audit App publication process...'

        # 1) Connect to Graph so we can query permissions & create the app
        Connect-TkMsService -MgGraph
    }
    process {
        try {
            # 2) Define read-only vs. read-write sets
            $graphReadOnly = @(
                'AppCatalog.ReadWrite.All',
                #'AuditLog.Read.All',
                'Channel.Delete.All',
                'ChannelMember.ReadWrite.All',
                'ChannelSettings.ReadWrite.All',
                #'DeviceManagementApps.Read.All',
                #'DeviceManagementApps.ReadWrite.All',
                #'DeviceManagementConfiguration.Read.All',
                #'DeviceManagementConfiguration.ReadWrite.All',
                #'DeviceManagementManagedDevices.Read.All',
                #'DeviceManagementManagedDevices.ReadWrite.All',
                'Directory.Read.All',
                #'Group.Read.All',
                'Group.ReadWrite.All',
                'Organization.Read.All',
                'Policy.Read.All',
                'Domain.Read.All'
                #'Policy.Read.ConditionalAccess',
                #'RoleManagement.Read.Directory',
                'TeamSettings.ReadWrite.All'
                #'TeamSettings.Read.All',
                #'UserAuthenticationMethod.Read.All',
                'User.Read.All'
            )
            #$graphReadWrite = @('Directory.ReadWrite.All') # add more if needed
            # For SharePoint, only 'Sites.Read.All' for read-only,
            # 'Sites.FullControl.All' for read-write
            $sharePointReadOnly = @('Sites.Read.All')
            $sharePointReadWrite = @('Sites.FullControl.All')
            # For Exchange, typically 'Exchange.ManageAsApp' suffices in read-only mode
            # Add more if you need read-write Exchange perms
            $exchangeReadOnly = @('Exchange.ManageAsApp')
            # Decide which sets to use
            $graphPerms = $graphReadOnly #if ($ReadWrite) { $graphReadOnly + $graphReadWrite } else { $graphReadOnly }
            $sharePointPerms = $sharePointReadOnly + $sharePointReadWrite
            $exchangePerms = $exchangeReadOnly
            $permissionsObject = [PSCustomObject]@{
                Graph      = $graphPerms
                SharePoint = $sharePointPerms
                Exchange   = $exchangePerms
            }
            Write-AuditLog "Graph Perms: $($graphPerms -join ', ')"
            Write-AuditLog "SharePoint Perms: $($sharePointPerms -join ', ')"
            Write-AuditLog "Exchange Perms: $($exchangePerms -join ', ')"
            $Context = Get-MgContext -ErrorAction Stop
            # 3) Gather the resource access objects (GUIDs) for all these perms
            $AppSettings = New-TkRequiredResourcePermissionObject `
                -GraphPermissions $graphPerms `
                -Scenario '365Audit' `
                -ErrorAction Stop
            # This returns an object with .RequiredResourceAccessList (the array
            # of MicrosoftGraphRequiredResourceAccess objects) plus .TenantId, etc.
            # 4) Generate the app name
            $appName = New-TkAppName -Prefix $AppPrefix -ScenarioName 'M365Audit' -ErrorAction Stop
            Write-AuditLog "Proposed new M365 Audit App name: $appName"
            # 5) Retrieve or create the certificate
            $CertDetails = Initialize-TkAppAuthCertificate `
            -AppName $appName `
            -Thumbprint $CertThumbprint `
            -Subject "CN=$appName" `
            -KeyExportPolicy $KeyExportPolicy `
            -ErrorAction Stop
            Write-AuditLog "Certificate Thumbprint: $($CertDetails.CertThumbprint); Expires: $($CertDetails.CertExpires)."
            # Show user proposed config
            $proposed = [PSCustomObject]@{
                ProposedAppName       = $appName
                CertificateThumbprint = $CertDetails.CertThumbprint
                CertExpires           = $CertDetails.CertExpires
                GraphPermissions      = $graphPerms -join ', '
                SharePointPermissions = $sharePointPerms -join ', '
                ExchangePermissions   = $exchangePerms -join ', '
            }
            Write-AuditLog 'Proposed creation of a new M365 Audit App with the following properties:'
            Write-AuditLog "$($proposed | Format-List)"
            # 6) Create the app in one pass with all resources
            $Notes = @"
Certificate Thumbprint: $($CertDetails.CertThumbprint)
Certificate Expires: $($CertDetails.CertExpires)
Tenant ID: $($Context.TenantId)
Graph App Permissions: $($graphPerms -join ', ')
SharePoint App Permissions: $($sharePointPerms -join ', ')
Exchange App Permissions: $($exchangePerms -join ', ')
Roles Assigned: 'Exchange Administrator', 'Global Reader'
Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim())
Client Hostname: $env:COMPUTERNAME
"@

            if ($PSCmdlet.ShouldProcess($appName, 'Create and configure M365 Audit App in EntraAD')) {
                Write-AuditLog 'Creating new EntraAD application with all resource permissions...'
                $appRegistration = New-TkAppRegistration `
                    -DisplayName $appName `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -Notes $Notes `
                    -SignInAudience 'AzureADMyOrg'
                Write-AuditLog "App registered. Object ID = $($appRegistration.Id), ClientId = $($appRegistration.AppId)."
                # 7) Grant the oauth2 permissions to service principal
                $ConsentUrl = Initialize-TkAppSpRegistration `
                    -AppRegistration $appRegistration `
                    -Context $Context `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -Scopes $permissionsObject `
                    -AuthMethod 'Certificate' `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -ErrorAction Stop
                    [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.')
                Write-AuditLog 'Appending Exchange Administrator role to the app.'
                $exoAdminRole = Get-MgDirectoryRole -Filter "displayName eq 'Exchange Administrator'"
                # Get the service principal object ID of the app
                $sp = Get-MgServicePrincipal -Filter "appId eq '$($appRegistration.appid)'" -ErrorAction Stop
                $spObjectId = $sp.Id
                $body = @{
                    '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$spObjectId"
                }
                New-MgDirectoryRoleMemberByRef `
                    -DirectoryRoleId $exoAdminRole.Id `
                    -BodyParameter $body
                Write-AuditLog 'Appending Global Reader role to the app.'
                $globalReaderRole = Get-MgDirectoryRole -Filter "displayName eq 'Global Reader'"
                $body = @{
                    '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$spObjectId"
                }
                New-MgDirectoryRoleMemberByRef `
                    -DirectoryRoleId $globalReaderRole.Id `
                    -BodyParameter $body
                # 8) Store final app info in the vault
                $output = [PSCustomObject]@{
                    AppName               = $("CN=$appName")
                    AppId                 = $appRegistration.AppId
                    ObjectId              = $appRegistration.Id
                    TenantId              = $context.TenantId
                    CertThumbprint        = $CertDetails.CertThumbprint
                    CertExpires           = $CertDetails.CertExpires
                    ConsentUrl            = $ConsentUrl
                    MgGraphPermissions    = "$($graphPerms)"
                    SharePointPermissions = "$($sharePointPerms)"
                    ExchangePermissions   = "$($exchangePerms)"
                }
                $m365AuditApp = [TkM365AuditAppParams]::new(
                    $output.AppName,
                    $output.AppId,
                    $output.ObjectId,
                    $output.TenantId,
                    $output.CertThumbprint,
                    $output.CertExpires,
                    $output.ConsentUrl,
                    $output.MgGraphPermissions,
                    $output.SharePointPermissions,
                    $output.ExchangePermissions
                )
                # Save to vault
                Set-TkJsonSecret -Name "CN=$appName" -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret
                Write-AuditLog "Saved app credentials to vault '$VaultName'."
                # Return as either param splat or plain object
                if ($ReturnParamSplat) {
                    return $m365AuditApp | ConvertTo-ParameterSplat
                }
                else {
                    return $m365AuditApp
                }
            }
        }
        catch {
            throw
        }
        finally {
            Write-AuditLog -EndFunction
        }
    }
}
#EndRegion '.\Public\Publish-TkM365AuditApp.ps1' 286
#Region '.\Public\Publish-TkMemPolicyManagerApp.ps1' -1

<#
    .SYNOPSIS
        Publishes a new MEM (Intune) Policy Manager App in Azure AD with read-only or read-write permissions.
    .DESCRIPTION
        The Publish-TkMemPolicyManagerApp function creates an Azure AD application intended for managing
        Microsoft Endpoint Manager (MEM/Intune) policies. It optionally creates or retrieves a certificate,
        configures the necessary Microsoft Graph permissions for read-only or read-write access, and stores
        the resulting app credentials in a SecretManagement vault.
    .PARAMETER AppPrefix
        A 2-4 character prefix used to build the application name (e.g., CORP, MSN). This helps uniquely
        identify the app in Azure AD.
    .PARAMETER CertThumbprint
        The thumbprint of an existing certificate in the current user's certificate store. If omitted,
        a new self-signed certificate is created.
    .PARAMETER KeyExportPolicy
        Specifies whether the newly created certificate is 'Exportable' or 'NonExportable'.
        Defaults to 'NonExportable' if not specified.
    .PARAMETER VaultName
        The name of the SecretManagement vault in which to store the app credentials.
        Defaults to 'MemPolicyManagerLocalStore'.
    .PARAMETER OverwriteVaultSecret
        If specified, overwrites any existing secret of the same name in the vault.
    .PARAMETER ReadWrite
        If specified, grants read-write MEM/Intune permissions. Otherwise, read-only permissions are granted.
    .PARAMETER ReturnParamSplat
        If specified, returns a parameter splat string for use in other functions. Otherwise, returns
        a PSCustomObject containing the app details.
    .EXAMPLE
        PS C:\> Publish-TkMemPolicyManagerApp -AppPrefix "CORP" -ReadWrite
        Creates a new MEM Policy Manager App with read-write permissions, retrieves or
        creates a certificate, and stores the credentials in the default vault.
    .INPUTS
        None. This function does not accept pipeline input.
    .OUTPUTS
        By default, returns a PSCustomObject (TkMemPolicyManagerAppParams) with details of the newly created
        app (AppId, certificate thumbprint, tenant ID, etc.). If -ReturnParamSplat is used, returns a parameter
        splat string.
    .NOTES
        This function requires the Microsoft.Graph module for application creation and the user must have
        permissions in Azure AD to register and grant permissions to the application. After creation, admin
        consent may be needed to finalize the permission grants.
        Permissions required:
            'Application.ReadWrite.All',
            'DelegatedPermissionGrant.ReadWrite.All',
            'Directory.ReadWrite.All',
            'RoleManagement.ReadWrite.Directory'
#>

function Publish-TkMemPolicyManagerApp {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(
            Mandatory = $true,
            HelpMessage = `
                '2-4 character prefix used for the App Name (e.g. MSN, CORP, etc.)'
        )]
        [ValidatePattern('^[A-Z0-9]{2,4}$')]
        [string]
        $AppPrefix,
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'Thumbprint of the certificate. If omitted, a self-signed cert is created.'
        )]
        [ValidatePattern('^[A-Fa-f0-9]{40}$')]
        [string]
        $CertThumbprint,
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Key export policy for the certificate.'
        )]
        [ValidateSet('Exportable', 'NonExportable')]
        [string]
        $KeyExportPolicy = 'NonExportable',
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'If specified, use a custom vault name. Otherwise, use the default.'
        )]
        [string]
        $VaultName = 'MemPolicyManagerLocalStore',
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'If specified, overwrite the vault secret if it already exists.'
        )]
        [switch]
        $OverwriteVaultSecret,
        [Parameter(
            HelpMessage = `
                'If specified, grant ReadWrite perms. Otherwise, read-only perms.'
        )]
        [switch]
        $ReadWrite,
        [Parameter(
            Mandatory = $false,
            HelpMessage = `
                'Return the param splat for use in other functions.'
        )]
        [switch]$ReturnParamSplat
    )
    begin {
        if (-not $script:LogString) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        try {
            Write-AuditLog '###############################################'
            $PublicMods = 'Microsoft.Graph', 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan'
            $PublicVers = '1.22.0', '1.1.2', '1.0.0'
            $ImportMods = 'Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications', 'Microsoft.Graph.Identity.SignIns', 'Microsoft.Graph.Users'
            $ModParams = @{
                PublicModuleNames      = $PublicMods
                PublicRequiredVersions = $PublicVers
                ImportModuleNames      = $ImportMods
                Scope                  = 'CurrentUser'
            }
            Initialize-TkModuleEnv @ModParams
            # Only connect to Graph
            Connect-TkMsService -MgGraph
            $Context = Get-MgContext -ErrorAction Stop
        }
        catch {
            $line = $_.InvocationInfo.Line
            $lineNum = $_.InvocationInfo.ScriptLineNumber
            throw [System.Management.Automation.RuntimeException]::new(
                "Error in $($MyInvocation.MyCommand.Name) at line $lineNum`:`n'$line' - $($_.Exception.Message)",
                $_.Exception
            )
        }
    }
    process {
        try {
            # 1) Determine the correct set of MEM permissions
            $readWritePerms = @(
                'DeviceManagementConfiguration.ReadWrite.All',
                'DeviceManagementApps.ReadWrite.All',
                'DeviceManagementManagedDevices.ReadWrite.All',
                'Policy.ReadWrite.ConditionalAccess',
                'Policy.Read.All'
            )
            $readOnlyPerms = @(
                'DeviceManagementConfiguration.Read.All',
                'DeviceManagementApps.Read.All',
                'DeviceManagementManagedDevices.Read.All',
                'Policy.Read.ConditionalAccess',
                'Policy.Read.All'
            )
            $permissions = if ($ReadWrite) { $readWritePerms } else { $readOnlyPerms }
            $permissionsObject = [PSCustomObject]@{
                Graph = $permissions
            }
            Write-AuditLog "Using the following MEM permissions: $($permissions -join ', ')"
            # 2) Build a Graph context object that looks up these permission IDs
            $AppSettings = New-TkRequiredResourcePermissionObject -GraphPermissions $permissions
            # 3) Build an app name for scenario "MemPolicyManager"
            $appName = New-TkAppName -Prefix $AppPrefix -ScenarioName 'MemPolicyManager'
            # 4) Add TenantId & AppName to the object so we can store them in the final JSON
            $AppSettings | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue $Context.TenantId
            $AppSettings | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $appName
            # 5) Create or retrieve the certificate
            $CertDetails = Initialize-TkAppAuthCertificate `
                -AppName $AppSettings.AppName `
                -Thumbprint $CertThumbprint `
                -Subject "CN=$($AppSettings.AppName)" `
                -KeyExportPolicy $KeyExportPolicy `
                -ErrorAction Stop
            # Build a “proposed” object so the user sees what’s about to happen
            $proposedObject = [PSCustomObject]@{
                ProposedAppName           = $AppSettings.AppName
                CertificateThumbprintUsed = $CertDetails.CertThumbprint
                CertificateExpires        = $CertDetails.CertExpires
                TenantID                  = $Context.TenantId
                RequestedPermissions      = ($permissions -join ', ')
                PermissionType            = 'Application'
            }
            Write-AuditLog 'Proposed creation of a new MEM Policy Manager App with the following properties:'
            Write-AuditLog "$($proposedObject | Format-List)"
            # The big If: confirm with ShouldProcess
            $Notes = @"
Certificate Thumbprint: $($CertDetails.CertThumbprint)
Certificate Expires: $($CertDetails.CertExpires)
Tenant ID: $($Context.TenantId)
Graph App Permissions: $($permissions -join ', ')
Read-Write Permissions: $(if ($ReadWrite) { 'ReadWrite' } else { 'Read-Only' })
Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim())
Client Hostname: $env:COMPUTERNAME
"@

            if ($PSCmdlet.ShouldProcess("MemPolicyManager App '$($AppSettings.AppName)'",
                    'Create and configure a new MEM Policy Manager app in Azure AD?')) {
                # 6) Register the application (with the cert)
                $appRegistration = New-TkAppRegistration `
                    -DisplayName $AppSettings.AppName `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -SignInAudience 'AzureADMyOrg' `
                    -Notes $Notes `
                    -ErrorAction Stop
                # 7) Create the Service Principal & grant the permissions
                $ConsentUrl = Initialize-TkAppSpRegistration `
                    -AppRegistration $appRegistration `
                    -Context $Context `
                    -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList `
                    -Scopes $permissionsObject `
                    -AuthMethod 'Certificate' `
                    -CertThumbprint $CertDetails.CertThumbprint `
                    -ErrorAction Stop
                [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.')
                # 8) Build a final PSCustomObject to store in the secret vault
                $output = [PSCustomObject]@{
                    AppId          = $appRegistration.AppId
                    AppName        = "CN=$($AppSettings.AppName)"
                    CertThumbprint = $CertDetails.CertThumbprint
                    ClientId       = $appRegistration.AppId
                    ConsentUrl     = $ConsentUrl
                    PermissionSet  = if ($ReadWrite) { 'ReadWrite' } else { 'ReadOnly' }
                    Permissions    = $permissions
                    TenantId       = $Context.TenantId
                }
                $auditObj = [TkMemPolicyManagerAppParams]::new(
                    $output.AppId,
                    $output.AppName,
                    $output.CertThumbprint,
                    $output.ClientId,
                    $output.ConsentUrl,
                    $output.PermissionSet,
                    $output.Permissions,
                    $output.TenantId
                )
                # 9) Store as JSON secret
                $secretName = "CN=$($AppSettings.AppName)"
                $savedName = Set-TkJsonSecret -Name $secretName -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret
                Write-AuditLog "Secret '$savedName' saved to vault '$VaultName'."
                # Return the final object (param-splat or normal)
                if ($ReturnParamSplat) {
                    return $auditObj | ConvertTo-ParameterSplat
                }
                else {
                    return $auditObj
                }
            }
            else {
                Write-AuditLog 'User elected not to create or configure the MEM Policy Manager App. (ShouldProcess => false).'
            }
        }
        catch {
            throw
        }
    }
    end {
        Write-AuditLog -EndFunction
    }
}

#EndRegion '.\Public\Publish-TkMemPolicyManagerApp.ps1' 256
#Region '.\Public\Send-TkEmailAppMessage.ps1' -1

<#
    .SYNOPSIS
        Sends an email using the Microsoft Graph API, either by retrieving app credentials from a local vault
        or by specifying them manually.
    .DESCRIPTION
        The Send-TkEmailAppMessage function uses the Microsoft Graph API to send an email to a specified
        recipient. It supports two parameter sets:
        1. 'Vault' (default): Provide an existing app name (AppName) whose credentials are stored in the
            local secret vault (e.g., GraphEmailAppLocalStore). The function retrieves the AppId, TenantId,
            and certificate thumbprint automatically.
        2. 'Manual': Provide the AppId, TenantId, and certificate thumbprint yourself, bypassing the vault.
            In both cases, the function obtains an OAuth2 token (via MSAL.PS) using the specified certificate
            and uses the Microsoft Graph 'sendMail' endpoint to deliver the message.
    .PARAMETER AppName
        [Vault Parameter Set Only]
        The name of the pre-created Microsoft Graph Email App (stored in GraphEmailAppLocalStore). Used only
        if the 'Vault' parameter set is chosen. The function retrieves the AppId, TenantId, and certificate
        thumbprint from the vault entry.
    .PARAMETER AppId
        [Manual Parameter Set Only]
        The Azure AD application (client) ID to use for sending the email. Must be used together with TenantId
        and CertThumbprint in the 'Manual' parameter set.
    .PARAMETER TenantId
        [Manual Parameter Set Only]
        The Azure AD tenant ID (GUID or domain name). Must be used together with AppId and CertThumbprint
        in the 'Manual' parameter set.
    .PARAMETER CertThumbprint
        [Manual Parameter Set Only]
        The certificate thumbprint (in Cert:\CurrentUser\My) used for authenticating as the Azure AD app.
        Must be used together with AppId and TenantId in the 'Manual' parameter set.
    .PARAMETER To
        The email address of the recipient.
    .PARAMETER FromAddress
        The email address of the sender who is authorized to send email as configured in the Graph Email App.
    .PARAMETER Subject
        The subject line of the email.
    .PARAMETER EmailBody
        The body text of the email.
    .PARAMETER AttachmentPath
        An array of file paths for any attachments to include in the email. Each path must exist as a leaf file.
    .EXAMPLE
        # Using the 'Vault' parameter set
        Send-TkEmailAppMessage -AppName "GraphEmailApp" -To "recipient@example.com" -FromAddress "sender@example.com" `
            -Subject "Test Email" -EmailBody "This is a test email."
        Retrieves the app's credentials (AppId, TenantId, CertThumbprint) from the local vault under the
        secret name "GraphEmailApp" and sends an email.
    .EXAMPLE
        # Using the 'Manual' parameter set
        Send-TkEmailAppMessage -AppId "00000000-1111-2222-3333-444444444444" -TenantId "contoso.onmicrosoft.com" `
            -CertThumbprint "AABBCCDDEEFF11223344556677889900" -To "recipient@example.com" -FromAddress "sender@example.com" `
            -Subject "Manual Email" -EmailBody "Hello from Manual!"
        Uses the provided AppId, TenantId, and CertThumbprint directly (no vault) to obtain a token and send an email.
    .NOTES
        - This function requires the Microsoft.Graph, SecretManagement, SecretManagement.JustinGrote.CredMan,
            and MSAL.PS modules to be installed (handled automatically via Initialize-TkModuleEnv).
        - For the 'Vault' parameter set, the local vault secret must store JSON properties including AppId,
            TenantID, and CertThumbprint.
        - Refer to https://learn.microsoft.com/en-us/graph/outlook-send-mail for details on sending mail
            via Microsoft Graph.
#>

function Send-TkEmailAppMessage {
    [CmdletBinding(DefaultParameterSetName = 'Vault')]
    param(
        # Use the vault-based approach (default)
        [Parameter(
            ParameterSetName = 'Vault',
            Mandatory = $true,
            HelpMessage = 'The name of the pre-created Email App from the vault.'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $AppName,
        # Use the manual approach (no vault)
        [Parameter(
            ParameterSetName = 'Manual',
            Mandatory = $true,
            HelpMessage = 'Manually specify the App (Client) ID.'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $AppId,
        [Parameter(
            ParameterSetName = 'Manual',
            Mandatory = $true,
            HelpMessage = 'Manually specify the Azure AD tenant (GUID or domain).'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $TenantId,
        [Parameter(
            ParameterSetName = 'Manual',
            Mandatory = $true,
            HelpMessage = 'Manually specify the certificate thumbprint in Cert:\CurrentUser\My.'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $CertThumbprint,
        # Common parameters for both parameter sets
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Vault',
            HelpMessage = 'The email address of the recipient.'
        )]
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Manual',
            HelpMessage = 'The email address of the recipient.'
        )]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
        [string]
        $To,
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Vault',
            HelpMessage = 'The email address of the sender.'
        )]
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Manual',
            HelpMessage = 'The email address of the sender.'
        )]
        [ValidateNotNullOrEmpty()]
        [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')]
        [string]
        $FromAddress,
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Vault',
            HelpMessage = 'The subject line of the email.'
        )]
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Manual',
            HelpMessage = 'The subject line of the email.'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $Subject,
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Vault',
            HelpMessage = 'The body text of the email.'
        )]
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Manual',
            HelpMessage = 'The body text of the email.'
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $EmailBody,
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'Vault',
            HelpMessage = 'The path to the attachment file.'
        )]
        [Parameter(
            Mandatory = $false,
            ParameterSetName = 'Manual',
            HelpMessage = 'The path to the attachment file.'
        )]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path $_ -PathType 'Leaf' })]
        [string[]]
        $AttachmentPath
    )
    begin {
        if (!($script:LogString)) {
            Write-AuditLog -Start
        }
        else {
            Write-AuditLog -BeginFunction
        }
        Write-AuditLog '###########################################################'
        # Install and import the Microsoft.Graph module. Tested: 1.22.0
        $PublicMods = `
            'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan', 'MSAL.PS'
        $PublicVers = `
            '1.1.2', '1.0.0', '4.37.0.0'
        $params1 = @{
            PublicModuleNames      = $PublicMods
            PublicRequiredVersions = $PublicVers
            Scope                  = 'CurrentUser'
        }
        Initialize-TkModuleEnv @params1
        # If manual parameter set:
        if ($PSCmdlet.ParameterSetName -eq 'Manual') {
            $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertThumbprint } -ErrorAction Stop
            $GraphEmailApp = @{
                AppId          = $AppId
                CertThumbprint = $CertThumbprint
                TenantID       = $TenantId
                CertExpires    = $cert.NotAfter
            }
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'Vault') {
            # If a GraphEmailApp object was not passed in, attempt to retrieve it from the local machine
            if ($AppName) {
                try {
                    # Step 7:
                    # Define the application Name and Encrypted File Paths.
                    $Auth = Get-Secret -Name "$AppName" -Vault GraphEmailAppLocalStore -AsPlainText -ErrorAction Stop
                    $authObj = $Auth | ConvertFrom-Json
                    $GraphEmailApp = $authObj
                    $AppId = $GraphEmailApp.AppId
                    $CertThumbprint = $GraphEmailApp.CertThumbprint
                    $Tenant = $GraphEmailApp.TenantID
                    $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertThumbprint } -ErrorAction Stop
                }
                catch {
                    Write-Error $_.Exception.Message
                }
            } # End Region If
        }
        if (!$GraphEmailApp) {
            throw 'GraphEmailApp object not found. Please specify the GraphEmailApp object or provide the AppName and RedirectUri parameters.'
        } # End Region If
        # Instantiate the required variables for retrieving the token.
        # Retrieve the self-signed certificate from the CurrentUser's certificate store
        if (!($cert)) {
            throw "Certificate with thumbprint $CertThumbprint not found in CurrentUser's certificate store"
        } # End Region If
        Write-AuditLog 'The Certificate:'
        Write-AuditLog $CertThumbprint
        Write-AuditLog "will expire on $($GraphEmailApp.CertExpires)"
        Write-AuditLog -Message 'Retrieved Certificate with thumbprint:'
        Write-AuditLog "$CertThumbprint"
    } # End Region Begin
    Process {
        # Authenticate with Azure AD and obtain an access token for the Microsoft Graph API using the certificate
        $MSToken = Get-MsalToken -ClientCertificate $Cert -ClientId $AppId -Authority "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ErrorAction Stop
        # Set up the request headers
        $authHeader = @{Authorization = "Bearer $($MSToken.AccessToken)" }
        # Set up the request URL
        $url = "https://graph.microsoft.com/v1.0/users/$($FromAddress)/sendMail"
        # Build the message body
        # Add a "from" field to the message object in $Message
        $FromField = @{
            emailAddress = @{
                address = "$($FromAddress)"
            }
        }
        $Message = @{
            message = @{
                subject      = "$Subject"
                body         = @{
                    contentType = 'text'
                    content     = "$EmailBody"
                }
                toRecipients = @(
                    @{
                        emailAddress = @{
                            address = "$To"
                        }
                    }
                )
                from         = $FromField
            }
        }
        if ($AttachmentPath) {
            Write-AuditLog -Message 'Attachments found. Processing...'
            $Message.message.attachments = @()
            foreach ($Path in $AttachmentPath) {
                $attachmentName = (Split-Path -Path $Path -Leaf)
                $attachmentBytes = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Path))
                $attachment = @{
                    '@odata.type'  = '#microsoft.graph.fileAttachment'
                    'Name'         = $attachmentName
                    'ContentBytes' = $attachmentBytes
                }
                $Message.message.attachments += $attachment
            }
        }
        $jsonMessage = $message | ConvertTo-Json -Depth 4
        $body = $jsonMessage
        Write-AuditLog -Message 'Processed message body. Ready to send email.'
    }
    End {
        try {
            # Send the email message using the Invoke-RestMethod cmdlet
            Write-AuditLog 'Sending email via Microsoft Graph.'
            [void](Invoke-RestMethod -Headers $authHeader -Uri $url -Body $body -Method POST -ContentType 'application/json' -ErrorAction Stop)
            Write-AuditLog "To : $To"
            Write-AuditLog "From : $FromAddress"
            Write-AuditLog "Attachments Sent : $(($Message.message.attachments).Count)"
            Write-AuditLog -EndFunction
        }
        catch {
            throw
        }
    } # End Region End
}
#EndRegion '.\Public\Send-TkEmailAppMessage.ps1' 294