MailDaemon.psm1
|
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PSFPowerShellDataFile -Path "$($script:ModuleRoot)\MailDaemon.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName MailDaemon.Import.DoDotSource -Fallback $false if ($MailDaemon_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName MailDaemon.Import.IndividualFiles -Fallback $false if ($MailDaemon_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'MailDaemon' -Language 'en-US' function Connect-MDGraph { <# .SYNOPSIS Connects to graph as configured. If configured. .DESCRIPTION Connects to graph as configured. If configured. .EXAMPLE PS C:\> Connect-MDGraph Connects to graph as configured. If configured. #> [CmdletBinding()] param () process { $settings = Select-PSFConfig -FullName 'MailDaemon.Daemon.Graph.*' if ($settings.NoAuth) { return } if ($settings.Identity) { Connect-EntraService -Service Graph -Identity return } if (-not $settings.ClientID -or -not $settings.TenantID) { Stop-PSFFunction -String 'Connect-MDGraph.Error.NoClientIDorTenantID' -EnableException $true -Cmdlet $PSCmdlet -Category InvalidData } if ( -not $settings.CertificateName -and -not $settings.CertificateThumbprint -and -not $settings.Federated ) { Stop-PSFFunction -String 'Connect-MDGraph.Error.NoAuthPath' -EnableException $true -Cmdlet $PSCmdlet -Category InvalidData } $idParam = $settings | ConvertTo-PSFHashtable -Include ClientID, TenantID if ($settings.Federated) { Connect-EntraService @idParam -Federated return } if ($settings.CertificateThumbprint) { Connect-EntraService @idParam -CertificateThumbprint $settings.CertificateThumbprint return } if ($settings.CertificateName) { Connect-EntraService @idParam -CertificateName $settings.CertificateName return } } } function Copy-Module { <# .SYNOPSIS Copies a module from one computer to another. .DESCRIPTION Copies a module from one computer to another. All transfers done via WinRM / Powershell Remoting. .PARAMETER ModuleName The name of the module to copy. Also accepts a path to the module root folder. .PARAMETER ModuleObject A specific module instance to copy (returned by Get-Module). .PARAMETER FromComputer The computer from which to pick up the module. Localhost by default. Accepts and reuses PSSession objects. .PARAMETER ToComputer The computer(s) on which to install the module. Accepts and reuses PSSession objects. .PARAMETER Credential The credentials to use when connecting to computers. .EXAMPLE PS C:\> Copy-Module -ModuleName BeerFactory -ToComputer server1 Copies the module 'BeerFactory' from localhost to server1 #> [CmdletBinding(DefaultParameterSetName = 'Object')] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'String', Position = 0)] [string[]] $ModuleName, [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = 'Object')] [PSModuleInfo[]] $ModuleObject, [Parameter(Mandatory = $true)] [PSFComputer[]] $ToComputer, [PSFComputer] $FromComputer = $env:COMPUTERNAME, [PSCredential] $Credential ) begin { $receiveScript = { param ( [string] $Module ) #region Specified a path $uri = [uri]$Module if ($uri.IsFile) { if (-not (Test-Path $Module)) { return [pscustomobject]@{ Module = $Module Success = $false Data = @() } } $sourcePath = "$($Module)\*" $moduleName = (Get-Module $Module -ListAvailable).Name $moduleVersion = (Get-Module $Module -ListAvailable).Version } #endregion Specified a path #region Specified a module name else { $moduleObject = Get-Module $Module | Sort-Object Version -Descending | Select-Object -First 1 if (-not $moduleObject) { return [pscustomobject]@{ Module = $Module Success = $false Data = @() } } $sourcePath = "$($moduleObject.ModuleBase)\*" $moduleName = $moduleObject.Name $moduleVersion = $moduleObject.Version } #endregion Specified a module name #region Gather module object $tempPath = "$($env:TEMP)\$(New-Guid).zip" $workingFolder = New-Item -Path $env:TEMP -Name (New-Guid) -ItemType Directory # Copy item is important, as the zip commands cannot access locked dlls, copy-item can. Copy-Item -Path $sourcePath -Destination $workingFolder.FullName -Recurse Compress-Archive -Path "$($workingFolder.FullName)\*" -DestinationPath $tempPath [pscustomobject]@{ Name = $moduleName Version = $moduleVersion Data = [System.IO.File]::ReadAllBytes($tempPath) Module = $Module Success = $true } Remove-Item $tempPath Remove-Item $workingFolder.FullName -Recurse -Force #endregion Gather module object } $installScript = { param ( $Modules ) #region Update the modules foreach ($module in $Modules) { $installRoot = "$($env:ProgramFiles)\WindowsPowerShell\Modules" if (-not (Test-Path "$($installRoot)\$($module.Name)")) { $null = New-Item -Path $installRoot -Name $module.Name -ItemType Directory -Force } $root = New-Item -Path "$($installRoot)\$($module.Name)" -Name $module.Version -ItemType Directory -Force $tempPath = "$($env:TEMP)\$(New-Guid).zip" [System.IO.File]::WriteAllBytes($tempPath, $Module.Data) Expand-Archive -Path $tempPath -DestinationPath $root.FullName -Force Remove-Item $tempPath } #endregion Update the modules } } process { foreach ($name in $ModuleName) { Write-PSFMessage -String 'Copy-Module.ReceivingModule' -StringValues $FromComputer, $name $module = Invoke-PSFCommand -ComputerName $FromComputer -Credential $Credential -ScriptBlock $receiveScript -ArgumentList $name if (-not $module.Success) { Stop-PSFFunction -String 'Copy-Module.ReceivingModule.Failed' -StringValues $FromComputer, $name -Continue -SilentlyContinue -Cmdlet $PSCmdlet } Write-PSFMessage -String 'Copy-Module.InstallingModule' -StringValues $name, ($ToComputer -join ", ") Invoke-PSFCommand -ComputerName $ToComputer -Credential $Credential -ScriptBlock $installScript -ArgumentList $module } foreach ($object in $ModuleObject) { Write-PSFMessage -String 'Copy-Module.ReceivingModule' -StringValues $FromComputer, $object.ModuleBase $module = Invoke-PSFCommand -ComputerName $FromComputer -Credential $Credential -ScriptBlock $receiveScript -ArgumentList $object.ModuleBase if (-not $module.Success) { Stop-PSFFunction -String 'Copy-Module.ReceivingModule.Failed' -StringValues $FromComputer, $object.ModuleBase -Continue -SilentlyContinue -Cmdlet $PSCmdlet } Write-PSFMessage -String 'Copy-Module.InstallingModule' -StringValues $object.ModuleBase, ($ToComputer -join ", ") Invoke-PSFCommand -ComputerName $ToComputer -Credential $Credential -ScriptBlock $installScript -ArgumentList $module } } } function Publish-GraphAttachment { <# .SYNOPSIS Adds a file-attachment to an email draft. .DESCRIPTION Adds a file-attachment to an email draft. Uses an upload session and can upload attachments up to 150MB. .PARAMETER Path Path to the file to upload. .PARAMETER Message The graph-path to the email draft. E.g.: "users/<userid>/messages/$($draft.id)" .EXAMPLE PS C:\> Publish-GraphAttachment -Path $attachment -Message "users/$From/messages/$($draft.id)" Uploads the specified file as attachment to the specified draft message. #> [CmdletBinding()] param ( [string] $Path, [string] $Message ) $file = Get-Item -Path $Path $session = Invoke-EntraRequest -Method POST -Path "$Message/attachments/createUploadSession" -ContentType 'application/json' -Body @{ AttachmentItem = @{ attachmentType = 'file' name = $file.Name size = $file.Length } } $null = Invoke-RestMethod -Method Put -Uri $session.uploadUrl -InFile $file.FullName -Headers @{ 'Content-Range' = "bytes 0-$($file.Length - 1)/$($file.Length)" 'Content-Type' = 'application/octet-stream' } } function Send-GraphMail { <# .SYNOPSIS Sends an email via Graph API. .DESCRIPTION Sends an email via Graph API. Must be connected to Graph first using Connect-EntraService. .PARAMETER From Who sends the email. Provide the UPN to the user account, will use the default email address only. .PARAMETER To Recipient of the email. .PARAMETER Cc Additional recipients of the email. .PARAMETER Bcc Even more recpients of the email. Invisible to each other. .PARAMETER Subject Subject of the email to send. Defaults to "<no subject>" .PARAMETER Body Body (text) of the email. .PARAMETER BodyAsHtml Whether the email is to be sent as html body. Will be sent as a plaintext email if not specified. .PARAMETER Attachments Any files to include in the email. .PARAMETER Priority The priority to assign to the email .EXAMPLE PS C:\> Send-GraphMail -From fred@contoso.com -To Max@contoso.com -Subject Test -Body "Test Mail" Sends a simple test email. #> [CmdletBinding()] param ( [string] $From, [string[]] $To, [string[]] $Cc, [string[]] $Bcc, [string] $Subject = '<no subject>', [string] $Body, [switch] $BodyAsHtml, [string[]] $Attachments, [ValidateSet('Low', 'Normal', 'High')] [string] $Priority ) Assert-EntraConnection -Service Graph -Cmdlet $PSCmdlet $draft = $null try{ # Step 1: Create Draft $reqBody = @{ subject = $Subject } if ($Body) { $reqBody["body"] = @{ contentType = 'text' content = $Body } if ($BodyAsHtml) { $reqBody.body.contentType = 'html' } } if ($To) { $reqBody['toRecipients'] = @() foreach ($entry in $To) { $reqBody['toRecipients'] += @{ emailAddress = @{ address = $entry } } } } if ($Cc) { $reqBody['ccRecipients'] = @() foreach ($entry in $Cc) { $reqBody['ccRecipients'] += @{ emailAddress = @{ address = $entry } } } } if ($Bcc) { $reqBody['bccRecipients'] = @() foreach ($entry in $Bcc) { $reqBody['bccRecipients'] += @{ emailAddress = @{ address = $entry } } } } if ($Priority) { $reqBody['importance'] = $Priority.ToLower() } $draft = Invoke-EntraRequest -Method Post -Path "users/$From/messages" -Header @{ 'Content-Type' = 'application/json' } -Body $reqBody # Step 2: Upload Attachments foreach ($attachment in $Attachments) { Publish-GraphAttachment -Path $attachment -Message "users/$From/messages/$($draft.id)" } # Step 3: Send $null = Invoke-EntraRequest -Method POST -Path "users/$From/messages/$($draft.id)/send" } catch { # Step X: Undo on failure if ($draft) { Invoke-EntraRequest -Method DELETE -Path "users/$From/messages/$($draft.id)" } throw } } function Test-Module { <# .SYNOPSIS Tests for module existence. .DESCRIPTION Tests whether a module - or set of modules - exists on the target machine(s). Includes support for version requirements (minimum or maximum). .PARAMETER Name Name of the module(s) to search for. .PARAMETER Version The version constraint. Whether that is the minimum, maximum or exactly this version is governed by the -Test parameter. The same version constraint will be applied to all modules specified! For custom versions per module, please use the -Module parameter to specify a hashtable with the mapping. .PARAMETER Module The combination of modules and versions to test. Specify the modulename as key and the version as value. E.g.: @{ MailDaemon = '1.0.0' } Specify '0.0' in order to not test about any specific version. .PARAMETER Test How to test for version. By default, the test will search for 'GreaterEqual' (that is: At least the specified version). Supported scenarios: 'LesserThan', 'LesserEqual', 'Equal', 'GreaterEqual', 'GreaterThan' Note on Lesser* comparisons: This only tests whether a version below the limit is present. It does not Test that NO greater version is available! .PARAMETER Quiet Disables output objects and instead returns $true if all modules specified meet the requirements, $false if not so. .PARAMETER ComputerName The computers on which to test. Uses WinRM / PowerShell Remoting to perform test. .PARAMETER Credential The credentials to use for connecting to computers for the test. Will be ignored for localhost. .EXAMPLE PS C:\> Test-Module -Name 'MyModule' Tests whether the module MyModule is available in any version. .EXAMPLE PS C:\> Test-Module -Name MailDaemon -Version 1.1.0 -ComputerName 'server1', 'Server2' Tests whether the module MailDaemon is available in at least version 1.1.0 on the computers server1 and server2. .EXAMPLE PS C:\> Test-Module -Name PSFramework -Version 1.0.0 -Quiet -Test 'Equal' Returns $true if the module PSFramework exists locally in exactly version 1.0.0, $false otherwise. .EXAMPLE PS C:\> Test-Module -Module @{ PSFramework = '1.0.0'; MailDaemon = '1.1.0' } -Test 'LesserThan' Returns whether PSFramework is present in any version less than 1.0.0 Returns whether MailDaemon is present in any version less than 1.1.0 #> [CmdletBinding(DefaultParameterSetName = 'Name')] param ( [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Name')] [string[]] $Name, [Parameter(Position = 1, ParameterSetName = 'Name')] [version] $Version = '0.0.0.0', [Parameter(Mandatory = $true, ParameterSetName = 'Hash')] [hashtable] $Module, [ValidateSet('LesserThan', 'LesserEqual', 'Equal', 'GreaterEqual', 'GreaterThan')] [string] $Test = 'GreaterEqual', [switch] $Quiet, [Parameter(ValueFromPipeline = $true)] [PSFComputer[]] $ComputerName = $env:COMPUTERNAME, [AllowNull()] [PSCredential] $Credential ) begin { #region Prepare Module parameter $moduleHash = $Module foreach ($moduleName in $Name) { $moduleHash[$moduleName] = $Version } foreach ($key in ([string[]]$moduleHash.Keys)) { $moduleHash[$key] = $moduleHash[$key] -as [Version] if (-not $moduleHash[$key]) { $moduleHash[$key] = ([Version]'0.0.0.0') } } #endregion Prepare Module parameter #region Validation Scriptblock $scriptBlock = { param ( [hashtable] $ModuleHash, [string] $Test, [bool] $Quiet ) #region Utility Functions function Write-Result { [CmdletBinding()] param ( [string] $Name, $Success, [AllowNull()] [AllowEmptyCollection()] $VersionsFound, [string] $Test ) $result = [bool]$Success [PSCustomObject]@{ Name = $Name Success = $result VersionsFound = $VersionsFound ComputerName = $env:COMPUTERNAME Test = $Test } } #endregion Utility Functions #region Validate each module specified foreach ($module in $ModuleHash.Keys) { $modulesFound = Get-Module -Name $module -ListAvailable if ($Quiet -and (-not $modulesFound)) { return $false } if ($ModuleHash[$module] -le '0.0.0.0') { Write-Result -Name $module -Success $modulesFound -VersionsFound $modulesFound.Version -Test $Test continue } #region Quiet Validation [Calls Continue] if ($Quiet) { switch ($Test) { 'LesserThan' { if (-not ($modulesFound | Where-Object Version -LT $ModuleHash[$module])) { return $false } } 'LesserEqual' { if (-not ($modulesFound | Where-Object Version -LE $ModuleHash[$module])) { return $false } } 'Equal' { if (-not ($modulesFound | Where-Object Version -EQ $ModuleHash[$module])) { return $false } } 'GreaterEqual' { if (-not ($modulesFound | Where-Object Version -GE $ModuleHash[$module])) { return $false } } 'GreaterThan' { if (-not ($modulesFound | Where-Object Version -GT $ModuleHash[$module])) { return $false } } } continue } #endregion Quiet Validation [Calls Continue] switch ($Test) { 'LesserThan' { Write-Result -Name $module -Success ($modulesFound | Where-Object Version -LT $ModuleHash[$module]) -VersionsFound $modulesFound.Version -Test $Test } 'LesserEqual' { Write-Result -Name $module -Success ($modulesFound | Where-Object Version -LE $ModuleHash[$module]) -VersionsFound $modulesFound.Version -Test $Test } 'Equal' { Write-Result -Name $module -Success ($modulesFound | Where-Object Version -EQ $ModuleHash[$module]) -VersionsFound $modulesFound.Version -Test $Test } 'GreaterEqual' { Write-Result -Name $module -Success ($modulesFound | Where-Object Version -GE $ModuleHash[$module]) -VersionsFound $modulesFound.Version -Test $Test } 'GreaterThan' { Write-Result -Name $module -Success ($modulesFound | Where-Object Version -GT $ModuleHash[$module]) -VersionsFound $modulesFound.Version -Test $Test } } } #endregion Validate each module specified if ($Quiet) { return $true } } #endregion Validation Scriptblock } process { Invoke-PSFCommand -ComputerName $ComputerName -Credential $Credential -ScriptBlock $scriptBlock -ArgumentList $moduleHash, $Test, $Quiet.ToBool() -HideComputerName } } function Add-MDMailContent { <# .SYNOPSIS Adds content to a pending email. .DESCRIPTION Adds content to a pending email. Use this command to incrementally add to the mail sent. .PARAMETER Body Add text to the mail body. .PARAMETER Attachments Add files to the list of files to send. .EXAMPLE PS C:\> Add-MDMailContent -Body "Phase 3: Completed" Adds the line "Phase 3: Completed" to the email body. #> [CmdletBinding()] Param ( [string] $Body, [string[]] $Attachments ) begin { if (-not $script:mail) { $script:mail = @{ } } } process { if ($Body) { if (-not ($script:mail["Body"])) { $script:mail["body"] = $Body } else { $script:mail["Body"] = $script:mail["Body"], $Body -join "`n" } } if ($Attachments) { if (-not $script:mail["Attachments"]) { $script:mail["Attachments"] = $Attachments } else { $script:mail["Attachments"] = @($script:mail["Attachments"]) + @($Attachments) } } } } function Install-MDDaemon { <# .SYNOPSIS Configures a computer for using the Mail Daemon .DESCRIPTION Configures a computer for using the Mail Daemon. This can include: - Installing the scheduled task - Creating folder and permission structure - Setting up the mail daemon configuration This action can be performed both locally or against remote computers .PARAMETER ComputerName The computer(s) to work against. Defaults to localhost, but can be used to install the module and set up the task across a wide range of computers. .PARAMETER Credential The credentials to use when connecting to computers. .PARAMETER NoTask Create the scheduled task. .PARAMETER TaskUser The credentials of the user the scheduled task will be executed as. .PARAMETER PickupPath The folder in which emails are queued for delivery. .PARAMETER SentPath The folder in which emails that were successfully sent are stored for a specified time before being deleted. .PARAMETER FailedPath The path where mails that could repeatedly not be sent are moved to. .PARAMETER DaemonUser The user to grant permissions needed to function as the Daemon account. This grants read/write access to all working folders. .PARAMETER WriteUser The user/group to grant permissions to needed to queue email. This grants write-only access to the mail inbox. .PARAMETER MailSentRetention The time to keep successfully sent emails around. .PARAMETER MailAbandonThreshold How long we attempt to send an email before abandoning it and moving it to -FailedPath. .PARAMETER MailFailedRetention How long we keep an abandoned email around before removing it entirely. .PARAMETER SmtpServer The mailserver to use for sending emails. .PARAMETER SenderDefault The default email address to use as sender. This is used for mails queued by a task that did not specify a sender. .PARAMETER SenderCredential The credentials to use to send emails. Will be stored in an encrypted file that can only be opened by the taskuser and from the computer it is installed on. .PARAMETER RecipientDefault Default email address to send the email to, if the individual script queuing the email does not specify one. .PARAMETER UseSSL Use SSL for sending emails. .PARAMETER ClientID The ClientID of the Application to use for sending emails via Graph API. .PARAMETER TenantID The TenantID of the Application to use for sending emails via Graph API. .PARAMETER Identity When authenticating to Entra for sending emails via Graph API, use the current Managed Identity to authenticate. .PARAMETER Federated When authenticating to Entra for sending emails via Graph API, use Federated Credentials to authenticate. This uses the current Managed Identity to get a token they can use to authenticate to the application with the actual permissions. .PARAMETER CertificateThumbprint When authenticating to Entra for sending emails via Graph API, use the certificate with the specified thumbprint. The certificate must be stored in one of the local certificate stores. .PARAMETER CertificateName When authenticating to Entra for sending emails via Graph API, use the newest certificate with the specified subject. The certificate must be stored in one of the local certificate stores. .PARAMETER NoLogging Disables logging. Unless specified, this setup step will also prepare the windows eventlog by creating a dedicated eventlog for MailDaemon. .EXAMPLE PS C:\> Install-MDDaemon -ComputerName DC1, DC2, DC3 -TaskUser $cred -DaemonUser "DOMAIN\MailDaemon" -SmtpServer 'mail.domain.org' -SenderDefault 'daemon@domain.org' -RecipientDefault 'helpdesk-t2@domain.org' Configures the mail daemon NoTask on the servers DC1, DC2 and DC3 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)] [PSFComputer[]] $ComputerName = $env:COMPUTERNAME, [PSCredential] $Credential, [switch] $NoTask, [PSCredential] $TaskUser, [string] $PickupPath, [string] $SentPath, [string] $FailedPath, [string] $DaemonUser, [string[]] $WriteUser, [Timespan] $MailSentRetention, [Timespan] $MailAbandonThreshold, [Timespan] $MailFailedRetention, [string] $SmtpServer, [string] $SenderDefault, [PSCredential] $SenderCredential, [string] $RecipientDefault, [switch] $UseSSL, [string] $ClientID, [string] $TenantID, [switch] $Identity, [switch] $Federated, [string] $CertificateThumbprint, [string] $CertificateName, [switch] $NoLogging ) begin { #region Repetitions (ugly) # Specifying repetitions directly in the commandline is ugly. # It ignores explicit settings and requires copying the repetition object from another task. # Since we do not want to rely on another task being available, instead I chose to store an object in its XML form. # By deserializing this back into an object at runtime we can carry an object in scriptcode. $object = @' <Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04"> <Obj RefId="0"> <TN RefId="0"> <T>Microsoft.Management.Infrastructure.CimInstance#Root/Microsoft/Windows/TaskScheduler/MSFT_TaskRepetitionPattern</T> <T>Microsoft.Management.Infrastructure.CimInstance#MSFT_TaskRepetitionPattern</T> <T>Microsoft.Management.Infrastructure.CimInstance</T> <T>System.Object</T> </TN> <ToString>MSFT_TaskRepetitionPattern</ToString> <Props> <S N="Duration">P1D</S> <S N="Interval">PT30M</S> <B N="StopAtDurationEnd">false</B> <Nil N="PSComputerName" /> </Props> <MS> <Obj N="__ClassMetadata" RefId="1"> <TN RefId="1"> <T>System.Collections.ArrayList</T> <T>System.Object</T> </TN> <LST> <Obj RefId="2"> <MS> <S N="ClassName">MSFT_TaskRepetitionPattern</S> <S N="Namespace">Root/Microsoft/Windows/TaskScheduler</S> <S N="ServerName">C0020127</S> <I32 N="Hash">-1401671928</I32> <S N="MiXml"><CLASS NAME="MSFT_TaskRepetitionPattern"><PROPERTY NAME="Duration" TYPE="string"></PROPERTY><PROPERTY NAME="Interval" TYPE="string"></PROPERTY><PROPERTY NAME="StopAtDurationEnd" TYPE="boolean"></PROPERTY></CLASS></S> </MS> </Obj> </LST> </Obj> </MS> </Obj> </Objs> '@ $repetitionObject = [System.Management.Automation.PSSerializer]::Deserialize($object) #endregion Repetitions (ugly) #region Setup Task Configuration if (-not $NoTask) { $action = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -Command Invoke-MDDaemon" if ($NoLogging) { $action = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -Command Invoke-MDDaemon -NoLogging" } $triggers = @() $triggers += New-ScheduledTaskTrigger -AtStartup -RandomDelay "00:15:00" $triggers += New-ScheduledTaskTrigger -At "00:00:00" -Daily if ($TaskUser) { $principal = New-ScheduledTaskPrincipal -UserId $TaskUser.UserName -LogonType Interactive } else { $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType Interactive } $taskItem = New-ScheduledTask -Action $action -Principal $principal -Trigger $triggers -Description "Mail Daemon task, checks for emails to send at a specified interval. Uses the internal MailDaemon module." $taskItem.Author = Get-PSFConfigValue -FullName 'MailDaemon.Task.Author' -Fallback "$($env:USERDOMAIN) IT Department" $taskItem.Triggers[1].Repetition = $repetitionObject $parametersRegister = @{ TaskName = 'MailDaemon' InputObject = $taskItem } if ($TaskUser) { $parametersRegister["User"] = $TaskUser.UserName $parametersRegister["Password"] = $TaskUser.GetNetworkCredential().Password } } #endregion Setup Task Configuration #region Preparing Parameters $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include 'PickupPath', 'SentPath', 'FailedPath', 'MailSentRetention', 'MailAbandonThreshold', 'MailFailedRetention', 'SmtpServer', 'SenderDefault', 'RecipientDefault', 'UseSSL', 'ClientID', 'TenantID', 'Identity', 'Federated', 'CertificateThumbprint', 'CertificateName' if ($parameters.Federated -or $parameters.Identity -or $parameters.ClientID) { $parameters.Type = 'Graph' } $paramMainInstallCall = @{ ArgumentList = $parameters Credential = $Credential } #endregion Preparing Parameters #region The Main Setup Scriptblock $paramMainInstallCall["ScriptBlock"] = { param ( $Parameters ) Import-Module -Name PSFramework Import-Module -Name MailDaemon Set-MDDaemon @Parameters #region Set file permissions if (-not (Test-Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath'))) { $null = New-Item (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath') -Force -ItemType Directory } if (-not (Test-Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath'))) { $null = New-Item (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath') -Force -ItemType Directory } if ($Parameters.DaemonUser) { Update-MDFolderPermission -DaemonUser $Parameters.DaemonUser } if ($Parameters.WriteUser) { Update-MDFolderPermission -WriteUser $Parameters.WriteUser } #endregion Set file permissions } #endregion The Main Setup Scriptblock } process { #region Ensure Modules are installed $testResults = Test-Module -ComputerName $ComputerName -Credential $Credential -Module @{ MailDaemon = $script:ModuleVersion PSFramework = (Get-Module -Name PSFramework).Version } $failedTests = $testResults | Where-Object Success -EQ $false if ($failedTests) { $grouped = $failedTests | Group-Object Name foreach ($groupSet in $grouped) { Copy-Module -ModuleName (Get-Module $groupSet.Name).ModuleBase -ToComputer $groupSet.Group.ComputerName } } #endregion Ensure Modules are installed $paramMainInstallCall['ComputerName'] = $ComputerName Invoke-PSFCommand @paramMainInstallCall #region Securely store credentials if ($PSBoundParameters.ContainsKey('SenderCredential')) { $parametersSave = @{ ComputerName = $ComputerName TargetCredential = $SenderCredential Path = 'C:\ProgramData\PowerShell\MailDaemon\senderCredentials.clixml' } if ($Credential) { $parametersSave['Credential'] = $Credential } if ($TaskUser) { $parametersSave['AccessAccount'] = $TaskUser } Save-MDCredential @parametersSave $parametersInvoke = @{ ComputerName = $ComputerName } if ($Credential) { $parametersInvoke['Credential'] = $Credential } Invoke-PSFCommand @parametersInvoke -ScriptBlock { Set-MDDaemon -SenderCredentialPath "C:\ProgramData\PowerShell\MailDaemon\senderCredentials.clixml" } } #endregion Securely store credentials #region Setup Logging if (-not $NoLogging) { Invoke-PSFCommand @parametersInvoke -ScriptBlock { if ($PSVersionTable.PSVersion.Major -gt 5 -and -not $IsWindows) { return } Set-PSFLoggingProvider -Name eventlog -InstanceName MailDaemonInvoke -LogName MailDaemon -Source MailDaemon -Enabled $true -Wait Write-PSFMessage -Message "Setting up MailDaemon logging" Disable-PSFLoggingProvider -Name eventlog -InstanceName MailDaemonInvoke } } #endregion Setup Logging #region Setup Task if (-not $NoTask) { Invoke-PSFCommand @parametersInvoke -ScriptBlock { param ($ParametersRegister) $taskObject = Get-ScheduledTask -TaskName $ParametersRegister.TaskName -ErrorAction Ignore if ($taskObject) { $taskObject | Unregister-ScheduledTask } $null = Register-ScheduledTask @ParametersRegister } -ArgumentList $parametersRegister } #endregion Setup Task } } function Invoke-MDDaemon { <# .SYNOPSIS Processes the email queue and sends emails .DESCRIPTION Processes the email queue and sends emails. Should be scheduled using a scheduled task. Recommended Setting: - Launch on boot with delay - Launch on Midnight - Repeat every 30 minutes for one day .PARAMETER NoLogging Disables Eventlog logging. By default, the mail invocation is logged to the Windows Eventlog. .EXAMPLE PS C:\> Invoke-MDDaemon Processes the email queue and sends emails #> [CmdletBinding()] param ( [switch] $NoLogging ) begin { if (-not (Test-Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath'))) { $null = New-Item -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath') -ItemType Directory } if (-not (Test-Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath'))) { $null = New-Item -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath') -ItemType Directory } $failedPath = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailFailedPath' $abandonThreshold = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailAbandonThreshold' if (-not (Test-Path $failedPath)) { $null = New-Item -Path $failedPath -ItemType Directory } if (-not $NoLogging -and ($PSVersionTable.PSVersion.Major -lt 6 -or $IsWindows)) { Set-PSFLoggingProvider -Name eventlog -InstanceName MailDaemonInvoke -LogName MailDaemon -Source MailDaemon -Enabled $true -Wait } $type = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.Type' -Fallback 'Smtp' $useSmtp = 'Smtp' -eq $type $useGraph = 'Graph' -eq $type } process { trap { Write-PSFMessage -Level Warning -String 'Invoke-MDDaemon.Error.General' -ErrorRecord $_ Disable-PSFLoggingProvider -Name eventlog -InstanceName MailDaemonInvoke throw $_ } if ($useGraph) { Connect-MDGraph } #region Send mails foreach ($item in (Get-ChildItem -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath') -Filter "*.clixml")) { $email = Import-PSFClixml -Path $item.FullName # Skip emails that should not yet be processed if ($email.NotBefore -gt (Get-Date)) { continue } # Build email parameters $parameters = @{ ErrorAction = 'Stop' } #region General if ($email.To) { $parameters["To"] = $email.To } else { $parameters["To"] = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.RecipientDefault' } if ($email.From) { $parameters["From"] = $email.From } else { $parameters["From"] = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SenderDefault' } if ($email.Cc) { $parameters["Cc"] = $email.Cc } if ($email.Bcc) { $parameters["Bcc"] = $email.Bcc } if ($email.Subject) { $parameters["Subject"] = $email.Subject } else { $parameters["Subject"] = "<no subject>" } if ($email.Priority) { $parameters["Priority"] = $email.Priority } if ($email.Body) { $parameters["Body"] = $email.Body } if ($null -ne $email.BodyAsHtml) { $parameters["BodyAsHtml"] = $email.BodyAsHtml } if ($email.Attachments) { if ($email.AttachmentsBinary) { $tempAttachmentParentDir = New-Item (Join-Path $item.Directory $item.BaseName) -Force -ItemType Directory $attachmentCounter = 0 $parameters["Attachments"] = @() # Using multiple subfolders to allow for duplicate attachment names foreach ($binaryAttachment in $email.AttachmentsBinary) { $tempAttachmentDir = New-Item (Join-Path $tempAttachmentParentDir $attachmentCounter) -Force -ItemType Directory $tempAttachmentPath = Join-Path $tempAttachmentDir $binaryAttachment.Name $null = [System.IO.File]::WriteAllBytes($tempAttachmentPath, $binaryAttachment.Data) $parameters["Attachments"] = @($parameters["Attachments"]) + $tempAttachmentPath $attachmentCounter = $attachmentCounter + 1 } } else { $parameters["Attachments"] = $email.Attachments } } #endregion General #region Smtp if ($useSmtp) { $parameters += @{ SmtpServer = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SmtpServer' Encoding = [System.Text.Encoding]::UTF8 } if (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.UseSSL' -Fallback $false) { $parameters['UseSSL'] = $true } if (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SenderCredentialPath') { $parameters["Credential"] = Import-Clixml -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SenderCredentialPath') } $sendCommand = Get-Command -Name Send-MailMessage } #endregion Smtp #region Graph if ($useGraph) { $sendCommand = Get-Command -Name Send-GraphMail } #endregion Graph Write-PSFMessage -Level Verbose -String 'Invoke-MDDaemon.SendMail.Start' -StringValues @($email.Taskname, $parameters['Subject'], $parameters['From'], ($parameters['To'] -join ",")) -Target $email.Taskname try { & $sendCommand @parameters } catch { "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') : $_" | Set-PSFFileContent -Path ($item.FullName -replace '.clixml', '.txt') -Append #region Abandon Email if beyond threshold if ($item.CreationTime.Add($abandonThreshold) -lt (Get-Date)) { Write-PSFMessage -String 'Invoke-MDDaemon.SendMail.Abandon' -StringValues $email.Taskname, $abandonThreshold $item.LastWriteTime = Get-Date Move-Item -LiteralPath $item.FullName -Destination $failedPath Move-Item -LiteralPath ($item.FullName -replace '.clixml', '.txt') -Destination $failedPath if ($email.Attachments -and $email.RemoveAttachments) { foreach ($attachment in $email.Attachments) { Remove-Item $attachment -Force } } } #endregion Abandon Email if beyond threshold Stop-PSFFunction -String 'Invoke-MDDaemon.SendMail.Failed' -StringValues $email.Taskname -ErrorRecord $_ -Continue -Target $email.Taskname } Write-PSFMessage -Level Verbose -String 'Invoke-MDDaemon.SendMail.Success' -StringValues $email.Taskname -Target $email.Taskname # Remove attachments only if ordered and mail was sent successfully if ($email.Attachments -and $email.RemoveAttachments) { foreach ($attachment in $email.Attachments) { Remove-Item $attachment -Force } } # Remove temp deserialized attachments if used if ($email.AttachmentsBinary) { $null = Remove-Item -Path $tempAttachmentParentDir -Recurse -Force } # Update the timestamp (the timeout for deletion uses this) and move it to the sent items folder $item.LastWriteTime = Get-Date try { Move-Item -Path $item.FullName -Destination (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath') -Force -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -String 'Invoke-MDDaemon.ManageSuccessJob.Failed' -StringValues $email.Taskname -Target $email.Taskname } } #endregion Send mails Disable-PSFLoggingProvider -Name eventlog -InstanceName MailDaemonInvoke } end { #region Cleanup expired mails $sentRetention = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentRetention' foreach ($item in (Get-ChildItem -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath'))) { if ($item.LastWriteTime.Add($sentRetention) -lt (Get-Date)) { Remove-Item $item.FullName } } $failedRetention = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailFailedRetention' foreach ($item in (Get-ChildItem -Path $failedPath)) { if ($item.LastWriteTime.Add($failedRetention) -lt (Get-Date)) { Remove-Item $item.FullName } } #endregion Cleanup expired mails } } function Save-MDCredential { <# .SYNOPSIS Stores credentials securely for use by the specified account. .DESCRIPTION This command encrypts credentials to a protected credentials file in the file system. This is designed to allow storing credential objects for use by scheduled task that run as SYSTEM or a service account. .PARAMETER TargetCredential The credentials to encrypt and write to file. .PARAMETER Path The path where to store the credential. Always considered as local path from the computer it is registered on. .PARAMETER AccessAccount The account that should have access to the credential. Defaults to the system account. Offer a full credentials object for a regular user account. .PARAMETER ComputerName The computer(s) to write the credential to. .PARAMETER Credential The credentials to use to authenticate to the target system. NOT the credentials stored for reuse. .EXAMPLE PS C:\> Save-MDCredential -ComputerName DC1,DC2,DC3 -TargetCredential $cred -Path "C:\ProgramData\PowerShell\Tasks\tesk1_credential.clixml" Saves the credentials stored in $cred on the computers DC1, DC2, DC3 for use by the SYSTEM account #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [PSCredential] $TargetCredential, [Parameter(Mandatory = $true)] [string] $Path, [PSCredential] $AccessAccount, [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [PSFComputer[]] $ComputerName = $env:COMPUTERNAME, [PSCredential] $Credential ) process { $parameters = @{ ArgumentList = $TargetCredential, $Path, $AccessAccount ComputerName = $ComputerName Credential = $Credential } Invoke-PSFCommand @parameters -ScriptBlock { Param ( [PSCredential] $Credential, [string] $Path, [PSCredential] $AccessAccount ) #region Folder Management if (Test-Path -Path $Path) { $item = Get-Item $Path if ($item.PSIsContainer) { $folder = $item.FullName $file = Join-Path $folder 'Credential.clixml' } else { $folder = Split-Path $item.FullName $file = $item.FullName } } else { if ([System.IO.Path]::GetExtension($Path)) { $folder = Split-Path $Path $file = $Path } else { $folder = $Path $file = Join-Path $folder 'Credential.clixml' } } if (-not (Test-Path -Path $folder)) { $null = New-Item -Path $folder -ItemType Directory -Force -ErrorAction Stop } #endregion Folder Management #region Access Privileges $accessUserName = $AccessAccount.UserName if (-not $accessUserName) { $accessUserName = "SYSTEM" } $acl = Get-Acl -Path $folder if (-not ($acl.Access | Where-Object IdentityReference -like $accessUserName | Where-Object { ($_.FileSystemRights -Band 278) -and ($_.FileSystemRights -Band 65536) })) { $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($accessUserName, 'Read, Write', 'Allow') $null = $acl.AddAccessRule($rule) $acl | Set-Acl -Path $folder } #endregion Access Privileges #region Create Task $folderCleaned = (Get-Item $folder).FullName $credFile = "{0}\{1}.txt" -f $folderCleaned, ([guid]::NewGuid()) $task = { $password = [System.IO.File]::ReadAllText("<credfile>") Remove-Item -Path "<credfile>" $credential = New-Object PSCredential("<username>", ($password | ConvertTo-SecureString -AsPlainText -Force)) $credential | Export-Clixml -Path "<exportPath>" } $commandString = $task.ToString().Replace("<credfile>", $credFile).Replace("<username>", $Credential.UserName).Replace("<exportPath>", $file) $encodedCommand = [convert]::ToBase64String(([System.Text.Encoding]::Unicode.GetBytes($commandString))) $action = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -EncodedCommand $encodedCommand" if ($accessUserName -ne "SYSTEM") { $principal = New-ScheduledTaskPrincipal -UserId $accessUserName -LogonType Interactive } else { $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType Interactive } $taskItem = New-ScheduledTask -Action $action -Principal $principal -Description "Temporary Task" $parametersRegister = @{ TaskName = "TempTask_$([guid]::NewGuid())" InputObject = $taskItem } if ($accessUserName -ne "SYSTEM") { $parametersRegister["User"] = $AccessAccount.UserName $parametersRegister["Password"] = $AccessAccount.GetNetworkCredential().Password } $null = Register-ScheduledTask @parametersRegister #endregion Create Task #region Perform Encryption [System.IO.File]::WriteAllText($credFile, $Credential.GetNetworkCredential().Password) Start-ScheduledTask -TaskName $parametersRegister.TaskName Start-Sleep -Seconds 5 #endregion Perform Encryption #region Cleanup Unregister-ScheduledTask -TaskName $parametersRegister.TaskName -Confirm:$false if (Test-Path -Path $credFile) { try { Remove-Item $credFile -Force -ErrorAction Stop } catch { Write-Warning "[$env:COMPUTERNAME] Clear Text Credential File still exists!! $credFile | $_" } } if (-not (Test-Path -Path $file)) { throw "[$env:COMPUTERNAME] Failed to create credential file! ($file)" } #endregion Cleanup } } } function Send-MDMail { <# .SYNOPSIS Queues current email for delivery. .DESCRIPTION Uses the data prepared by Set-MDMail or Add-MDMailContent and queues the email for delivery. .PARAMETER TaskName Name of the task that is sending the email. Used in the name of the file used to queue messages in order to reduce likelyhood of accidental clash. .PARAMETER PersistAttachments Attachments will be serialized with the queued email allowing the source files to be removed immediately. .PARAMETER DontTrigger Do not trigger the task that sends the email. By default, after submitting an email for delivery, it will immediately trigger the scheduled task to send it. .EXAMPLE PS C:\> Send-MDMail -TaskName "Logrotate" Queues the currently prepared email under the name "Logrotate" #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [string] $TaskName, [switch] $PersistAttachments, [switch] $DontTrigger ) begin { # Ensure the pickup patch exists if (-not (Test-Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath'))) { try { $null = New-Item -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath') -ItemType Directory -Force -ErrorAction Stop } catch { Stop-PSFFunction -String 'Send-MDMail.Folder.CreationFailed' -StringValues (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath') -ErrorRecord $_ -Cmdlet $PSCmdlet -EnableException $true } } } process { # Don't send an email if nothing was set up if (-not $script:mail) { Stop-PSFFunction -String 'Send-MDMail.Email.NotRegisteredYet' -EnableException $true -Cmdlet $PSCmdlet } $script:mail['Taskname'] = $TaskName if ($PersistAttachments) { # Add the attachments bytes to the mail object if (-not $script:mail["AttachmentsBinary"]) { $script:mail["AttachmentsBinary"] = @() } foreach ($attachment in $script:mail['Attachments']) { $script:mail['AttachmentsBinary'] = @($script:mail['AttachmentsBinary']) + @{Name = (split-path -Path $attachment -Leaf); Data = [System.IO.File]::ReadAllBytes($attachment)} } } # Send the email Write-PSFMessage -String 'Send-MDMail.Email.Sending' -StringValues $TaskName -Target $TaskName try { [PSCustomObject]$script:mail | Export-PSFClixml -Path "$(Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath')\$($TaskName)-$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').clixml" -Depth 4 -ErrorAction Stop } catch { Stop-PSFFunction -String 'Send-MDMail.Email.SendingFailed' -StringValues $TaskName -ErrorRecord $_ -Cmdlet $PSCmdlet -EnableException $true -Target $TaskName } # Reset email, now that it is queued $script:mail = $null if ($DontTrigger) { return } try { Start-ScheduledTask -TaskName MailDaemon -ErrorAction Stop } catch { Stop-PSFFunction -String 'Send-MDMail.Email.TriggerFailed' -StringValues $TaskName -ErrorRecord $_ -Cmdlet $PSCmdlet -EnableException $true -Target $TaskName } } } function Set-MDDaemon { <# .SYNOPSIS Configures the Daemon settings on the target computer(s) .DESCRIPTION Command that governs the Mail Daemon settings. .PARAMETER PickupPath The folder in which emails are queued for delivery. .PARAMETER SentPath The folder in which emails that were successfully sent are stored for a specified time before being deleted. .PARAMETER FailedPath The path where mails that could repeatedly not be sent are moved to. .PARAMETER MailSentRetention The time to keep successfully sent emails around. .PARAMETER MailAbandonThreshold How long we attempt to send an email before abandoning it and moving it to -FailedPath. .PARAMETER MailFailedRetention How long we keep an abandoned email around before removing it entirely. .PARAMETER Type In what fundamental way should emails be sent? - SMTP: Via classic SMTP relay (authenticated or not so) - Graph: Via Graph API (using Application authentication) The different modes need different configuration parameters. .PARAMETER SmtpServer The mailserver to use for sending emails. .PARAMETER SenderDefault The default email address to use as sender. This is used for mails queued by a task that did not specify a sender. .PARAMETER RecipientDefault Default email address to send the email to, if the individual script queuing the email does not specify one. .PARAMETER SenderCredentialPath The path to where the credentials file can be found, that should be used by the daemon. .PARAMETER UseSSL Use SSL for sending emails. .PARAMETER ClientID The ClientID of the Application to use for sending emails via Graph API. .PARAMETER TenantID The TenantID of the Application to use for sending emails via Graph API. .PARAMETER Identity When authenticating to Entra for sending emails via Graph API, use the current Managed Identity to authenticate. .PARAMETER Federated When authenticating to Entra for sending emails via Graph API, use Federated Credentials to authenticate. This uses the current Managed Identity to get a token they can use to authenticate to the application with the actual permissions. .PARAMETER CertificateThumbprint When authenticating to Entra for sending emails via Graph API, use the certificate with the specified thumbprint. The certificate must be stored in one of the local certificate stores. .PARAMETER CertificateName When authenticating to Entra for sending emails via Graph API, use the newest certificate with the specified subject. The certificate must be stored in one of the local certificate stores. .PARAMETER ComputerName The computer(s) to work against. Defaults to localhost, but can be used to update the module settings across a wide range of computers. .PARAMETER Credential The credentials to use when connecting to computers. .EXAMPLE PS C:\> Set-MDDaemon -PickupPath 'C:\MailDaemon\Pickup' Updates the configuration to now pickup incoming emails from 'C:\MailDaemon\Pickup'. Will not move pending email jobs. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "")] [CmdletBinding()] param ( [string] $PickupPath, [string] $SentPath, [string] $FailedPath, [Timespan] $MailSentRetention, [Timespan] $MailAbandonThreshold, [Timespan] $MailFailedRetention, [ValidateSet('Graph', 'Smtp')] [string] $Type, [string] $SmtpServer, [string] $SenderDefault, [string] $RecipientDefault, [string] $SenderCredentialPath, [switch] $UseSSL, [string] $ClientID, [string] $TenantID, [switch] $Identity, [switch] $Federated, [string] $CertificateThumbprint, [string] $CertificateName, [Parameter(ValueFromPipeline = $true)] [PSFComputer[]] $ComputerName = $env:COMPUTERNAME, [PSCredential] $Credential ) begin { #region Configuration Script $configurationScript = { param ( $Parameters ) # Import module so settings are initialized if (-not (Get-Module MailDaemon)) { Import-Module MailDaemon } foreach ($key in $Parameters.Keys) { Write-PSFMessage -String 'Set-MDDaemon.UpdateSetting' -StringValues $key, $Parameters[$key] switch ($key) { 'PickupPath' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailPickupPath' -Value $Parameters[$key] if (-not (Test-Path $Parameters[$key])) { $null = New-Item $Parameters[$key] -Force -ItemType Directory } } 'SentPath' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailSentPath' -Value $Parameters[$key] if (-not (Test-Path $Parameters[$key])) { $null = New-Item $Parameters[$key] -Force -ItemType Directory } } 'FailedPath' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailFailedPath' -Value $Parameters[$key] if (-not (Test-Path $Parameters[$key])) { $null = New-Item $Parameters[$key] -Force -ItemType Directory } } 'MailSentRetention' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailSentRetention' -Value $Parameters[$key] } 'MailAbandonThreshold' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailAbandonThreshold' -Value $Parameters[$key] } 'MailFailedRetention' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.MailFailedRetention' -Value $Parameters[$key] } 'SmtpServer' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.SmtpServer' -Value $Parameters[$key] } 'SenderDefault' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.SenderDefault' -Value $Parameters[$key] } 'SenderCredentialPath' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.SenderCredentialPath' -Value $Parameters[$key] } 'RecipientDefault' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.RecipientDefault' -Value $Parameters[$key] } 'UseSSL' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.UseSSL' -Value $Parameters[$key].ToBool() } 'Type' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Type' -Value $Parameters[$key] } 'ClientID' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.ClientID' -Value $Parameters[$key] } 'TenantID' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.TenantID' -Value $Parameters[$key] } 'Identity' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.Identity' -Value $Parameters[$key] } 'Federated' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.Federated' -Value $Parameters[$key] } 'CertificateThumbprint' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.CertificateThumbprint' -Value $Parameters[$key] } 'CertificateName' { Set-PSFConfig -Module MailDaemon -Name 'Daemon.Graph.CertificateName' -Value $Parameters[$key] } } } Get-PSFConfig -Module MailDaemon -Name Daemon.* | Where-Object Unchanged -EQ $false | Register-PSFConfig -Scope SystemDefault } #endregion Configuration Script $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Exclude ComputerName, Credential $connect = $PSBoundParameters | ConvertTo-PSFHashtable -Include ComputerName, Credential } process { #region Modules must be installed and current if ($moduleResult = Test-Module @connect -Module @{ MailDaemon = $script:ModuleVersion PSFramework = (Get-Module -Name PSFramework).Version } | Where-Object Success -EQ $false) { Stop-PSFFunction -String 'General.ModuleMissing' -StringValues ($moduleResult.ComputerName -join ", ") -EnableException $true -Cmdlet $PSCmdlet } #endregion Modules must be installed and current Write-PSFMessage -String 'Set-MDDaemon.UpdatingSettings' -StringValues ($ComputerName -join ", ") Invoke-PSFCommand @connect -ScriptBlock $configurationScript -ArgumentList $parameters } } enum MailPriority { Normal Low High } function Set-MDMail { <# .SYNOPSIS Changes properties for the upcoming mail to queue. .DESCRIPTION This command sets up the email to send, configuring properties such as the sender, recipient or content. .PARAMETER From The email address of the sender. .PARAMETER To The email address to send to. .PARAMETER Cc Additional addresses to keep in the information flow. .PARAMETER Bcc Additional addresses to keep silently informed .PARAMETER Subject The subject to send the email under. .PARAMETER Body The body of the email to send. You can individually add content to the body using Add-MDMailContent. .PARAMETER BodyAsHtml Whether the body is to be understood as html text. .PARAMETER Attachments Any attachments to send. Avoid sending large attachments with emails. You can individually add attachments to the email using Add-MDMailContent (using this parameter will replace attachments sent). .PARAMETER RemoveAttachments After sending the email, remove the attachments sent. Use this to have the system clean up temporary files you wrote before sending this report. .PARAMETER NotBefore Do not send this email before this timestamp has come to pass. .PARAMETER Priority The priority of the email .EXAMPLE PS C:\> Set-MDMail -From 'script@contoso.com' -To 'support@contoso.com' -Subject 'Daily Update Report' -Body $body Sends an email as script@contoso.com to support@contoso.com, reporting on the daily update status. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] [CmdletBinding()] Param ( [string] $From, [string[]] $To, [string[]] $Cc, [string[]] $Bcc, [string] $Subject, [string] $Body, [switch] $BodyAsHtml, [string[]] $Attachments, [switch] $RemoveAttachments, [datetime] $NotBefore, [MailPriority] $Priority ) begin { if (-not $script:mail) { $script:mail = @{ } } } process { if ($From) { $script:mail["From"] = $From } if ($To) { $script:mail["To"] = $To } if ($Cc) { $script:mail["Cc"] = $Cc } if ($Bcc) { $script:mail["Bcc"] = $Bcc } if ($Subject) { $script:mail["Subject"] = $Subject } if ($Body) { $script:mail["Body"] = $Body } if ($BodyAsHtml.IsPresent) { $script:mail["BodyAsHtml"] = ([bool]$BodyAsHtml) } if ($Attachments) { $script:mail["Attachments"] = $Attachments } if ($RemoveAttachments.IsPresent) { $script:mail["RemoveAttachments"] = ([bool]$RemoveAttachments) } if ($NotBefore) { $script:mail["NotBefore"] = $NotBefore } if ($Priority) { $script:mail["Priority"] = $Priority } } } function Update-MDFolderPermission { <# .SYNOPSIS Assigns permissions for the mail daemon working folders. .DESCRIPTION Assigns permissions for the mail daemon working folders. Enables simple assignment of privileges in case regular accounts need to write to protected pickup paths and helps implementing least privilege. .PARAMETER ComputerName The computer(s) to work against. Defaults to localhost. .PARAMETER Credential The credentials to use when connecting to computers. .PARAMETER DaemonUser The user to grant the necessary access to manage submitted mail items. .PARAMETER WriteUser Users that should be able to submit mails. .PARAMETER Confirm If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. .PARAMETER WhatIf If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. .EXAMPLE PS C:\> Update-MDFolderPermission -DaemonUser 'domain\srv_server1mail$' Grants Daemon User privileges on the local computer to the service account 'domain\srv_server1mail$' #> [CmdletBinding(SupportsShouldProcess = $true)] Param ( [Parameter(ValueFromPipeline = $true)] [PSFComputer[]] $ComputerName = $env:COMPUTERNAME, [PSCredential] $Credential, [string] $DaemonUser = " ", [string[]] $WriteUser = " " ) begin { #region Permission Assigning Scriptblock $permissionScript = { param ( [string] $DaemonUser, [string[]] $WriteUser ) Import-Module MailDaemon $pickupPath = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath' $sentPath = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailSentPath' $failedPath = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailFailedPath' if ($DaemonUser.Trim()) { Write-PSFMessage -String 'Update-MDFolderPermission.Granting.DaemonUser' -StringValues $DaemonUser, $pickupPath, $sentPath $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($DaemonUser, 'Read, Write', 'Allow') $acl = Get-Acl -Path $pickupPath $acl.AddAccessRule($rule) $acl | Set-Acl -Path $pickupPath $acl = Get-Acl -Path $sentPath $acl.AddAccessRule($rule) $acl | Set-Acl -Path $sentPath $acl = Get-Acl -Path $failedPath $acl.AddAccessRule($rule) $acl | Set-Acl -Path $failedPath } foreach ($user in $WriteUser) { if ($user.Trim()) { continue } Write-PSFMessage -String 'Update-MDFolderPermission.Granting.WriteUser' -StringValues $user, $pickupPath $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($user, 'Write', 'Allow') $acl = Get-Acl -Path $pickupPath $acl.AddAccessRule($rule) $acl | Set-Acl -Path $pickupPath } } #endregion Permission Assigning Scriptblock } process { #region Modules must be installed and current if ($moduleResult = Test-Module -ComputerName $ComputerName -Credential $Credential -Module @{ MailDaemon = $script:ModuleVersion PSFramework = (Get-Module -Name PSFramework).Version } | Where-Object Success -EQ $false) { Stop-PSFFunction -String 'General.ModuleMissing' -StringValues ($moduleResult.ComputerName -join ", ") -EnableException $true -Cmdlet $PSCmdlet } #endregion Modules must be installed and current if (Test-PSFShouldProcess -PSCmdlet $PSCmdlet -Target ($ComputerName -join ", ") -Action "Granting the write permissions needed by the Daemon User ($($DaemonUser)) and Write User ($($WriteUser -join ', '))") { Invoke-PSFCommand -ComputerName $ComputerName -Credential $Credential -ScriptBlock $permissionScript -ArgumentList $DaemonUser, $WriteUser } } } Register-PSFConfigValidation -Name 'MailDaemon.Protocol' -ScriptBlock { param ( $Value ) $Result = [PSCustomObject]@{ Success = $True Value = $null Message = "" } $legalValues = 'Smtp', 'Graph' if ("$Value" -notin $legalValues) { $Result.Message = "Illegal Mail Protocol: $Value | Legal Options: $($legalValues -join ', ')" $Result.Success = $False return $Result } $Result.Value = "$Value" return $Result } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'MailDaemon' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'MailDaemon' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'MailDaemon' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." Set-PSFConfig -Module 'MailDaemon' -Name 'Task.Author' -Value "$($env:USERDOMAIN) IT Department" -Initialize -Validation 'string' -SimpleExport -Description 'When setting up the scheduled task using Install-MDDaemon, this is the name used as the author of the scheduled task' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailPickupPath' -Value "$(Get-PSFPath -Name ProgramData)\PowerShell\MailDaemon\Pickup" -Initialize -Validation 'string' -SimpleExport -Description "The folder from which the daemon will pickup email tasks." Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailSentPath' -Value "$(Get-PSFPath -Name ProgramData)\PowerShell\MailDaemon\Sent" -Initialize -Validation 'string' -SimpleExport -Description "The folder into which completed tasks are moved" Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailFailedPath' -Value "$(Get-PSFPath -Name ProgramData)\PowerShell\MailDaemon\Sent" -Initialize -Validation 'string' -SimpleExport -Description "The folder into which failed tasks are moved" Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailSentRetention' -Value (New-TimeSpan -Days 7) -Initialize -Validation 'timespan' -SimpleExport -Description "How long sent email tasks are retained" Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailAbandonThreshold' -Value (New-TimeSpan -Days 14) -Initialize -Validation 'timespan' -SimpleExport -Description "How long we try to send failing tasks, before abandoning them" Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailFailedRetention' -Value (New-TimeSpan -Days 14) -Initialize -Validation 'timespan' -SimpleExport -Description "How long failed email tasks are retained" Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.SenderDefault' -Value "maildaemon@$($env:USERDNSDOMAIN)" -Initialize -Validation 'string' -SimpleExport -Description "The default sending email address." Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Type' -Value 'SMTP' -Initialize -Validation 'MailDaemon.Protocol' -SimpleExport -Description 'The protocol used to send email. Supports either "SMTP" or "Graph". The choice determines what authentication options must be specified.' # SMTP Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.SmtpServer' -Value "mail.$($env:USERDNSDOMAIN)" -Initialize -Validation 'string' -SimpleExport -Description "The mail server to use." Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.SenderCredentialPath' -Value '' -Initialize -Validation 'string' -SimpleExport -Description "The path to the credentials to use for authenticated mail sending." Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.RecipientDefault' -Value "support@$($env:USERDNSDOMAIN)" -Initialize -Validation 'string' -SimpleExport -Description "The default recipient to receive emails." Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.UseSSL' -Value $false -Initialize -Validation 'bool' -SimpleExport -Description "Whether mails should be sent using SSL." # Graph Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.ClientID' -Value $null -Initialize -Validation guid -SimpleExport -Description 'The client ID of the Application to use for authentication to graph.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.TenantID' -Value $null -Initialize -Validation guid -SimpleExport -Description 'The tenant ID of the Application to use for authentication to graph.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.CertificateThumbprint' -Value '' -Initialize -Validation string -SimpleExport -Description 'Authenticate using the specified certificate (by thumbprint). The account must have read access to the private key.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.CertificateName' -Value '' -Initialize -Validation string -SimpleExport -Description 'Authenticate using the specified certificate (by subject). The account must have read access to the private key.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.Federated' -Value $false -Initialize -Validation bool -SimpleExport -Description 'Authenticate using Federated Credentials. Generally requires running as admin for access.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.Identity' -Value $false -Initialize -Validation bool -SimpleExport -Description 'Authenticate using the Managed Identity of the current environment. Generally requires running as admin for access.' Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.Graph.NoAuth' -Value $false -Initialize -Validation bool -SimpleExport -Description 'Do not authenticate at all during processing. This assumes you have handled graph authentication before calling Invoke-MDDaemon - something the default task the module sets up will not do. Use with care' <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'MailDaemon.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "MailDaemon.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name MailDaemon.alcohol #> New-PSFLicense -Product 'MailDaemon' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2019-02-08") -Text @" Copyright (c) 2019 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ #endregion Load compiled code |