diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 6a91ae67..5f78f26f 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -295,6 +295,130 @@ $req = New-IdleLifecycleRequest ` } ``` +### HTML formatted Out of Office messages + +Exchange Online supports formatted automatic reply messages with HTML markup (bold, links, lists, line breaks). +IdLE provides stable idempotency for HTML messages by normalizing server-side canonicalization. + +**Example with HTML formatted messages:** + +```powershell +@{ + Name = 'Set formatted OOF for Leaver' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + InternalMessage = @' +
This mailbox is no longer monitored.
+For urgent matters, please contact:
+This mailbox is no longer monitored.
+Please contact our Service Desk at servicedesk@contoso.com.
+'@ + ExternalAudience = 'All' + } + } +} +``` + +**Idempotency behavior:** + +- When `MessageFormat = 'Html'`, the provider normalizes messages for comparison to handle Exchange server-side HTML canonicalization. +- Common normalization operations include: + - Line ending normalization (CRLF ↔ LF) + - Removal of Exchange-added HTML wrappers (``, ``, ``) + - Whitespace normalization +- This ensures workflows report `Changed = $false` on subsequent runs when the effective message content has not changed. + +**Combining HTML with template variables:** + +```powershell +@{ + Name = 'Set formatted OOF with dynamic manager' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + InternalMessage = @' +This mailbox is no longer monitored.
+For urgent matters, please contact {{Request.DesiredState.Manager.DisplayName}}.
+'@ + ExternalMessage = @' +This mailbox is no longer monitored.
+Please contact our Service Desk at servicedesk@contoso.com.
+'@ + ExternalAudience = 'All' + } + } +} +``` + +**Loading messages from external files (host-side approach):** + +For long or complex HTML messages, you can load content from external files in your host script before creating the plan: + +```powershell +# Host script - load templates before planning +$internalMessageTemplate = Get-Content -Path './templates/oof-internal.html' -Raw -Encoding UTF8 +$externalMessageTemplate = Get-Content -Path './templates/oof-external.html' -Raw -Encoding UTF8 + +# Build request with template content +$req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor $env:USERNAME ` + -Input @{ UserPrincipalName = 'user@contoso.com' } ` + -DesiredState @{ + InternalOOFMessage = $internalMessageTemplate + ExternalOOFMessage = $externalMessageTemplate + Manager = @{ + DisplayName = 'Jane Manager' + Mail = 'jmanager@contoso.com' + } + } + +# Workflow definition references the loaded content +@{ + Name = 'Set OOF with templates' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + InternalMessage = '{{Request.DesiredState.InternalOOFMessage}}' + ExternalMessage = '{{Request.DesiredState.ExternalOOFMessage}}' + ExternalAudience = 'All' + } + } +} +``` + +**Template file example** (`./templates/oof-internal.html`): + +```html +This mailbox is no longer monitored.
+For urgent matters, please contact:
+This mailbox is no longer monitored.
+For urgent matters, please contact:
+This mailbox is no longer monitored.
+Please contact our Service Desk at servicedesk@contoso.com.
+'@ ExternalAudience = 'All' } } diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 new file mode 100644 index 00000000..43536e1b --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -0,0 +1,94 @@ +function Normalize-IdleExchangeOnlineAutoReplyMessage { + <# + .SYNOPSIS + Normalizes Exchange Online auto-reply messages for stable idempotency comparison. + + .DESCRIPTION + Exchange Online may introduce server-side canonicalization when storing automatic reply messages, + such as adding HTML/body wrappers, normalizing line endings, or adjusting whitespace. + + This helper performs minimal, deterministic normalization to ensure that functionally equivalent + messages are recognized as identical during idempotency checks. + + Normalization operations: + - Normalize line endings (CRLF to LF) + - Remove common HTML wrappers added by Exchange (, , ) + - Trim leading/trailing whitespace + - Normalize consecutive whitespace sequences in HTML (multiple spaces/tabs to single space) + + This function does NOT sanitize or validate HTML. It only normalizes structural differences + introduced by server-side canonicalization. + + .PARAMETER Message + The auto-reply message string to normalize (plain text or HTML). + + .OUTPUTS + System.String - The normalized message string. + + .EXAMPLE + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentMessage + if ($normalized -eq (Normalize-IdleExchangeOnlineAutoReplyMessage -Message $desiredMessage)) { + # Messages are functionally equivalent + } + + .NOTES + This is a private helper function used by the ExchangeOnline provider for idempotency checks. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string] $Message + ) + + if ([string]::IsNullOrEmpty($Message)) { + return '' + } + + # Start with the original message + $normalized = $Message + + # 1. Normalize line endings: CRLF -> LF + $normalized = $normalized -replace "`r`n", "`n" + $normalized = $normalized -replace "`r", "`n" + + # 2. Remove common HTML wrappers that Exchange may add + # Remove declarations + $normalized = $normalized -replace '(?i)]*>', '' + + # Remove opening and closing tags (with optional attributes) + $normalized = $normalized -replace '(?i)]*>', '' + $normalized = $normalized -replace '(?i)', '' + + # Remove wrapper tags while preserving their inner content + $normalized = $normalized -replace '(?is)]*>\s*(.*?)\s*', '$1' + + # Remove opening and closing tags (with optional attributes) + $normalized = $normalized -replace '(?i)]*>', '' + $normalized = $normalized -replace '(?i)', '' + + # 3. Trim leading/trailing whitespace (including newlines) + $normalized = $normalized.Trim() + + # 4. Normalize whitespace conservatively + # Only collapse truly excessive whitespace that Exchange commonly adds + # This is conservative to avoid making intentionally different messages compare equal + # NOTE: This normalization is ONLY used for idempotency comparison, not for modifying + # the actual message sent to Exchange. The original message formatting is preserved. + + # Normalize 3+ consecutive spaces/tabs to 2 (preserves intentional double-spacing) + # This handles Exchange adding extra whitespace without collapsing intentional formatting + $normalized = $normalized -replace '[ \t]{3,}', ' ' + + # 5. Normalize excessive empty lines (4+ consecutive newlines to 3) + # This is very conservative - only removes truly excessive blank lines + # Preserves intentional spacing while handling Exchange-added excessive gaps + $normalized = $normalized -replace '\n{4,}', "`n`n`n" + + # 6. Final trim to remove any whitespace introduced by previous operations + $normalized = $normalized.Trim() + + return $normalized +} diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 63256837..875707fd 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -347,21 +347,42 @@ function New-IdleExchangeOnlineProvider { # Get current config for idempotency check $currentConfig = $this.GetOutOfOffice($mailbox.PrimarySmtpAddress, $AuthSession) - # Simple idempotency check: if mode matches and messages match, skip update + # Idempotency check with message normalization for stable comparison + # Check all fields independently to detect any configuration drift $changed = $false + + # Check mode if ($currentConfig.Mode -ne $mode) { $changed = $true } - elseif ($Config.ContainsKey('InternalMessage') -and $currentConfig.InternalMessage -ne $Config['InternalMessage']) { - $changed = $true + + # Check internal message with normalization + if ($Config.ContainsKey('InternalMessage')) { + # Use normalization to handle server-side HTML canonicalization + $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage + $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] + if ($normalizedCurrent -ne $normalizedDesired) { + $changed = $true + } } - elseif ($Config.ContainsKey('ExternalMessage') -and $currentConfig.ExternalMessage -ne $Config['ExternalMessage']) { - $changed = $true + + # Check external message with normalization + if ($Config.ContainsKey('ExternalMessage')) { + # Use normalization to handle server-side HTML canonicalization + $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage + $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] + if ($normalizedCurrent -ne $normalizedDesired) { + $changed = $true + } } - elseif ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { + + # Check external audience + if ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { $changed = $true } - elseif ($mode -eq 'Scheduled') { + + # Check scheduled mode dates + if ($mode -eq 'Scheduled') { # Compare dates (allow small tolerance for serialization differences) # Tolerance: 60 seconds to account for rounding during serialization/deserialization $dateComparisonToleranceSeconds = 60 diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index d3d4dcb2..22f3f577 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -18,6 +18,10 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { - InternalMessage: string (optional) - ExternalMessage: string (optional) - ExternalAudience: 'None' | 'Known' | 'All' (optional, default provider-specific) + - MessageFormat: 'Text' | 'Html' (optional, default 'Text') + When set to 'Html', messages are treated as HTML markup and passed through without modification. + When set to 'Text', messages are treated as plain text. + Providers may normalize HTML to ensure stable idempotency (e.g., handling server-side wrapping). Authentication: - If With.AuthSessionName is present, the step acquires an auth session via @@ -48,6 +52,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { InternalMessage = 'I am out of office.' ExternalMessage = 'I am currently unavailable.' ExternalAudience = 'All' + MessageFormat = 'Text' } } } @@ -101,6 +106,24 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } } + .EXAMPLE + # In workflow definition (HTML formatted message): + @{ + Name = 'Enable Out of Office with HTML' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + InternalMessage = 'I am out of office.
For urgent matters, contact my manager.
' + ExternalMessage = 'I am currently unavailable.
Please contact our Service Desk at servicedesk@contoso.com.
' + ExternalAudience = 'All' + } + } + } + .EXAMPLE # Template usage with dynamic manager attributes (Leaver scenario): # Note: Templates are resolved during planning against the request object. @@ -190,6 +213,14 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } } + # Validate MessageFormat if provided + if ($config.ContainsKey('MessageFormat')) { + $validFormats = @('Text', 'Html') + if ($config.MessageFormat -notin $validFormats) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config.MessageFormat to be one of: $($validFormats -join ', '). Got: $($config.MessageFormat)" + } + } + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index 2b2ffd1a..3d2c828f 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -392,5 +392,119 @@ Describe 'ExchangeOnline provider - Unit tests' { $oofConfig = $provider.GetOutOfOffice('user11@contoso.com', $null) $oofConfig.ExternalAudience | Should -Be 'All' } + + It 'handles HTML normalization for stable idempotency' { + Add-TestMailbox -PrimarySmtpAddress 'user12@contoso.com' + + # Set initial OOF with plain HTML message + $initialConfig = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office
' + ExternalMessage = 'Currently unavailable' + } + $provider.EnsureOutOfOffice('user12@contoso.com', $initialConfig, $null) | Out-Null + + # Simulate Exchange wrapping the message in HTML/body tags + $wrappedMessage = "`r`nOut of office
`r`n" + $fakeAdapter.Store.AutoReply['user12@contoso.com']['InternalMessage'] = $wrappedMessage + + # Re-run with same logical message (should be idempotent) + $result = $provider.EnsureOutOfOffice('user12@contoso.com', $initialConfig, $null) + + # Should detect no change despite server-side wrapping + $result.Changed | Should -Be $false + } + + It 'handles line ending normalization for stable idempotency' { + Add-TestMailbox -PrimarySmtpAddress 'user13@contoso.com' + + # Set initial OOF with LF line endings + $messageWithLF = "Line 1`nLine 2`nLine 3" + $initialConfig = @{ + Mode = 'Enabled' + InternalMessage = $messageWithLF + } + $provider.EnsureOutOfOffice('user13@contoso.com', $initialConfig, $null) | Out-Null + + # Simulate Exchange returning CRLF line endings + $messageWithCRLF = "Line 1`r`nLine 2`r`nLine 3" + $fakeAdapter.Store.AutoReply['user13@contoso.com']['InternalMessage'] = $messageWithCRLF + + # Re-run with LF message (should be idempotent) + $result = $provider.EnsureOutOfOffice('user13@contoso.com', $initialConfig, $null) + + # Should detect no change despite line ending differences + $result.Changed | Should -Be $false + } + } + + Context 'Normalize-IdleExchangeOnlineAutoReplyMessage' { + BeforeAll { + # Import the private normalization function for direct testing + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $normalizeFunctionPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\Private\Normalize-IdleExchangeOnlineAutoReplyMessage.ps1' + + if (-not (Test-Path -LiteralPath $normalizeFunctionPath -PathType Leaf)) { + throw "Normalize-IdleExchangeOnlineAutoReplyMessage script not found at: $normalizeFunctionPath" + } + + # Dot-source the private function + . $normalizeFunctionPath + } + + It 'removes HTML wrappers' { + $input = 'Test message
' + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $normalized | Should -Be 'Test message
' + } + + It 'normalizes CRLF to LF' { + $input = "Line 1`r`nLine 2`r`nLine 3" + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $normalized | Should -Be "Line 1`nLine 2`nLine 3" + } + + It 'trims leading and trailing whitespace' { + $input = "Test message
`n`n" + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $normalized | Should -Be 'Test message
' + } + + It 'normalizes excessive spaces conservatively' { + $input = 'Test message here
' + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + # 3+ spaces become 2 spaces (conservative normalization) + $normalized | Should -Be 'Test message here
' + } + + It 'handles empty string input' { + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message '' + + $normalized | Should -Be '' + } + + It 'handles null input' { + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $null + + $normalized | Should -Be '' + } + + It 'removes DOCTYPE declarations' { + $input = 'Test
' + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $normalized | Should -Be 'Test
' + } + + It 'preserves intentional HTML formatting' { + $input = 'This is important and contact us.
' + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $normalized | Should -Be 'This is important and contact us.
' + } } } diff --git a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 index 1b149bcf..e2a4cdae 100644 --- a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -214,4 +214,33 @@ Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" } + + It 'accepts MessageFormat = Text' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'Text' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'accepts MessageFormat = Html' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'Html' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'throws when MessageFormat is invalid' { + $step = $script:StepTemplate + $step.With.Config.MessageFormat = 'InvalidFormat' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*MessageFormat to be one of: Text, Html*" + } }