From 20e816cb1bc034852cbc2510ad3145aa075b6077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 8 Aug 2026 21:50:06 +0200 Subject: [PATCH 1/9] Add optional Mailozaurr email transport --- Build/Build-Module.ps1 | 37 ++++---- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 109 ++++++++++++++++++++++ Public/Email.ps1 | 18 +++- Tests/Email.Tests.ps1 | 113 +++++++++++++++++++++++ 4 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 Private/Invoke-PSWriteHTMLMailozaurr.ps1 create mode 100644 Tests/Email.Tests.ps1 diff --git a/Build/Build-Module.ps1 b/Build/Build-Module.ps1 index c5b4611b..56a906f6 100644 --- a/Build/Build-Module.ps1 +++ b/Build/Build-Module.ps1 @@ -1,4 +1,17 @@ -Invoke-ModuleBuild -ModuleName 'PSWriteHTML' { +param( + [ValidateSet('Manifest', 'Build', 'Publish')] + [string] $ConfigurationGateMode = 'Build', + + [bool] $SignModule = ($ConfigurationGateMode -eq 'Publish'), + + [string] $PowerShellGalleryApiKeyPath = 'C:\Support\Important\PowerShellGalleryAPI.txt', + + [string] $GitHubApiKeyPath = 'C:\Support\Important\GitHubAPI.txt' +) + +Import-Module PSPublishModule -Force -ErrorAction Stop + +Build-Module -ModuleName 'PSWriteHTML' { # Usual defaults as per standard module $Manifest = [ordered] @{ ModuleVersion = '1.X.0' @@ -27,6 +40,7 @@ 'Microsoft.PowerShell.Utility' # this is optional, and checked for existance in the source codes directly 'PSParseHTML' + 'Mailozaurr' ) -IgnoreFunctionName 'Select-Unique', 'Compare-TwoArrays', 'IsNumeric', 'IsOfType', 'Format-HTML', 'Optimize-HTML' # those functions are internal within private function $ConfigurationFormat = [ordered] @{ @@ -70,23 +84,13 @@ # when creating PSD1 use special style without comments and with only required parameters New-ConfigurationFormat -ApplyTo 'DefaultPSD1', 'OnMergePSD1' -PSD1Style 'Minimal' # configuration for documentation, at the same time it enables documentation processing - $documentationConfiguration = @{ - Enable = $true - PathReadme = 'Docs\Readme.md' - Path = 'Docs' - } - - if ((Get-Command New-ConfigurationDocumentation).Parameters.ContainsKey('SyncExternalHelpToProjectRoot')) { - $documentationConfiguration.SyncExternalHelpToProjectRoot = $true - } - - New-ConfigurationDocumentation @documentationConfiguration + New-ConfigurationDocumentation -Enable -PathReadme 'Docs\Readme.md' -Path 'Docs' -SyncExternalHelpToProjectRoot New-ConfigurationImportModule -ImportSelf $newConfigurationBuildSplat = @{ Enable = $true - SignModule = $true + SignModule = $SignModule MergeModuleOnBuild = $true MergeFunctionsFromApprovedModules = $true CertificateThumbprint = '483292C9E317AA13B07BB7A96AE9D1A5ED9E7703' @@ -100,7 +104,8 @@ New-ConfigurationArtefact -Type Unpacked -Enable -Path "$PSScriptRoot\..\Artefacts\Unpacked" -AddRequiredModules New-ConfigurationArtefact -Type Packed -Enable -Path "$PSScriptRoot\..\Artefacts\Packed" -ArtefactName '.v.zip' - # options for publishing to github/psgallery - #New-ConfigurationPublish -Type PowerShellGallery -FilePath 'C:\Support\Important\PowerShellGalleryAPI.txt' -Enabled:$true - #New-ConfigurationPublish -Type GitHub -FilePath 'C:\Support\Important\GitHubAPI.txt' -UserName 'EvotecIT' -Enabled:$true #-GenerateReleaseNotes + New-ConfigurationPublish -Type PowerShellGallery -FilePath $PowerShellGalleryApiKeyPath -Enabled:$false -UseAsDependencyVersionSource + New-ConfigurationPublish -Type GitHub -FilePath $GitHubApiKeyPath -UserName 'EvotecIT' -RepositoryName 'PSWriteHTML' -Enabled:$false -GenerateReleaseNotes + + New-ConfigurationGate -Mode $ConfigurationGateMode } -ExitCode diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 new file mode 100644 index 00000000..1761f3dd --- /dev/null +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -0,0 +1,109 @@ +function Invoke-PSWriteHTMLMailozaurr { + <# + .SYNOPSIS + Maps the legacy PSWriteHTML email contract to Mailozaurr without requiring the module. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][System.Collections.IDictionary] $EmailParameters, + [Parameter(Mandatory)][AllowEmptyString()][string] $Body, + [System.Collections.Generic.List[string]] $Attachment, + [System.Collections.IDictionary] $AdditionalParameters, + [switch] $WhatIf + ) + + $Command = Get-Command -Name 'Mailozaurr\Send-EmailMessage' -ErrorAction SilentlyContinue + if (-not $Command) { + $Command = Get-Command -Name 'Send-EmailMessage' -ErrorAction SilentlyContinue + } + if (-not $Command) { + throw "Email -UseMailozaurr requires the Mailozaurr module and its Send-EmailMessage command to be available." + } + + if ($EmailParameters.PasswordFromFile) { + throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior. Pass Credential or provider authentication through -MailozaurrParameters." + } + + $Parameters = [ordered] @{} + $CommonMappings = [ordered] @{ + From = 'From' + To = 'To' + CC = 'Cc' + BCC = 'Bcc' + ReplyTo = 'ReplyTo' + Subject = 'Subject' + Priority = 'Priority' + } + foreach ($SourceName in $CommonMappings.Keys) { + $Value = $EmailParameters[$SourceName] + if ($null -ne $Value -and -not ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value))) { + $Parameters[$CommonMappings[$SourceName]] = $Value + } + } + + $UseSmtpTransport = $true + $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') + if ($AdditionalParameters) { + foreach ($Key in $AdditionalParameters.Keys) { + $SelectorName = [string] $Key + if ($NonSmtpSelectors -notcontains $SelectorName) { + continue + } + + $SelectorValue = $AdditionalParameters[$Key] + $SelectorEnabled = if ($SelectorValue -is [System.Management.Automation.SwitchParameter]) { + $SelectorValue.IsPresent + } elseif ($SelectorValue -is [bool]) { + $SelectorValue + } else { + $null -ne $SelectorValue + } + if ($SelectorEnabled) { + $UseSmtpTransport = $false + break + } + } + } + + if ($UseSmtpTransport) { + $SmtpMappings = [ordered] @{ + Server = 'Server' + Login = 'Username' + Password = 'Password' + Port = 'Port' + } + foreach ($SourceName in $SmtpMappings.Keys) { + $Value = $EmailParameters[$SourceName] + if ($null -ne $Value -and -not ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value))) { + $Parameters[$SmtpMappings[$SourceName]] = $Value + } + } + if ($EmailParameters.UseDefaultCredentials) { + $Parameters['UseDefaultCredentials'] = $true + } + if ($EmailParameters.EnableSSL) { + $Parameters['UseSsl'] = $true + } + if ($EmailParameters.PasswordAsSecure) { + $Parameters['AsSecureString'] = $true + } + if ($EmailParameters.DeliveryNotifications -and $EmailParameters.DeliveryNotifications -ne 'None') { + $Parameters['DeliveryNotificationOption'] = @($EmailParameters.DeliveryNotifications) + } + } + + $Parameters['HTML'] = $Body + if ($Attachment -and $Attachment.Count -gt 0) { + $Parameters['Attachment'] = $Attachment.ToArray() + } + if ($AdditionalParameters) { + foreach ($Key in $AdditionalParameters.Keys) { + $Parameters[$Key] = $AdditionalParameters[$Key] + } + } + if ($WhatIf) { + $Parameters['WhatIf'] = $true + } + + & $Command @Parameters +} diff --git a/Public/Email.ps1 b/Public/Email.ps1 index 49d60475..949270b7 100644 --- a/Public/Email.ps1 +++ b/Public/Email.ps1 @@ -75,6 +75,12 @@ function Email { .PARAMETER OutputHTML Switch parameter to output the email content as HTML. + .PARAMETER UseMailozaurr + Sends through Mailozaurr's Send-EmailMessage command when requested. Mailozaurr is resolved at runtime and is not a required PSWriteHTML dependency. + + .PARAMETER MailozaurrParameters + Optional provider, authentication, retry, and transport parameters passed to Send-EmailMessage. Explicit values override parameters mapped from Email. + .PARAMETER WhatIf Switch parameter to show what would happen without actually sending the email. @@ -105,6 +111,8 @@ function Email { [alias('Supress')][bool] $Suppress = $true, [switch] $Online, [switch] $OutputHTML, + [switch] $UseMailozaurr, + [System.Collections.IDictionary] $MailozaurrParameters, [switch] $WhatIf ) $Script:EmailSchema = [ordered]@{} @@ -198,6 +206,10 @@ function Email { if ($OutputHTML) { # If outputhtml is set it allows to return Body of HTML for using it in different scenarios $Body + $EndTime = Stop-TimeLog -Time $StartTime -Option OneLiner + Write-Verbose "Email - Time to render: $EndTime" + $Script:EmailSchema = $null + return } if ($AttachSelf) { if ($AttachSelfName) { @@ -218,7 +230,11 @@ function Email { } #$MailSentTo = "To: $($ServerParameters.To -join ', '); CC: $($ServerParameters.CC -join ', '); BCC: $($ServerParameters.BCC -join ', ')".Trim() - $EmailOutput = Send-Email -EmailParameters $ServerParameters -Body $Body -Attachment $Attachments -WhatIf:$WhatIf + if ($UseMailozaurr) { + $EmailOutput = Invoke-PSWriteHTMLMailozaurr -EmailParameters $ServerParameters -Body $Body -Attachment $Attachments -AdditionalParameters $MailozaurrParameters -WhatIf:$WhatIf + } else { + $EmailOutput = Send-Email -EmailParameters $ServerParameters -Body $Body -Attachment $Attachments -WhatIf:$WhatIf + } if (-not $Suppress) { $EmailOutput } diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 new file mode 100644 index 00000000..8ab1363a --- /dev/null +++ b/Tests/Email.Tests.ps1 @@ -0,0 +1,113 @@ +Describe 'Email content and transport contracts' { + BeforeAll { + . "$PSScriptRoot\..\Private\Invoke-PSWriteHTMLMailozaurr.ps1" + . "$PSScriptRoot\..\Public\Email.ps1" + . "$PSScriptRoot\..\Public\EmailServer.ps1" + + function Stop-TimeLog { + '0 ms' + } + + function Test-MailozaurrSender { + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Smtp')] + param( + [object] $From, + [object[]] $To, + [object[]] $Cc, + [object[]] $Bcc, + [string] $ReplyTo, + [Parameter(ParameterSetName = 'Smtp')][string] $Server, + [Parameter(ParameterSetName = 'Smtp')][string] $Username, + [Parameter(ParameterSetName = 'Smtp')][string] $Password, + [Parameter(ParameterSetName = 'Smtp')][int] $Port, + [string] $Subject, + [string] $Priority, + [string[]] $HTML, + [object[]] $Attachment, + [Parameter(ParameterSetName = 'Smtp')][switch] $UseSsl, + [Parameter(ParameterSetName = 'Smtp')][switch] $AsSecureString, + [Parameter(ParameterSetName = 'Smtp')][switch] $UseDefaultCredentials, + [Parameter(Mandatory, ParameterSetName = 'Graph')][switch] $Graph, + [Parameter(ParameterSetName = 'Graph')][pscredential] $Credential, + [string] $ProviderMarker + ) + + [pscustomobject] @{ + ParameterSet = $PSCmdlet.ParameterSetName + From = $From + To = $To + HTML = $HTML -join '' + Attachment = $Attachment + UseSsl = $UseSsl.IsPresent + UseDefaultCredentials = $UseDefaultCredentials.IsPresent + ProviderMarker = $ProviderMarker + } + } + + $script:TestMailozaurrSenderCommand = Get-Command Test-MailozaurrSender + } + + BeforeEach { + $script:LegacySendCount = 0 + function Send-Email { + $script:LegacySendCount++ + throw 'The legacy sender must not be invoked by this test.' + } + } + + It 'returns content without invoking any sender when OutputHTML is selected' { + $result = Email -OutputHTML -Suppress:$false { + 'Rendered only' + } + + $result | Should -Be 'Rendered only' + $script:LegacySendCount | Should -Be 0 + } + + It 'maps the generated body and legacy headers to the optional Mailozaurr command' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -SSL -MailozaurrParameters @{ ProviderMarker = 'runtime-adapter' } { + 'Mailozaurr body' + } + + $result.From | Should -Be 'sender@example.test' + $result.To | Should -Contain 'recipient@example.test' + $result.HTML | Should -Be 'Mailozaurr body' + $result.UseSsl | Should -BeTrue + $result.ProviderMarker | Should -Be 'runtime-adapter' + $script:LegacySendCount | Should -Be 0 + } + + It 'does not leak SMTP-only defaults into a Graph Mailozaurr parameter set' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('graph-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Graph = $true; Credential = $credential } { + 'Graph body' + } + + $result.ParameterSet | Should -Be 'Graph' + $result.HTML | Should -Be 'Graph body' + $script:LegacySendCount | Should -Be 0 + } + + It 'preserves legacy default-credential SMTP behavior' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' { + EmailServer -Server 'smtp.example.test' -UseDefaultCredential + 'Default credentials' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.UseDefaultCredentials | Should -BeTrue + $script:LegacySendCount | Should -Be 0 + } +} From eb74ffbf3c7df9e8d96cf9e2e996679ec1aaaa16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 8 Aug 2026 22:19:48 +0200 Subject: [PATCH 2/9] Harden optional Mailozaurr transport selection --- PSWriteHTML.Tests.ps1 | 9 ++++-- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 37 +++++++++++++--------- Tests/Email.Tests.ps1 | 40 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 16 deletions(-) diff --git a/PSWriteHTML.Tests.ps1 b/PSWriteHTML.Tests.ps1 index 65e0bb0c..7e647f04 100644 --- a/PSWriteHTML.Tests.ps1 +++ b/PSWriteHTML.Tests.ps1 @@ -51,8 +51,13 @@ try { Write-Color 'Running tests...' -Color Yellow Write-Color -$result = Invoke-Pester -Script $PSScriptRoot\Tests -Verbose -PassThru +$InvokePesterCommand = Get-Command Invoke-Pester +if ($InvokePesterCommand.Parameters.ContainsKey('Path')) { + $result = Invoke-Pester -Path $PSScriptRoot\Tests -Output Detailed -PassThru +} else { + $result = Invoke-Pester -Script $PSScriptRoot\Tests -Verbose -PassThru +} if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) tests failed." -} \ No newline at end of file +} diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index 1761f3dd..cc26b51d 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -14,16 +14,15 @@ function Invoke-PSWriteHTMLMailozaurr { $Command = Get-Command -Name 'Mailozaurr\Send-EmailMessage' -ErrorAction SilentlyContinue if (-not $Command) { - $Command = Get-Command -Name 'Send-EmailMessage' -ErrorAction SilentlyContinue + $Candidate = Get-Command -Name 'Send-EmailMessage' -ErrorAction SilentlyContinue + if ($Candidate -and ($Candidate.ModuleName -eq 'Mailozaurr' -or $Candidate.Source -eq 'Mailozaurr')) { + $Command = $Candidate + } } if (-not $Command) { throw "Email -UseMailozaurr requires the Mailozaurr module and its Send-EmailMessage command to be available." } - if ($EmailParameters.PasswordFromFile) { - throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior. Pass Credential or provider authentication through -MailozaurrParameters." - } - $Parameters = [ordered] @{} $CommonMappings = [ordered] @{ From = 'From' @@ -43,22 +42,25 @@ function Invoke-PSWriteHTMLMailozaurr { $UseSmtpTransport = $true $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') + $IsSelectorEnabled = { + param($Value) + + if ($Value -is [System.Management.Automation.SwitchParameter]) { + return $Value.IsPresent + } + if ($Value -is [bool]) { + return $Value + } + return $null -ne $Value + } if ($AdditionalParameters) { foreach ($Key in $AdditionalParameters.Keys) { $SelectorName = [string] $Key if ($NonSmtpSelectors -notcontains $SelectorName) { continue } - $SelectorValue = $AdditionalParameters[$Key] - $SelectorEnabled = if ($SelectorValue -is [System.Management.Automation.SwitchParameter]) { - $SelectorValue.IsPresent - } elseif ($SelectorValue -is [bool]) { - $SelectorValue - } else { - $null -ne $SelectorValue - } - if ($SelectorEnabled) { + if (& $IsSelectorEnabled $SelectorValue) { $UseSmtpTransport = $false break } @@ -66,6 +68,10 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($UseSmtpTransport) { + if ($EmailParameters.PasswordFromFile) { + throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior for SMTP. Pass Credential or provider authentication through -MailozaurrParameters." + } + $SmtpMappings = [ordered] @{ Server = 'Server' Login = 'Username' @@ -98,6 +104,9 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($AdditionalParameters) { foreach ($Key in $AdditionalParameters.Keys) { + if ($NonSmtpSelectors -contains [string] $Key -and -not (& $IsSelectorEnabled $AdditionalParameters[$Key])) { + continue + } $Parameters[$Key] = $AdditionalParameters[$Key] } } diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index 8ab1363a..503d575e 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -41,6 +41,7 @@ Describe 'Email content and transport contracts' { UseSsl = $UseSsl.IsPresent UseDefaultCredentials = $UseDefaultCredentials.IsPresent ProviderMarker = $ProviderMarker + BoundParameters = @($PSBoundParameters.Keys) } } @@ -96,6 +97,45 @@ Describe 'Email content and transport contracts' { $script:LegacySendCount | Should -Be 0 } + It 'omits disabled provider selectors from the Mailozaurr splat' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ Graph = $false } { + 'SMTP body' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.BoundParameters | Should -Not -Contain 'Graph' + } + + It 'ignores legacy PasswordFromFile when a non-SMTP provider is selected' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('graph-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Graph = $true; Credential = $credential } { + 'Graph body' + } + + $result.ParameterSet | Should -Be 'Graph' + } + + It 'rejects an unrelated global Send-EmailMessage command' { + Mock Get-Command { + param($Name) + if ($Name -eq 'Mailozaurr\Send-EmailMessage') { + return $null + } + $script:TestMailozaurrSenderCommand + } + + { Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' { 'body' } } | + Should -Throw '*requires the Mailozaurr module*' + } + It 'preserves legacy default-credential SMTP behavior' { Mock Get-Command { $script:TestMailozaurrSenderCommand From 5adfa629d1735122755a34839558c6ddf0454af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 8 Aug 2026 22:34:16 +0200 Subject: [PATCH 3/9] Allow explicit Mailozaurr SMTP authentication --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 21 +++++++++++++++++---- Tests/Email.Tests.ps1 | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index cc26b51d..b3972212 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -42,7 +42,7 @@ function Invoke-PSWriteHTMLMailozaurr { $UseSmtpTransport = $true $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') - $IsSelectorEnabled = { + $IsParameterEnabled = { param($Value) if ($Value -is [System.Management.Automation.SwitchParameter]) { @@ -51,6 +51,9 @@ function Invoke-PSWriteHTMLMailozaurr { if ($Value -is [bool]) { return $Value } + if ($Value -is [string]) { + return -not [string]::IsNullOrWhiteSpace($Value) + } return $null -ne $Value } if ($AdditionalParameters) { @@ -60,7 +63,7 @@ function Invoke-PSWriteHTMLMailozaurr { continue } $SelectorValue = $AdditionalParameters[$Key] - if (& $IsSelectorEnabled $SelectorValue) { + if (& $IsParameterEnabled $SelectorValue) { $UseSmtpTransport = $false break } @@ -68,7 +71,17 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($UseSmtpTransport) { - if ($EmailParameters.PasswordFromFile) { + $HasExplicitSmtpAuthentication = $false + if ($AdditionalParameters) { + foreach ($AuthenticationParameter in @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2')) { + if ($AdditionalParameters.Keys -contains $AuthenticationParameter -and + (& $IsParameterEnabled $AdditionalParameters[$AuthenticationParameter])) { + $HasExplicitSmtpAuthentication = $true + break + } + } + } + if ($EmailParameters.PasswordFromFile -and -not $HasExplicitSmtpAuthentication) { throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior for SMTP. Pass Credential or provider authentication through -MailozaurrParameters." } @@ -104,7 +117,7 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($AdditionalParameters) { foreach ($Key in $AdditionalParameters.Keys) { - if ($NonSmtpSelectors -contains [string] $Key -and -not (& $IsSelectorEnabled $AdditionalParameters[$Key])) { + if ($NonSmtpSelectors -contains [string] $Key -and -not (& $IsParameterEnabled $AdditionalParameters[$Key])) { continue } $Parameters[$Key] = $AdditionalParameters[$Key] diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index 503d575e..4757e334 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -28,7 +28,7 @@ Describe 'Email content and transport contracts' { [Parameter(ParameterSetName = 'Smtp')][switch] $AsSecureString, [Parameter(ParameterSetName = 'Smtp')][switch] $UseDefaultCredentials, [Parameter(Mandatory, ParameterSetName = 'Graph')][switch] $Graph, - [Parameter(ParameterSetName = 'Graph')][pscredential] $Credential, + [pscredential] $Credential, [string] $ProviderMarker ) @@ -123,6 +123,20 @@ Describe 'Email content and transport contracts' { $result.ParameterSet | Should -Be 'Graph' } + It 'allows explicit SMTP authentication to replace legacy PasswordFromFile' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('smtp-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ Credential = $credential } { + 'SMTP body' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.BoundParameters | Should -Contain 'Credential' + } + It 'rejects an unrelated global Send-EmailMessage command' { Mock Get-Command { param($Name) From d5b836ed65405f82d7ec829320fde8ed3ed92311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 8 Aug 2026 23:06:48 +0200 Subject: [PATCH 4/9] fix: honor case-sensitive authentication keys --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 11 ++++++++--- Tests/Email.Tests.ps1 | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index b3972212..2fd8e834 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -74,9 +74,14 @@ function Invoke-PSWriteHTMLMailozaurr { $HasExplicitSmtpAuthentication = $false if ($AdditionalParameters) { foreach ($AuthenticationParameter in @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2')) { - if ($AdditionalParameters.Keys -contains $AuthenticationParameter -and - (& $IsParameterEnabled $AdditionalParameters[$AuthenticationParameter])) { - $HasExplicitSmtpAuthentication = $true + foreach ($Key in $AdditionalParameters.Keys) { + if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase) -and + (& $IsParameterEnabled $AdditionalParameters[$Key])) { + $HasExplicitSmtpAuthentication = $true + break + } + } + if ($HasExplicitSmtpAuthentication) { break } } diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index 4757e334..d39f5443 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -137,6 +137,22 @@ Describe 'Email content and transport contracts' { $result.BoundParameters | Should -Contain 'Credential' } + It 'finds explicit SMTP authentication in a case-sensitive dictionary by its actual key' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('smtp-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + $parameters = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + $parameters.Add('credential', $credential) + + $result = Email -UseMailozaurr -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters $parameters { + 'SMTP body' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.BoundParameters | Should -Contain 'credential' + } + It 'rejects an unrelated global Send-EmailMessage command' { Mock Get-Command { param($Name) From 66b04b6bdb4a301e2ffa671d0eced2ac18f9ea28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sat, 8 Aug 2026 23:46:35 +0200 Subject: [PATCH 5/9] Preserve email output and authentication contracts --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 15 ++++++--- Public/Email.ps1 | 4 --- Tests/Email.Tests.ps1 | 39 +++++++++++++++++++++--- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index 2fd8e834..73816001 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -71,22 +71,22 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($UseSmtpTransport) { - $HasExplicitSmtpAuthentication = $false + $SmtpPasswordReplacementParameter = if ($EmailParameters.UseDefaultCredentials) { 'UseDefaultCredentials' } else { $null } if ($AdditionalParameters) { - foreach ($AuthenticationParameter in @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2')) { + foreach ($AuthenticationParameter in @('Credential', 'Password', 'UseDefaultCredentials')) { foreach ($Key in $AdditionalParameters.Keys) { if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase) -and (& $IsParameterEnabled $AdditionalParameters[$Key])) { - $HasExplicitSmtpAuthentication = $true + $SmtpPasswordReplacementParameter = $AuthenticationParameter break } } - if ($HasExplicitSmtpAuthentication) { + if ($SmtpPasswordReplacementParameter) { break } } } - if ($EmailParameters.PasswordFromFile -and -not $HasExplicitSmtpAuthentication) { + if ($EmailParameters.PasswordFromFile -and -not $SmtpPasswordReplacementParameter) { throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior for SMTP. Pass Credential or provider authentication through -MailozaurrParameters." } @@ -97,6 +97,11 @@ function Invoke-PSWriteHTMLMailozaurr { Port = 'Port' } foreach ($SourceName in $SmtpMappings.Keys) { + if ($EmailParameters.PasswordFromFile -and + ($SourceName -eq 'Password' -or + ($SourceName -eq 'Login' -and $SmtpPasswordReplacementParameter -in @('Credential', 'UseDefaultCredentials')))) { + continue + } $Value = $EmailParameters[$SourceName] if ($null -ne $Value -and -not ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value))) { $Parameters[$SmtpMappings[$SourceName]] = $Value diff --git a/Public/Email.ps1 b/Public/Email.ps1 index 949270b7..6a1c44d2 100644 --- a/Public/Email.ps1 +++ b/Public/Email.ps1 @@ -206,10 +206,6 @@ function Email { if ($OutputHTML) { # If outputhtml is set it allows to return Body of HTML for using it in different scenarios $Body - $EndTime = Stop-TimeLog -Time $StartTime -Option OneLiner - Write-Verbose "Email - Time to render: $EndTime" - $Script:EmailSchema = $null - return } if ($AttachSelf) { if ($AttachSelfName) { diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index d39f5443..b47cf823 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -56,13 +56,20 @@ Describe 'Email content and transport contracts' { } } - It 'returns content without invoking any sender when OutputHTML is selected' { + It 'returns content and preserves the legacy send when OutputHTML is selected' { + Mock Send-Email { + $script:LegacySendCount++ + 'sent' + } + $result = Email -OutputHTML -Suppress:$false { - 'Rendered only' + 'Rendered and sent' } - $result | Should -Be 'Rendered only' - $script:LegacySendCount | Should -Be 0 + $result | Should -HaveCount 2 + $result[0] | Should -Be 'Rendered and sent' + $result[1] | Should -Be 'sent' + $script:LegacySendCount | Should -Be 1 } It 'maps the generated body and legacy headers to the optional Mailozaurr command' { @@ -129,12 +136,34 @@ Describe 'Email content and transport contracts' { } $credential = [pscredential]::new('smtp-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) - $result = Email -UseMailozaurr -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ Credential = $credential } { + $result = Email -UseMailozaurr -Username 'legacy-user@example.test' -Password 'C:\secrets\smtp-password.txt' -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ Credential = $credential } { 'SMTP body' } $result.ParameterSet | Should -Be 'Smtp' $result.BoundParameters | Should -Contain 'Credential' + $result.BoundParameters | Should -Not -Contain 'Username' + $result.BoundParameters | Should -Not -Contain 'Password' + } + + It 'rejects Username alone as a replacement for legacy PasswordFromFile' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + { Email -UseMailozaurr -Password 'C:\secrets\smtp-password.txt' -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ Username = 'smtp-user@example.test' } { + 'SMTP body' + } } | Should -Throw '*does not map the legacy -PasswordFromFile behavior*' + } + + It 'rejects OAuth2 without the credential that supplies its token' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + { Email -UseMailozaurr -Password 'C:\secrets\smtp-password.txt' -PasswordFromFile -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -Server 'smtp.example.test' -MailozaurrParameters @{ OAuth2 = $true } { + 'SMTP body' + } } | Should -Throw '*does not map the legacy -PasswordFromFile behavior*' } It 'finds explicit SMTP authentication in a case-sensitive dictionary by its actual key' { From a960da76cc9410ffb9e615985851c5d4654c9b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 00:30:05 +0200 Subject: [PATCH 6/9] Harden optional Mailozaurr authentication mapping --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 40 ++++++++---- Tests/Email.Tests.ps1 | 80 ++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index 73816001..fb14c65e 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -42,6 +42,7 @@ function Invoke-PSWriteHTMLMailozaurr { $UseSmtpTransport = $true $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') + $SmtpAuthenticationParameters = @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2') $IsParameterEnabled = { param($Value) @@ -71,21 +72,36 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($UseSmtpTransport) { - $SmtpPasswordReplacementParameter = if ($EmailParameters.UseDefaultCredentials) { 'UseDefaultCredentials' } else { $null } + $ExplicitSmtpAuthenticationParameter = $null + $AdditionalUseDefaultCredentialsSpecified = $false if ($AdditionalParameters) { foreach ($AuthenticationParameter in @('Credential', 'Password', 'UseDefaultCredentials')) { foreach ($Key in $AdditionalParameters.Keys) { - if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase) -and - (& $IsParameterEnabled $AdditionalParameters[$Key])) { - $SmtpPasswordReplacementParameter = $AuthenticationParameter - break + if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase)) { + if ($AuthenticationParameter -eq 'UseDefaultCredentials') { + $AdditionalUseDefaultCredentialsSpecified = $true + } + if (& $IsParameterEnabled $AdditionalParameters[$Key]) { + $ExplicitSmtpAuthenticationParameter = $AuthenticationParameter + break + } } } - if ($SmtpPasswordReplacementParameter) { + if ($ExplicitSmtpAuthenticationParameter) { break } } } + $LegacyDefaultCredentialsEffective = ([bool] $EmailParameters.UseDefaultCredentials) -and + -not $AdditionalUseDefaultCredentialsSpecified -and + -not $ExplicitSmtpAuthenticationParameter + $SmtpPasswordReplacementParameter = if ($ExplicitSmtpAuthenticationParameter) { + $ExplicitSmtpAuthenticationParameter + } elseif ($LegacyDefaultCredentialsEffective) { + 'UseDefaultCredentials' + } else { + $null + } if ($EmailParameters.PasswordFromFile -and -not $SmtpPasswordReplacementParameter) { throw "Email -UseMailozaurr does not map the legacy -PasswordFromFile behavior for SMTP. Pass Credential or provider authentication through -MailozaurrParameters." } @@ -97,9 +113,8 @@ function Invoke-PSWriteHTMLMailozaurr { Port = 'Port' } foreach ($SourceName in $SmtpMappings.Keys) { - if ($EmailParameters.PasswordFromFile -and - ($SourceName -eq 'Password' -or - ($SourceName -eq 'Login' -and $SmtpPasswordReplacementParameter -in @('Credential', 'UseDefaultCredentials')))) { + if (($SourceName -eq 'Password' -and ($EmailParameters.PasswordFromFile -or $SmtpPasswordReplacementParameter)) -or + ($SourceName -eq 'Login' -and $SmtpPasswordReplacementParameter -in @('Credential', 'UseDefaultCredentials'))) { continue } $Value = $EmailParameters[$SourceName] @@ -107,13 +122,13 @@ function Invoke-PSWriteHTMLMailozaurr { $Parameters[$SmtpMappings[$SourceName]] = $Value } } - if ($EmailParameters.UseDefaultCredentials) { + if ($LegacyDefaultCredentialsEffective) { $Parameters['UseDefaultCredentials'] = $true } if ($EmailParameters.EnableSSL) { $Parameters['UseSsl'] = $true } - if ($EmailParameters.PasswordAsSecure) { + if ($EmailParameters.PasswordAsSecure -and $SmtpPasswordReplacementParameter -notin @('Credential', 'UseDefaultCredentials')) { $Parameters['AsSecureString'] = $true } if ($EmailParameters.DeliveryNotifications -and $EmailParameters.DeliveryNotifications -ne 'None') { @@ -130,6 +145,9 @@ function Invoke-PSWriteHTMLMailozaurr { if ($NonSmtpSelectors -contains [string] $Key -and -not (& $IsParameterEnabled $AdditionalParameters[$Key])) { continue } + if ($SmtpAuthenticationParameters -contains [string] $Key -and -not (& $IsParameterEnabled $AdditionalParameters[$Key])) { + continue + } $Parameters[$Key] = $AdditionalParameters[$Key] } } diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index b47cf823..91fde357 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -209,4 +209,84 @@ Describe 'Email content and transport contracts' { $result.UseDefaultCredentials | Should -BeTrue $script:LegacySendCount | Should -Be 0 } + + It 'lets an explicit credential replace legacy default authentication' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('smtp-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Credential = $credential } { + EmailServer -Server 'smtp.example.test' -UserName 'legacy-user@example.test' -Password 'legacy-password' -PasswordAsSecure -UseDefaultCredential + 'Explicit credentials' + } + + $result.BoundParameters | Should -Contain 'Credential' + $result.BoundParameters | Should -Not -Contain 'UseDefaultCredentials' + $result.BoundParameters | Should -Not -Contain 'Username' + $result.BoundParameters | Should -Not -Contain 'Password' + $result.BoundParameters | Should -Not -Contain 'AsSecureString' + } + + It 'lets explicit username and password replace legacy default authentication' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Username = 'smtp-user@example.test'; Password = 'explicit-password' } { + EmailServer -Server 'smtp.example.test' -UseDefaultCredential + 'Explicit password' + } + + $result.BoundParameters | Should -Contain 'Username' + $result.BoundParameters | Should -Contain 'Password' + $result.BoundParameters | Should -Not -Contain 'UseDefaultCredentials' + } + + It 'omits a disabled default-credential selector and suppresses the legacy value' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ UseDefaultCredentials = $false } { + EmailServer -Server 'smtp.example.test' -UseDefaultCredential + 'Anonymous SMTP' + } + + $result.BoundParameters | Should -Not -Contain 'UseDefaultCredentials' + } + + It 'omits an inactive null credential without replacing legacy authentication' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Credential = $null } { + EmailServer -Server 'smtp.example.test' -UserName 'legacy-user@example.test' -Password 'legacy-password' + 'Legacy credentials' + } + + $result.BoundParameters | Should -Not -Contain 'Credential' + $result.BoundParameters | Should -Contain 'Username' + $result.BoundParameters | Should -Contain 'Password' + } + + It 'omits inactive authentication values from a case-sensitive parameter dictionary' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $parameters = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + $parameters['credential'] = $null + $parameters['username'] = ' ' + $parameters['password'] = '' + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters $parameters { + EmailServer -Server 'smtp.example.test' + 'Anonymous SMTP' + } + + $result.BoundParameters | Should -Not -Contain 'credential' + $result.BoundParameters | Should -Not -Contain 'username' + $result.BoundParameters | Should -Not -Contain 'password' + } } From dfe6f899d7f4931849a601b17a51bc125fe3730b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 01:26:18 +0200 Subject: [PATCH 7/9] Respect explicit secure password overrides --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 11 +++++++++- Tests/Email.Tests.ps1 | 28 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index fb14c65e..9fcee610 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -74,7 +74,14 @@ function Invoke-PSWriteHTMLMailozaurr { if ($UseSmtpTransport) { $ExplicitSmtpAuthenticationParameter = $null $AdditionalUseDefaultCredentialsSpecified = $false + $AdditionalAsSecureStringSpecified = $false if ($AdditionalParameters) { + foreach ($Key in $AdditionalParameters.Keys) { + if ([string]::Equals([string] $Key, 'AsSecureString', [System.StringComparison]::OrdinalIgnoreCase)) { + $AdditionalAsSecureStringSpecified = $true + break + } + } foreach ($AuthenticationParameter in @('Credential', 'Password', 'UseDefaultCredentials')) { foreach ($Key in $AdditionalParameters.Keys) { if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase)) { @@ -128,7 +135,9 @@ function Invoke-PSWriteHTMLMailozaurr { if ($EmailParameters.EnableSSL) { $Parameters['UseSsl'] = $true } - if ($EmailParameters.PasswordAsSecure -and $SmtpPasswordReplacementParameter -notin @('Credential', 'UseDefaultCredentials')) { + if ($EmailParameters.PasswordAsSecure -and + -not $AdditionalAsSecureStringSpecified -and + $SmtpPasswordReplacementParameter -notin @('Credential', 'Password', 'UseDefaultCredentials')) { $Parameters['AsSecureString'] = $true } if ($EmailParameters.DeliveryNotifications -and $EmailParameters.DeliveryNotifications -ne 'None') { diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index 91fde357..d0ee31da 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -289,4 +289,32 @@ Describe 'Email content and transport contracts' { $result.BoundParameters | Should -Not -Contain 'username' $result.BoundParameters | Should -Not -Contain 'password' } + + It 'does not apply the legacy secure-string flag to a plaintext password override' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Password = 'replacement-password' } { + EmailServer -Server 'smtp.example.test' -UserName 'legacy-user@example.test' -Password 'legacy-password' -PasswordAsSecure + 'Plaintext replacement' + } + + $result.BoundParameters | Should -Contain 'Password' + $result.BoundParameters | Should -Not -Contain 'AsSecureString' + } + + It 'honors an explicit secure-string flag with a replacement password' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Password = 'replacement-password'; AsSecureString = $true } { + EmailServer -Server 'smtp.example.test' -UserName 'legacy-user@example.test' -Password 'legacy-password' + 'Secure replacement' + } + + $result.BoundParameters | Should -Contain 'Password' + $result.BoundParameters | Should -Contain 'AsSecureString' + } } From 570372e4956c004b1411fed8b14089f9b1390a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 02:23:12 +0200 Subject: [PATCH 8/9] Filter SMTP-only authentication parameters --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 25 +++++++-- Tests/Email.Tests.ps1 | 66 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index 9fcee610..a04713b7 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -42,7 +42,8 @@ function Invoke-PSWriteHTMLMailozaurr { $UseSmtpTransport = $true $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') - $SmtpAuthenticationParameters = @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2') + $SmtpAuthenticationParameters = @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2', 'AsSecureString') + $SmtpOnlyParameters = @('Username', 'Password', 'UseDefaultCredentials', 'OAuth2', 'AsSecureString') $IsParameterEnabled = { param($Value) @@ -78,15 +79,21 @@ function Invoke-PSWriteHTMLMailozaurr { if ($AdditionalParameters) { foreach ($Key in $AdditionalParameters.Keys) { if ([string]::Equals([string] $Key, 'AsSecureString', [System.StringComparison]::OrdinalIgnoreCase)) { - $AdditionalAsSecureStringSpecified = $true - break + $Value = $AdditionalParameters[$Key] + if ($Value -is [bool] -or $Value -is [System.Management.Automation.SwitchParameter]) { + $AdditionalAsSecureStringSpecified = $true + break + } } } foreach ($AuthenticationParameter in @('Credential', 'Password', 'UseDefaultCredentials')) { foreach ($Key in $AdditionalParameters.Keys) { if ([string]::Equals([string] $Key, $AuthenticationParameter, [System.StringComparison]::OrdinalIgnoreCase)) { if ($AuthenticationParameter -eq 'UseDefaultCredentials') { - $AdditionalUseDefaultCredentialsSpecified = $true + $Value = $AdditionalParameters[$Key] + if ($Value -is [bool] -or $Value -is [System.Management.Automation.SwitchParameter]) { + $AdditionalUseDefaultCredentialsSpecified = $true + } } if (& $IsParameterEnabled $AdditionalParameters[$Key]) { $ExplicitSmtpAuthenticationParameter = $AuthenticationParameter @@ -151,6 +158,16 @@ function Invoke-PSWriteHTMLMailozaurr { } if ($AdditionalParameters) { foreach ($Key in $AdditionalParameters.Keys) { + if ($SmtpOnlyParameters -contains [string] $Key -and -not $UseSmtpTransport) { + continue + } + if ([string]::Equals([string] $Key, 'AsSecureString', [System.StringComparison]::OrdinalIgnoreCase)) { + $Value = $AdditionalParameters[$Key] + $IsBooleanSwitchValue = $Value -is [bool] -or $Value -is [System.Management.Automation.SwitchParameter] + if (-not $IsBooleanSwitchValue -or -not (& $IsParameterEnabled $Value)) { + continue + } + } if ($NonSmtpSelectors -contains [string] $Key -and -not (& $IsParameterEnabled $AdditionalParameters[$Key])) { continue } diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index d0ee31da..0fff3116 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -317,4 +317,70 @@ Describe 'Email content and transport contracts' { $result.BoundParameters | Should -Contain 'Password' $result.BoundParameters | Should -Contain 'AsSecureString' } + + It 'omits a disabled secure-string switch from non-SMTP providers' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ Graph = $true; AsSecureString = $false } { + 'Graph body' + } + + $result.ParameterSet | Should -Be 'Graph' + $result.BoundParameters | Should -Not -Contain 'AsSecureString' + } + + It 'treats a null secure-string switch as unspecified legacy authentication' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ AsSecureString = $null } { + EmailServer -Server 'smtp.example.test' -UserName 'legacy-user@example.test' -Password 'legacy-password' -PasswordAsSecure + 'Legacy secure password' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.BoundParameters | Should -Contain 'AsSecureString' + } + + It 'omits SMTP-only authentication values from non-SMTP providers while retaining shared credentials' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('graph-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ + Graph = $true + Credential = $credential + Username = 'stale-smtp-user' + Password = 'stale-smtp-password' + UseDefaultCredentials = $true + OAuth2 = $true + AsSecureString = $true + } { + 'Graph authentication' + } + + $result.ParameterSet | Should -Be 'Graph' + $result.BoundParameters | Should -Contain 'Credential' + foreach ($parameter in 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2', 'AsSecureString') { + $result.BoundParameters | Should -Not -Contain $parameter + } + } + + It 'treats a null default-credential switch as unspecified legacy authentication' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ UseDefaultCredentials = $null } { + EmailServer -Server 'smtp.example.test' -UseDefaultCredential + 'Legacy default credentials' + } + + $result.ParameterSet | Should -Be 'Smtp' + $result.BoundParameters | Should -Contain 'UseDefaultCredentials' + } } From 886ca7ea74b4a09fecbce1c570e1e27399c6932a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Sun, 9 Aug 2026 03:12:10 +0200 Subject: [PATCH 9/9] Filter transport-specific Mailozaurr parameters --- Private/Invoke-PSWriteHTMLMailozaurr.ps1 | 12 ++++- Tests/Email.Tests.ps1 | 65 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 index a04713b7..abf35bf1 100644 --- a/Private/Invoke-PSWriteHTMLMailozaurr.ps1 +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -43,7 +43,17 @@ function Invoke-PSWriteHTMLMailozaurr { $UseSmtpTransport = $true $NonSmtpSelectors = @('Graph', 'MgGraphRequest', 'SendGrid', 'EmailProvider') $SmtpAuthenticationParameters = @('Credential', 'Username', 'Password', 'UseDefaultCredentials', 'OAuth2', 'AsSecureString') - $SmtpOnlyParameters = @('Username', 'Password', 'UseDefaultCredentials', 'OAuth2', 'AsSecureString') + $SmtpOnlyParameters = @( + 'Server', 'SmtpServer', 'Port', 'Encoding', + 'DeliveryNotificationOption', 'DeliveryStatusNotificationType', + 'Username', 'Password', 'AuthenticationMechanism', 'SecureSocketOptions', 'UseSsl', + 'SkipCertificateRevocation', 'SkipCertificateValidation', 'SkipCertificateValidatation', + 'Timeout', 'MimeMessagePath', 'LocalDomain', 'UseDefaultCredentials', + 'SignOrEncrypt', 'CertificatePath', 'CertificatePassword', + 'CertificatePasswordAsSecureString', 'CertificateThumbprint', 'Certificate', + 'PublicKeyPath', 'PrivateKeyPath', 'PrivateKeyPassword', + 'PrivateKeyPasswordAsSecureString', 'OAuth2', 'oAuth', 'AsSecureString' + ) $IsParameterEnabled = { param($Value) diff --git a/Tests/Email.Tests.ps1 b/Tests/Email.Tests.ps1 index 0fff3116..f810994e 100644 --- a/Tests/Email.Tests.ps1 +++ b/Tests/Email.Tests.ps1 @@ -370,6 +370,71 @@ Describe 'Email content and transport contracts' { } } + It 'omits the complete SMTP transport surface from non-SMTP providers' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('graph-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + $smtpOnly = @{ + Server = 'smtp.example.test' + Port = 465 + Encoding = 'UTF8' + DeliveryNotificationOption = 'OnFailure' + DeliveryStatusNotificationType = 'Full' + AuthenticationMechanism = 'Plain' + SecureSocketOptions = 'SslOnConnect' + UseSsl = $true + SkipCertificateRevocation = $true + SkipCertificateValidation = $true + Timeout = 45000 + MimeMessagePath = 'message.eml' + LocalDomain = 'example.test' + SignOrEncrypt = 'Sign' + CertificatePath = 'certificate.pfx' + CertificatePassword = 'not-a-secret' + CertificatePasswordAsSecureString = $true + CertificateThumbprint = '00' + Certificate = [pscustomobject]@{ Subject = 'CN=Test' } + PublicKeyPath = 'public.asc' + PrivateKeyPath = 'private.asc' + PrivateKeyPassword = 'not-a-secret' + PrivateKeyPasswordAsSecureString = $true + } + $parameters = @{ Graph = $true; Credential = $credential } + foreach ($entry in $smtpOnly.GetEnumerator()) { + $parameters[$entry.Key] = $entry.Value + } + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters $parameters { + 'Graph transport filtering' + } + + $result.ParameterSet | Should -Be 'Graph' + $result.BoundParameters | Should -Contain 'Credential' + foreach ($parameter in $smtpOnly.Keys) { + $result.BoundParameters | Should -Not -Contain $parameter + } + } + + It 'omits the SMTP OAuth alias from non-SMTP providers' { + Mock Get-Command { + $script:TestMailozaurrSenderCommand + } + $credential = [pscredential]::new('graph-user@example.test', (ConvertTo-SecureString 'not-a-secret' -AsPlainText -Force)) + + $result = Email -UseMailozaurr -Suppress:$false -From 'sender@example.test' -To 'recipient@example.test' -MailozaurrParameters @{ + Graph = $true + Credential = $credential + oAuth = $true + } { + 'Graph OAuth alias filtering' + } + + $result.ParameterSet | Should -Be 'Graph' + $result.BoundParameters | Should -Contain 'Credential' + $result.BoundParameters | Should -Not -Contain 'oAuth' + } + It 'treats a null default-credential switch as unspecified legacy authentication' { Mock Get-Command { $script:TestMailozaurrSenderCommand