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/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 new file mode 100644 index 00000000..abf35bf1 --- /dev/null +++ b/Private/Invoke-PSWriteHTMLMailozaurr.ps1 @@ -0,0 +1,195 @@ +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) { + $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." + } + + $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') + $SmtpAuthenticationParameters = @('Credential', '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) + + if ($Value -is [System.Management.Automation.SwitchParameter]) { + return $Value.IsPresent + } + if ($Value -is [bool]) { + return $Value + } + if ($Value -is [string]) { + return -not [string]::IsNullOrWhiteSpace($Value) + } + return $null -ne $Value + } + if ($AdditionalParameters) { + foreach ($Key in $AdditionalParameters.Keys) { + $SelectorName = [string] $Key + if ($NonSmtpSelectors -notcontains $SelectorName) { + continue + } + $SelectorValue = $AdditionalParameters[$Key] + if (& $IsParameterEnabled $SelectorValue) { + $UseSmtpTransport = $false + break + } + } + } + + if ($UseSmtpTransport) { + $ExplicitSmtpAuthenticationParameter = $null + $AdditionalUseDefaultCredentialsSpecified = $false + $AdditionalAsSecureStringSpecified = $false + if ($AdditionalParameters) { + foreach ($Key in $AdditionalParameters.Keys) { + if ([string]::Equals([string] $Key, 'AsSecureString', [System.StringComparison]::OrdinalIgnoreCase)) { + $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') { + $Value = $AdditionalParameters[$Key] + if ($Value -is [bool] -or $Value -is [System.Management.Automation.SwitchParameter]) { + $AdditionalUseDefaultCredentialsSpecified = $true + } + } + if (& $IsParameterEnabled $AdditionalParameters[$Key]) { + $ExplicitSmtpAuthenticationParameter = $AuthenticationParameter + break + } + } + } + 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." + } + + $SmtpMappings = [ordered] @{ + Server = 'Server' + Login = 'Username' + Password = 'Password' + Port = 'Port' + } + foreach ($SourceName in $SmtpMappings.Keys) { + if (($SourceName -eq 'Password' -and ($EmailParameters.PasswordFromFile -or $SmtpPasswordReplacementParameter)) -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 + } + } + if ($LegacyDefaultCredentialsEffective) { + $Parameters['UseDefaultCredentials'] = $true + } + if ($EmailParameters.EnableSSL) { + $Parameters['UseSsl'] = $true + } + if ($EmailParameters.PasswordAsSecure -and + -not $AdditionalAsSecureStringSpecified -and + $SmtpPasswordReplacementParameter -notin @('Credential', 'Password', 'UseDefaultCredentials')) { + $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) { + 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 + } + if ($SmtpAuthenticationParameters -contains [string] $Key -and -not (& $IsParameterEnabled $AdditionalParameters[$Key])) { + continue + } + $Parameters[$Key] = $AdditionalParameters[$Key] + } + } + if ($WhatIf) { + $Parameters['WhatIf'] = $true + } + + & $Command @Parameters +} diff --git a/Public/Email.ps1 b/Public/Email.ps1 index 49d60475..6a1c44d2 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]@{} @@ -218,7 +226,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..f810994e --- /dev/null +++ b/Tests/Email.Tests.ps1 @@ -0,0 +1,451 @@ +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, + [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 + BoundParameters = @($PSBoundParameters.Keys) + } + } + + $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 and preserves the legacy send when OutputHTML is selected' { + Mock Send-Email { + $script:LegacySendCount++ + 'sent' + } + + $result = Email -OutputHTML -Suppress:$false { + 'Rendered and sent' + } + + $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' { + 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 '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 '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 -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' { + 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) + 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 + } + + $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 + } + + 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' + } + + 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' + } + + 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 '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 + } + + $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' + } +}