diff --git a/.gitignore b/.gitignore index e220c4a..2b4fdbf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ MailDaemon/MailDaemon.psproj TestResults/* # ignore the publishing Directory -publish/* \ No newline at end of file +publish/* + +experiments/* \ No newline at end of file diff --git a/MailDaemon/MailDaemon.psd1 b/MailDaemon/MailDaemon.psd1 index 595cfb1..5da7264 100644 --- a/MailDaemon/MailDaemon.psd1 +++ b/MailDaemon/MailDaemon.psd1 @@ -3,7 +3,7 @@ RootModule = 'MailDaemon.psm1' # Version number of this module. - ModuleVersion = '1.2.14' + ModuleVersion = '1.3.19' # ID used to uniquely identify this module GUID = 'd5ba333f-5210-4d69-83f0-150dd0909139' @@ -27,6 +27,7 @@ # this module RequiredModules = @( @{ ModuleName='PSFramework'; ModuleVersion='1.13.416' } + @{ ModuleName='EntraAuth'; ModuleVersion='1.8.52' } ) # Assemblies that must be loaded prior to importing this module diff --git a/MailDaemon/changelog.md b/MailDaemon/changelog.md index 300af93..bc680c3 100644 --- a/MailDaemon/changelog.md +++ b/MailDaemon/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 1.3.19 (2026-05-28) + ++ Major: Added capability to send emails by Graph API, rather than SMTP. ++ New: Configuration Setting 'MailDaemon.Daemon.Type' - determins whether to send email by Smtp or Graph. ++ New: Configuration Settings to define Graph behavior: 'MailDaemon.Daemon.Graph.*' ++ Upd: New dependency: EntraAuth - implements the Graph API authentication and interaction. ++ Upd: Mail Tasks are now stored in PSFramework CliDat format to save disk space. New client tasks cannot be processed by old agent versions. + ## 1.2.14 (2026-02-12) + Fix: Update-MDFolderPermission - does not set permissions for the failed folder diff --git a/MailDaemon/en-us/strings.psd1 b/MailDaemon/en-us/strings.psd1 index c868cf7..402c61a 100644 --- a/MailDaemon/en-us/strings.psd1 +++ b/MailDaemon/en-us/strings.psd1 @@ -5,6 +5,7 @@ 'General.ModuleMissing' = 'The MailDaemon module could not be found in sufficient version on: {0}. Terminating Execution. To install or update to the current version, use Install-MDDaemon or access the PSGallery directly using "Install-Module MailDaemon".' # Invoke-MDDaemon + 'Invoke-MDDaemon.Error.General' = 'An error happened while processing pending emails' 'Invoke-MDDaemon.SendMail.Abandon' = '{0} - Abandoning email after failing to send it within the configured timespan ({1})' # $email.Taskname, $abandonThreshold 'Invoke-MDDaemon.SendMail.Start' = '{0} - Sending Mail: "{1}" From {2} to {3}' 'Invoke-MDDaemon.SendMail.Failed' = '{0} - Failed to send email!' @@ -30,4 +31,8 @@ # Update-MDFolderPermission 'Update-MDFolderPermission.Granting.DaemonUser' = 'Assigning write permissions as daemon account to {0} on "{1}" and "{2}"' 'Update-MDFolderPermission.Granting.WriteUser' = 'Assigning write permissions as mail submitter to {0} on "{1}"' + + # Connect-MDGraph + 'Connect-MDGraph.Error.NoAuthPath' = 'Failed to connect to Entra & Graph: None of the required authentication options were specified! Use Set-MDDaemon to configure a certificate or federated credetnials!' + 'Connect-MDGraph.Error.NoClientIDorTenantID' = 'Failed to connect to Entra & Graph: ClientID or TenantID were missing and must be provided!' } \ No newline at end of file diff --git a/MailDaemon/functions/Install-MDDaemon.ps1 b/MailDaemon/functions/Install-MDDaemon.ps1 index d70762d..49bf5dc 100644 --- a/MailDaemon/functions/Install-MDDaemon.ps1 +++ b/MailDaemon/functions/Install-MDDaemon.ps1 @@ -68,6 +68,27 @@ .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. @@ -132,6 +153,24 @@ [switch] $UseSSL, + [string] + $ClientID, + + [string] + $TenantID, + + [switch] + $Identity, + + [switch] + $Federated, + + [string] + $CertificateThumbprint, + + [string] + $CertificateName, + [switch] $NoLogging ) @@ -210,7 +249,8 @@ #endregion Setup Task Configuration #region Preparing Parameters - $parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include 'PickupPath', 'SentPath', 'FailedPath', 'MailSentRetention', 'MailAbandonThreshold', 'MailFailedRetention', 'SmtpServer', 'SenderDefault', 'RecipientDefault', 'UseSSL' + $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 @@ -227,7 +267,7 @@ Import-Module -Name PSFramework Import-Module -Name MailDaemon - Set-MDDaemon @parameters + 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 } diff --git a/MailDaemon/functions/Invoke-MDDaemon.ps1 b/MailDaemon/functions/Invoke-MDDaemon.ps1 index a2d4a09..4a8ddf2 100644 --- a/MailDaemon/functions/Invoke-MDDaemon.ps1 +++ b/MailDaemon/functions/Invoke-MDDaemon.ps1 @@ -42,26 +42,34 @@ 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-Clixml -Path $item.FullName + $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 = @{ - SmtpServer = Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SmtpServer' - Encoding = ([System.Text.Encoding]::UTF8) ErrorAction = 'Stop' } - if (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.UseSSL' -Fallback $false) { $parameters['UseSSL'] = $true } + + + #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 } @@ -91,10 +99,29 @@ $parameters["Attachments"] = $email.Attachments } } - if (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SenderCredentialPath') { $parameters["Credential"] = Import-Clixml -Path (Get-PSFConfigValue -FullName 'MailDaemon.Daemon.SenderCredentialPath') } + #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 { Send-MailMessage @parameters } + 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 diff --git a/MailDaemon/functions/Send-MDMail.ps1 b/MailDaemon/functions/Send-MDMail.ps1 index 42905f0..b4c23a1 100644 --- a/MailDaemon/functions/Send-MDMail.ps1 +++ b/MailDaemon/functions/Send-MDMail.ps1 @@ -13,6 +13,10 @@ .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" @@ -26,7 +30,10 @@ $TaskName, [switch] - $PersistAttachments + $PersistAttachments, + + [switch] + $DontTrigger ) begin @@ -60,7 +67,7 @@ # Send the email Write-PSFMessage -String 'Send-MDMail.Email.Sending' -StringValues $TaskName -Target $TaskName - try { [PSCustomObject]$script:mail | Export-Clixml -Path "$(Get-PSFConfigValue -FullName 'MailDaemon.Daemon.MailPickupPath')\$($TaskName)-$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').clixml" -Depth 4 -ErrorAction Stop } + 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 @@ -69,6 +76,8 @@ # Reset email, now that it is queued $script:mail = $null + if ($DontTrigger) { return } + try { Start-ScheduledTask -TaskName MailDaemon -ErrorAction Stop } catch { diff --git a/MailDaemon/functions/Set-MDDaemon.ps1 b/MailDaemon/functions/Set-MDDaemon.ps1 index bf7d18a..ab48c78 100644 --- a/MailDaemon/functions/Set-MDDaemon.ps1 +++ b/MailDaemon/functions/Set-MDDaemon.ps1 @@ -23,6 +23,12 @@ .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. @@ -39,6 +45,27 @@ .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. @@ -74,6 +101,10 @@ [Timespan] $MailFailedRetention, + + [ValidateSet('Graph', 'Smtp')] + [string] + $Type, [string] $SmtpServer, @@ -89,6 +120,24 @@ [switch] $UseSSL, + + [string] + $ClientID, + + [string] + $TenantID, + + [switch] + $Identity, + + [switch] + $Federated, + + [string] + $CertificateThumbprint, + + [string] + $CertificateName, [Parameter(ValueFromPipeline = $true)] [PSFComputer[]] @@ -131,6 +180,13 @@ '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] } } } @@ -139,10 +195,11 @@ #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 -ComputerName $ComputerName -Credential $Credential -Module @{ + if ($moduleResult = Test-Module @connect -Module @{ MailDaemon = $script:ModuleVersion PSFramework = (Get-Module -Name PSFramework).Version } | Where-Object Success -EQ $false) { @@ -151,6 +208,6 @@ #endregion Modules must be installed and current Write-PSFMessage -String 'Set-MDDaemon.UpdatingSettings' -StringValues ($ComputerName -join ", ") - Invoke-PSFCommand -ComputerName $ComputerName -Credential $Credential -ScriptBlock $configurationScript -ArgumentList $parameters + Invoke-PSFCommand @connect -ScriptBlock $configurationScript -ArgumentList $parameters } } \ No newline at end of file diff --git a/MailDaemon/internal/configurations/daemon.ps1 b/MailDaemon/internal/configurations/daemon.ps1 index f651622..8d7747f 100644 --- a/MailDaemon/internal/configurations/daemon.ps1 +++ b/MailDaemon/internal/configurations/daemon.ps1 @@ -4,8 +4,21 @@ Set-PSFConfig -Module 'MailDaemon' -Name 'Daemon.MailFailedPath' -Value "$(Get-P 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.SmtpServer' -Value "mail.$($env:USERDNSDOMAIN)" -Initialize -Validation 'string' -SimpleExport -Description "The mail server to use." 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." \ No newline at end of file +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' \ No newline at end of file diff --git a/MailDaemon/internal/functions/Connect-MDGraph.ps1 b/MailDaemon/internal/functions/Connect-MDGraph.ps1 new file mode 100644 index 0000000..3f1b1d3 --- /dev/null +++ b/MailDaemon/internal/functions/Connect-MDGraph.ps1 @@ -0,0 +1,52 @@ +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 + } + } +} \ No newline at end of file diff --git a/MailDaemon/internal/functions/Publish-GraphAttachment.ps1 b/MailDaemon/internal/functions/Publish-GraphAttachment.ps1 new file mode 100644 index 0000000..13c8093 --- /dev/null +++ b/MailDaemon/internal/functions/Publish-GraphAttachment.ps1 @@ -0,0 +1,44 @@ +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//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' + } +} \ No newline at end of file diff --git a/MailDaemon/internal/functions/Send-GraphMail.ps1 b/MailDaemon/internal/functions/Send-GraphMail.ps1 new file mode 100644 index 0000000..b23f67d --- /dev/null +++ b/MailDaemon/internal/functions/Send-GraphMail.ps1 @@ -0,0 +1,134 @@ +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 "" + + .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 = '', + + [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 + } +} \ No newline at end of file diff --git a/MailDaemon/internal/functions/Test-Module.ps1 b/MailDaemon/internal/functions/Test-Module.ps1 index 4a6a410..cf437fa 100644 --- a/MailDaemon/internal/functions/Test-Module.ps1 +++ b/MailDaemon/internal/functions/Test-Module.ps1 @@ -73,7 +73,7 @@ [Parameter(Mandatory = $true, ParameterSetName = 'Hash')] [hashtable] - $Module = @{ }, + $Module, [ValidateSet('LesserThan', 'LesserEqual', 'Equal', 'GreaterEqual', 'GreaterThan')] [string] diff --git a/MailDaemon/internal/scripts/config-validation.ps1 b/MailDaemon/internal/scripts/config-validation.ps1 new file mode 100644 index 0000000..c737d36 --- /dev/null +++ b/MailDaemon/internal/scripts/config-validation.ps1 @@ -0,0 +1,23 @@ +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 +} \ No newline at end of file diff --git a/MailDaemon/internal/scripts/postimport.ps1 b/MailDaemon/internal/scripts/postimport.ps1 index 81583ec..e23123f 100644 --- a/MailDaemon/internal/scripts/postimport.ps1 +++ b/MailDaemon/internal/scripts/postimport.ps1 @@ -10,6 +10,9 @@ After building the module, this file will be completely ignored, adding anything $moduleRoot = Split-Path (Split-Path $PSScriptRoot) +# Load Configuration Validation +"$moduleRoot\internal\scripts\config-validation.ps1" + # Load Configurations (Get-ChildItem "$moduleRoot\internal\configurations\*.ps1" -ErrorAction Ignore).FullName diff --git a/MailDaemon/tests/general/Help.Tests.ps1 b/MailDaemon/tests/general/Help.Tests.ps1 index ed05293..0d02bc6 100644 --- a/MailDaemon/tests/general/Help.Tests.ps1 +++ b/MailDaemon/tests/general/Help.Tests.ps1 @@ -89,7 +89,7 @@ foreach ($command in $commands) { Context "Test parameter help for $commandName" { - $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable' + $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable', 'ProgressAction' $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common $parameterNames = $parameters.Name diff --git a/README.md b/README.md index ae72266..f91bfd0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# Description +# MailDaemon + +## Description This module is a simple way to implement a mail daemon on your systems. @@ -6,7 +8,7 @@ Ever felt it a pain to set up your mail system right? Anonymous sending is causi Then this module is for you! -# Main Features +## Main Features + Centralize mail sending, with dedicated account or credentials + Retry sending emails when service is unavailable @@ -15,12 +17,13 @@ Then this module is for you! + Easy to use + Manageable by Group Policy / SCCM / Intune / ... -# Prerequisites +## Prerequisites -+ PowerShell 5.1 ++ PowerShell 5.1 (or later) + PowerShell Module: PSFramework ++ PowerShell Module: EntraAuth -# Installation +## Installation To install the module from the PSGallery, run this line: @@ -28,12 +31,22 @@ To install the module from the PSGallery, run this line: Install-Module MailDaemon ``` -Setting up the Daemon on your system: +## Setting up the Daemon on your system + +> Local using SMTP ```powershell Install-MDDaemon -SmtpServer mail.domain.com -SenderDefault 'support@domain.com' -RecipientDefault 'support@domain.com' ``` +> Local using Graph API + +```powershell +Install-MDDaemon -SenderDefault 'support@domain.onmicrosoft.com' -ClientID $clientID -TenantID $tenantID -CertificateName 'CN=GraphMailCertificate' +``` + +> Remote Deployment + Setting it up an all^ machines^^: ```powershell @@ -44,7 +57,21 @@ Get-ADComputer -Filter * | Install-MDDaemon -SmtpServer mail.domain.com -SenderD ^^Expect some of them to fail, due to being offline ;) -# Sending Emails +## Setting up Sending emails via Graph + +Some setup is required before you can send emails via Graph API. + +> WARNING: Before you actually go and do it, read to the end of this section!!! + ++ First: [Set up an application in Entra](https://github.com/FriedrichWeinmann/EntraAuth/blob/master/docs/creating-applications.md) ++ Second: [Configure Authentication via Certificate](https://github.com/FriedrichWeinmann/EntraAuth/blob/master/docs/authenticate-certificate.md). Alternative options such as Federated Credentials or Managed Identity exist, but are somewhat more complicated. ++ Third: [Assign _Application_ scopes to the application](https://github.com/FriedrichWeinmann/EntraAuth/blob/master/docs/api-permissions.md): `Mail.ReadWrite` and `Mail.Send` + +The third step is an incredibly impactful step - it gives your application full access to every single mailbox in the tenant, which is almost certainly way too much! +You should [constrain the scope of your application's permission](https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac) first before assigning those scopes. +This may need to be done by the Exchange ONline team. + +## Sending Emails Sending emails is a matter of up to three commands used during your script: