From f34e97ffa8508dc5f16ccec45e56d173a19dd6f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:45:47 +0000 Subject: [PATCH 1/9] Initial plan From 3be3eabf5362ee77e84777d11ac711d6487fffb1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:50:08 +0000 Subject: [PATCH 2/9] Add MessageFormat support and HTML normalization for OOF messages Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- ...ize-IdleExchangeOnlineAutoReplyMessage.ps1 | 88 ++++++++++++++ .../Public/New-IdleExchangeOnlineProvider.ps1 | 34 ++++-- ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 31 +++++ .../ExchangeOnlineProvider.Tests.ps1 | 113 ++++++++++++++++++ ...IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 | 29 +++++ 5 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 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..d69db763 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -0,0 +1,88 @@ +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 ... sections entirely + $normalized = $normalized -replace '(?is)]*>.*?', '' + + # 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 multiple consecutive whitespace characters (spaces, tabs, newlines) to single space + # This handles cases where Exchange might add extra whitespace + # Only apply inside HTML tags (between > and <) to avoid breaking intentional formatting + # For simplicity, normalize all sequences of 2+ spaces/tabs to single space + $normalized = $normalized -replace '[ \t]{2,}', ' ' + + # 5. Remove empty lines (multiple consecutive newlines) + $normalized = $normalized -replace '\n{3,}', "`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..5044b58f 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -347,21 +347,41 @@ 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 $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 (-not $changed -and $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 (-not $changed -and $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 (-not $changed -and $Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { $changed = $true } - elseif ($mode -eq 'Scheduled') { + + # Check scheduled mode dates + if (-not $changed -and $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..969fc871 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -392,5 +392,118 @@ 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`n

Out 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 multiple spaces to single space' { + $input = '

Test message here

' + $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input + + $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*" + } } From 4676fed8a0195878b3d94f782d436e7d4cb1f8a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:51:44 +0000 Subject: [PATCH 3/9] Update documentation with HTML OOF message examples Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../providers/provider-exchangeonline.md | 70 +++++++++++++++++++ .../step-mailbox-ensure-out-of-office.md | 5 ++ .../exo-leaver-mailbox-offboarding.psd1 | 17 ++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 6a91ae67..f6a4041c 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -295,6 +295,76 @@ $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:

+ +'@ + ExternalMessage = @' +

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' + } + } +} +``` + --- ## Limitations and known issues diff --git a/docs/reference/steps/step-mailbox-ensure-out-of-office.md b/docs/reference/steps/step-mailbox-ensure-out-of-office.md index 0d381576..a8315cfc 100644 --- a/docs/reference/steps/step-mailbox-ensure-out-of-office.md +++ b/docs/reference/steps/step-mailbox-ensure-out-of-office.md @@ -37,6 +37,11 @@ Out of Office Config shape (data-only hashtable): - 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 diff --git a/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 index f0f90b77..b778df36 100644 --- a/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 +++ b/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 @@ -1,7 +1,7 @@ @{ Name = 'ExchangeOnline Leaver - Mailbox Offboarding' LifecycleEvent = 'Leaver' - Description = 'Converts mailbox to shared, enables Out of Office with dynamic manager contact info, and optionally delegates access for offboarding users.' + Description = 'Converts mailbox to shared, enables Out of Office with HTML-formatted dynamic manager contact info, and optionally delegates access for offboarding users.' Steps = @( @{ Name = 'GetMailboxInfo' @@ -28,8 +28,19 @@ IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } Config = @{ Mode = 'Enabled' - InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' - ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' + MessageFormat = 'Html' + InternalMessage = @' +

This mailbox is no longer monitored.

+

For urgent matters, please contact:

+ +'@ + ExternalMessage = @' +

This mailbox is no longer monitored.

+

Please contact our Service Desk at servicedesk@contoso.com.

+'@ ExternalAudience = 'All' } } From 03087f728bd8714da7961574eb6375bba6bad0a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:53:38 +0000 Subject: [PATCH 4/9] Address code review feedback on normalization comments Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 index d69db763..cbe06207 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -72,13 +72,15 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { # 3. Trim leading/trailing whitespace (including newlines) $normalized = $normalized.Trim() - # 4. Normalize multiple consecutive whitespace characters (spaces, tabs, newlines) to single space + # 4. Normalize multiple consecutive whitespace characters (spaces, tabs) to single space # This handles cases where Exchange might add extra whitespace - # Only apply inside HTML tags (between > and <) to avoid breaking intentional formatting - # For simplicity, normalize all sequences of 2+ spaces/tabs to single space + # NOTE: This may affect intentional spacing in preformatted HTML elements (
, ).
+    # For typical OOF messages (paragraphs, links, lists), this is acceptable.
+    # Normalize all sequences of 2+ spaces/tabs to single space
     $normalized = $normalized -replace '[ \t]{2,}', ' '
 
-    # 5. Remove empty lines (multiple consecutive newlines)
+    # 5. Normalize excessive empty lines (3+ consecutive newlines to 2)
+    # Preserves intentional double line breaks while removing excessive spacing
     $normalized = $normalized -replace '\n{3,}', "`n`n"
 
     # 6. Final trim to remove any whitespace introduced by previous operations

From 01674108c9fbb177070e8f3eb07481e76b51bb9a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 10 Feb 2026 17:40:17 +0000
Subject: [PATCH 5/9] Add FromFile pattern for loading HTML messages from
 external files

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
---
 .../providers/provider-exchangeonline.md      | 41 ++++++++
 ...ailbox-offboarding-external-templates.psd1 | 48 +++++++++
 .../templates/oof-leaver-external.html        |  3 +
 .../templates/oof-leaver-internal.html        |  7 ++
 .../Private/Resolve-IdleWorkflowTemplates.ps1 | 55 ++++++++++-
 .../Resolve-IdleWorkflowTemplates.Tests.ps1   | 99 +++++++++++++++++++
 tests/fixtures/templates/oof-external.html    |  2 +
 tests/fixtures/templates/oof-internal.html    |  6 ++
 8 files changed, 260 insertions(+), 1 deletion(-)
 create mode 100644 examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1
 create mode 100644 examples/workflows/templates/templates/oof-leaver-external.html
 create mode 100644 examples/workflows/templates/templates/oof-leaver-internal.html
 create mode 100644 tests/fixtures/templates/oof-external.html
 create mode 100644 tests/fixtures/templates/oof-internal.html

diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md
index f6a4041c..edbce162 100644
--- a/docs/reference/providers/provider-exchangeonline.md
+++ b/docs/reference/providers/provider-exchangeonline.md
@@ -365,6 +365,47 @@ IdLE provides stable idempotency for HTML messages by normalizing server-side ca
 }
 ```
 
+**Loading messages from external files:**
+
+For long or complex HTML messages, you can load content from external files using the `@{ FromFile = 'path' }` pattern:
+
+```powershell
+@{
+  Name = 'Set OOF with external templates'
+  Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice'
+  With = @{
+    Provider    = 'ExchangeOnline'
+    IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' }
+    Config      = @{
+      Mode            = 'Enabled'
+      MessageFormat   = 'Html'
+      InternalMessage = @{ FromFile = './templates/oof-internal.html' }
+      ExternalMessage = @{ FromFile = './templates/oof-external.html' }
+      ExternalAudience = 'All'
+    }
+  }
+}
+```
+
+**Template file example** (`./templates/oof-internal.html`):
+
+```html
+

This mailbox is no longer monitored.

+

For urgent matters, please contact:

+ +``` + +**Notes on file loading:** + +- File paths can be absolute or relative (relative paths resolve from current working directory) +- Template placeholders (`{{...}}`) work in both the file path and file content +- Files must exist at planning time (when `New-IdlePlan` is called) +- File content is loaded as UTF-8 +- This keeps workflows clean and allows reuse of message templates across multiple workflows + --- ## Limitations and known issues diff --git a/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 b/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 new file mode 100644 index 00000000..412b683e --- /dev/null +++ b/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 @@ -0,0 +1,48 @@ +@{ + Name = 'ExchangeOnline Leaver - Mailbox Offboarding (With External Templates)' + LifecycleEvent = 'Leaver' + Description = 'Converts mailbox to shared, enables Out of Office using HTML templates loaded from external files.' + Steps = @( + @{ + Name = 'GetMailboxInfo' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + } + } + @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.EnsureType' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + MailboxType = 'Shared' + } + } + @{ + Name = 'EnableOutOfOfficeWithExternalTemplates' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + MessageFormat = 'Html' + # Load HTML templates from external files + # Template files can contain {{...}} placeholders that will be resolved + InternalMessage = @{ FromFile = './templates/oof-leaver-internal.html' } + ExternalMessage = @{ FromFile = './templates/oof-leaver-external.html' } + ExternalAudience = 'All' + } + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Mailbox offboarding completed with external OOF templates.' + } + } + ) +} diff --git a/examples/workflows/templates/templates/oof-leaver-external.html b/examples/workflows/templates/templates/oof-leaver-external.html new file mode 100644 index 00000000..fc8a243e --- /dev/null +++ b/examples/workflows/templates/templates/oof-leaver-external.html @@ -0,0 +1,3 @@ +

This mailbox is no longer monitored.

+

Please contact our Service Desk at servicedesk@contoso.com for assistance.

+

Thank you.

diff --git a/examples/workflows/templates/templates/oof-leaver-internal.html b/examples/workflows/templates/templates/oof-leaver-internal.html new file mode 100644 index 00000000..04978777 --- /dev/null +++ b/examples/workflows/templates/templates/oof-leaver-internal.html @@ -0,0 +1,7 @@ +

This mailbox is no longer monitored.

+

For urgent matters, please contact:

+ +

Thank you for your understanding.

diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index cbf391bd..570a46b2 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -11,6 +11,14 @@ function Resolve-IdleWorkflowTemplates { - Steps[*].With (including nested structures) - OnFailureSteps[*].With (including nested structures) + Special patterns: + - @{ FromFile = 'path/to/file.txt' }: Loads file content as a string + * Supports template placeholders in the file path + * Supports template placeholders within the file content + * Relative paths are resolved from the current working directory + * File must exist at planning time + * File content is loaded as UTF-8 + .PARAMETER Value The value to process (hashtable, array, string, or scalar). @@ -58,8 +66,53 @@ function Resolve-IdleWorkflowTemplates { return $Value } - # Hashtables/dictionaries: recurse on values + # Hashtables/dictionaries: check for special patterns first if ($Value -is [System.Collections.IDictionary]) { + # Special pattern: @{ FromFile = 'path/to/file' } + # Load file content and return as string + if ($Value.Count -eq 1 -and $Value.ContainsKey('FromFile')) { + $filePath = $Value['FromFile'] + + if ($null -eq $filePath -or [string]::IsNullOrWhiteSpace($filePath)) { + throw [System.ArgumentException]::new( + ("FromFile error in step '{0}': File path cannot be null or empty." -f $StepName), + 'Workflow' + ) + } + + # Resolve template placeholders in the file path (e.g., @{ FromFile = '{{Request.DesiredState.TemplatePath}}' }) + $resolvedPath = Resolve-IdleTemplateString -Value ([string]$filePath) -Request $Request -StepName $StepName + + # Convert to absolute path if relative + if (-not [System.IO.Path]::IsPathRooted($resolvedPath)) { + # Relative paths are resolved from the current working directory + $resolvedPath = Join-Path -Path (Get-Location).Path -ChildPath $resolvedPath + } + + # Validate file exists + if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { + throw [System.ArgumentException]::new( + ("FromFile error in step '{0}': File not found at path '{1}'." -f $StepName, $resolvedPath), + 'Workflow' + ) + } + + # Load file content as UTF-8 string + try { + $fileContent = Get-Content -LiteralPath $resolvedPath -Raw -Encoding UTF8 -ErrorAction Stop + + # Resolve any template placeholders within the loaded file content + return Resolve-IdleTemplateString -Value $fileContent -Request $Request -StepName $StepName + } + catch { + throw [System.ArgumentException]::new( + ("FromFile error in step '{0}': Failed to read file '{1}'. {2}" -f $StepName, $resolvedPath, $_.Exception.Message), + 'Workflow' + ) + } + } + + # General hashtable: recurse on values $resolved = @{} foreach ($key in $Value.Keys) { $resolved[$key] = Resolve-IdleWorkflowTemplates -Value $Value[$key] -Request $Request -StepName $StepName diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 67e444a2..fe44222b 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -536,3 +536,102 @@ Describe 'Template Substitution' { # through direct unit testing due to test harness limitations. The security checks # are applied regardless of pure/mixed template mode as verified by manual testing. } + +Describe 'FromFile template pattern' { + Context 'Loading external files' { + It 'loads file content from absolute path' { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $testFilePath = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates/oof-external.html' + + # Import the private functions directly for testing + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1') + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1') + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + Manager = @{ + DisplayName = 'Jane Manager' + Mail = 'jmanager@contoso.com' + } + } + + $value = @{ FromFile = $testFilePath } + $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' + + $result | Should -BeOfType [string] + $result | Should -BeLike '*This mailbox is no longer monitored*' + $result | Should -BeLike '*Service Desk*' + } + + It 'resolves template placeholders within loaded file content' { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $testFilePath = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates/oof-internal.html' + + # Import the private functions directly for testing + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1') + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1') + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + Manager = @{ + DisplayName = 'Jane Manager' + Mail = 'jmanager@contoso.com' + } + } + + $value = @{ FromFile = $testFilePath } + $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' + + $result | Should -BeLike '*Jane Manager*' + $result | Should -BeLike '*jmanager@contoso.com*' + } + + It 'throws when file does not exist' { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + + # Import the private functions directly for testing + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1') + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1') + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{} + + $value = @{ FromFile = '/nonexistent/path/to/file.html' } + + { Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' } | + Should -Throw -ExpectedMessage '*File not found*' + } + + It 'throws when FromFile value is null or empty' { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + + # Import the private functions directly for testing + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1') + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1') + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{} + + $value = @{ FromFile = '' } + + { Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' } | + Should -Throw -ExpectedMessage '*File path cannot be null or empty*' + } + + It 'resolves template placeholders in file path' { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + + # Import the private functions directly for testing + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1') + . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1') + + $templatesDir = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates' + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + TemplateFile = 'oof-external.html' + TemplatesDir = $templatesDir + } + + $value = @{ FromFile = '{{Request.DesiredState.TemplatesDir}}/{{Request.DesiredState.TemplateFile}}' } + $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' + + $result | Should -BeLike '*This mailbox is no longer monitored*' + } + } +} diff --git a/tests/fixtures/templates/oof-external.html b/tests/fixtures/templates/oof-external.html new file mode 100644 index 00000000..2eb87609 --- /dev/null +++ b/tests/fixtures/templates/oof-external.html @@ -0,0 +1,2 @@ +

This mailbox is no longer monitored.

+

Please contact our Service Desk at servicedesk@contoso.com.

diff --git a/tests/fixtures/templates/oof-internal.html b/tests/fixtures/templates/oof-internal.html new file mode 100644 index 00000000..6a3d9186 --- /dev/null +++ b/tests/fixtures/templates/oof-internal.html @@ -0,0 +1,6 @@ +

This mailbox is no longer monitored.

+

For urgent matters, please contact:

+ From 8d4bd39e09c1cbe2c1a49ca7acd1ce02d412dde7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:48:46 +0000 Subject: [PATCH 6/9] Remove global FromFile pattern to fix backward compatibility issue Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../providers/provider-exchangeonline.md | 37 ++++--- ...ailbox-offboarding-external-templates.psd1 | 48 --------- .../templates/oof-leaver-external.html | 3 - .../templates/oof-leaver-internal.html | 7 -- .../Private/Resolve-IdleWorkflowTemplates.ps1 | 55 +---------- ...ize-IdleExchangeOnlineAutoReplyMessage.ps1 | 5 +- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 99 ------------------- tests/fixtures/templates/oof-external.html | 2 - tests/fixtures/templates/oof-internal.html | 6 -- 9 files changed, 30 insertions(+), 232 deletions(-) delete mode 100644 examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 delete mode 100644 examples/workflows/templates/templates/oof-leaver-external.html delete mode 100644 examples/workflows/templates/templates/oof-leaver-internal.html delete mode 100644 tests/fixtures/templates/oof-external.html delete mode 100644 tests/fixtures/templates/oof-internal.html diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index edbce162..5f78f26f 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -365,13 +365,32 @@ IdLE provides stable idempotency for HTML messages by normalizing server-side ca } ``` -**Loading messages from external files:** +**Loading messages from external files (host-side approach):** -For long or complex HTML messages, you can load content from external files using the `@{ FromFile = 'path' }` pattern: +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 external templates' + Name = 'Set OOF with templates' Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' @@ -379,8 +398,8 @@ For long or complex HTML messages, you can load content from external files usin Config = @{ Mode = 'Enabled' MessageFormat = 'Html' - InternalMessage = @{ FromFile = './templates/oof-internal.html' } - ExternalMessage = @{ FromFile = './templates/oof-external.html' } + InternalMessage = '{{Request.DesiredState.InternalOOFMessage}}' + ExternalMessage = '{{Request.DesiredState.ExternalOOFMessage}}' ExternalAudience = 'All' } } @@ -398,13 +417,7 @@ For long or complex HTML messages, you can load content from external files usin ``` -**Notes on file loading:** - -- File paths can be absolute or relative (relative paths resolve from current working directory) -- Template placeholders (`{{...}}`) work in both the file path and file content -- Files must exist at planning time (when `New-IdlePlan` is called) -- File content is loaded as UTF-8 -- This keeps workflows clean and allows reuse of message templates across multiple workflows +This approach keeps workflow definitions clean, allows template reuse, and maintains the data-only principle by loading files at the host level before planning. --- diff --git a/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 b/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 deleted file mode 100644 index 412b683e..00000000 --- a/examples/workflows/templates/exo-leaver-mailbox-offboarding-external-templates.psd1 +++ /dev/null @@ -1,48 +0,0 @@ -@{ - Name = 'ExchangeOnline Leaver - Mailbox Offboarding (With External Templates)' - LifecycleEvent = 'Leaver' - Description = 'Converts mailbox to shared, enables Out of Office using HTML templates loaded from external files.' - Steps = @( - @{ - Name = 'GetMailboxInfo' - Type = 'IdLE.Step.Mailbox.GetInfo' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } - } - } - @{ - Name = 'ConvertToSharedMailbox' - Type = 'IdLE.Step.Mailbox.EnsureType' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } - MailboxType = 'Shared' - } - } - @{ - Name = 'EnableOutOfOfficeWithExternalTemplates' - Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } - Config = @{ - Mode = 'Enabled' - MessageFormat = 'Html' - # Load HTML templates from external files - # Template files can contain {{...}} placeholders that will be resolved - InternalMessage = @{ FromFile = './templates/oof-leaver-internal.html' } - ExternalMessage = @{ FromFile = './templates/oof-leaver-external.html' } - ExternalAudience = 'All' - } - } - } - @{ - Name = 'EmitCompletionEvent' - Type = 'IdLE.Step.EmitEvent' - With = @{ - Message = 'Mailbox offboarding completed with external OOF templates.' - } - } - ) -} diff --git a/examples/workflows/templates/templates/oof-leaver-external.html b/examples/workflows/templates/templates/oof-leaver-external.html deleted file mode 100644 index fc8a243e..00000000 --- a/examples/workflows/templates/templates/oof-leaver-external.html +++ /dev/null @@ -1,3 +0,0 @@ -

This mailbox is no longer monitored.

-

Please contact our Service Desk at servicedesk@contoso.com for assistance.

-

Thank you.

diff --git a/examples/workflows/templates/templates/oof-leaver-internal.html b/examples/workflows/templates/templates/oof-leaver-internal.html deleted file mode 100644 index 04978777..00000000 --- a/examples/workflows/templates/templates/oof-leaver-internal.html +++ /dev/null @@ -1,7 +0,0 @@ -

This mailbox is no longer monitored.

-

For urgent matters, please contact:

- -

Thank you for your understanding.

diff --git a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 index 570a46b2..cbf391bd 100644 --- a/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1 @@ -11,14 +11,6 @@ function Resolve-IdleWorkflowTemplates { - Steps[*].With (including nested structures) - OnFailureSteps[*].With (including nested structures) - Special patterns: - - @{ FromFile = 'path/to/file.txt' }: Loads file content as a string - * Supports template placeholders in the file path - * Supports template placeholders within the file content - * Relative paths are resolved from the current working directory - * File must exist at planning time - * File content is loaded as UTF-8 - .PARAMETER Value The value to process (hashtable, array, string, or scalar). @@ -66,53 +58,8 @@ function Resolve-IdleWorkflowTemplates { return $Value } - # Hashtables/dictionaries: check for special patterns first + # Hashtables/dictionaries: recurse on values if ($Value -is [System.Collections.IDictionary]) { - # Special pattern: @{ FromFile = 'path/to/file' } - # Load file content and return as string - if ($Value.Count -eq 1 -and $Value.ContainsKey('FromFile')) { - $filePath = $Value['FromFile'] - - if ($null -eq $filePath -or [string]::IsNullOrWhiteSpace($filePath)) { - throw [System.ArgumentException]::new( - ("FromFile error in step '{0}': File path cannot be null or empty." -f $StepName), - 'Workflow' - ) - } - - # Resolve template placeholders in the file path (e.g., @{ FromFile = '{{Request.DesiredState.TemplatePath}}' }) - $resolvedPath = Resolve-IdleTemplateString -Value ([string]$filePath) -Request $Request -StepName $StepName - - # Convert to absolute path if relative - if (-not [System.IO.Path]::IsPathRooted($resolvedPath)) { - # Relative paths are resolved from the current working directory - $resolvedPath = Join-Path -Path (Get-Location).Path -ChildPath $resolvedPath - } - - # Validate file exists - if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) { - throw [System.ArgumentException]::new( - ("FromFile error in step '{0}': File not found at path '{1}'." -f $StepName, $resolvedPath), - 'Workflow' - ) - } - - # Load file content as UTF-8 string - try { - $fileContent = Get-Content -LiteralPath $resolvedPath -Raw -Encoding UTF8 -ErrorAction Stop - - # Resolve any template placeholders within the loaded file content - return Resolve-IdleTemplateString -Value $fileContent -Request $Request -StepName $StepName - } - catch { - throw [System.ArgumentException]::new( - ("FromFile error in step '{0}': Failed to read file '{1}'. {2}" -f $StepName, $resolvedPath, $_.Exception.Message), - 'Workflow' - ) - } - } - - # General hashtable: recurse on values $resolved = @{} foreach ($key in $Value.Keys) { $resolved[$key] = Resolve-IdleWorkflowTemplates -Value $Value[$key] -Request $Request -StepName $StepName diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 index cbe06207..4e3c9df8 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -74,13 +74,16 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { # 4. Normalize multiple consecutive whitespace characters (spaces, tabs) to single space # This handles cases where Exchange might add extra whitespace - # NOTE: This may affect intentional spacing in preformatted HTML elements (
, ).
+    # NOTE: This normalization is ONLY used for idempotency comparison, not for modifying
+    # the actual message sent to Exchange. The original message formatting is preserved.
+    # This may affect intentional spacing in preformatted HTML elements (
, ).
     # For typical OOF messages (paragraphs, links, lists), this is acceptable.
     # Normalize all sequences of 2+ spaces/tabs to single space
     $normalized = $normalized -replace '[ \t]{2,}', ' '
 
     # 5. Normalize excessive empty lines (3+ consecutive newlines to 2)
     # Preserves intentional double line breaks while removing excessive spacing
+    # This normalization is conservative to avoid collapsing distinct message structures
     $normalized = $normalized -replace '\n{3,}', "`n`n"
 
     # 6. Final trim to remove any whitespace introduced by previous operations
diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1
index fe44222b..67e444a2 100644
--- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1
+++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1
@@ -536,102 +536,3 @@ Describe 'Template Substitution' {
     # through direct unit testing due to test harness limitations. The security checks
     # are applied regardless of pure/mixed template mode as verified by manual testing.
 }
-
-Describe 'FromFile template pattern' {
-    Context 'Loading external files' {
-        It 'loads file content from absolute path' {
-            $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
-            $testFilePath = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates/oof-external.html'
-            
-            # Import the private functions directly for testing
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1')
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1')
-            
-            $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{
-                Manager = @{
-                    DisplayName = 'Jane Manager'
-                    Mail        = 'jmanager@contoso.com'
-                }
-            }
-            
-            $value = @{ FromFile = $testFilePath }
-            $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep'
-            
-            $result | Should -BeOfType [string]
-            $result | Should -BeLike '*This mailbox is no longer monitored*'
-            $result | Should -BeLike '*Service Desk*'
-        }
-        
-        It 'resolves template placeholders within loaded file content' {
-            $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
-            $testFilePath = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates/oof-internal.html'
-            
-            # Import the private functions directly for testing
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1')
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1')
-            
-            $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{
-                Manager = @{
-                    DisplayName = 'Jane Manager'
-                    Mail        = 'jmanager@contoso.com'
-                }
-            }
-            
-            $value = @{ FromFile = $testFilePath }
-            $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep'
-            
-            $result | Should -BeLike '*Jane Manager*'
-            $result | Should -BeLike '*jmanager@contoso.com*'
-        }
-        
-        It 'throws when file does not exist' {
-            $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
-            
-            # Import the private functions directly for testing
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1')
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1')
-            
-            $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{}
-            
-            $value = @{ FromFile = '/nonexistent/path/to/file.html' }
-            
-            { Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' } |
-                Should -Throw -ExpectedMessage '*File not found*'
-        }
-        
-        It 'throws when FromFile value is null or empty' {
-            $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
-            
-            # Import the private functions directly for testing
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1')
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1')
-            
-            $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{}
-            
-            $value = @{ FromFile = '' }
-            
-            { Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep' } |
-                Should -Throw -ExpectedMessage '*File path cannot be null or empty*'
-        }
-        
-        It 'resolves template placeholders in file path' {
-            $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
-            
-            # Import the private functions directly for testing
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1')
-            . (Join-Path -Path $repoRoot -ChildPath 'src/IdLE.Core/Private/Resolve-IdleWorkflowTemplates.ps1')
-            
-            $templatesDir = Join-Path -Path $repoRoot -ChildPath 'tests/fixtures/templates'
-            
-            $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{
-                TemplateFile = 'oof-external.html'
-                TemplatesDir = $templatesDir
-            }
-            
-            $value = @{ FromFile = '{{Request.DesiredState.TemplatesDir}}/{{Request.DesiredState.TemplateFile}}' }
-            $result = Resolve-IdleWorkflowTemplates -Value $value -Request $req -StepName 'TestStep'
-            
-            $result | Should -BeLike '*This mailbox is no longer monitored*'
-        }
-    }
-}
diff --git a/tests/fixtures/templates/oof-external.html b/tests/fixtures/templates/oof-external.html
deleted file mode 100644
index 2eb87609..00000000
--- a/tests/fixtures/templates/oof-external.html
+++ /dev/null
@@ -1,2 +0,0 @@
-

This mailbox is no longer monitored.

-

Please contact our Service Desk at servicedesk@contoso.com.

diff --git a/tests/fixtures/templates/oof-internal.html b/tests/fixtures/templates/oof-internal.html deleted file mode 100644 index 6a3d9186..00000000 --- a/tests/fixtures/templates/oof-internal.html +++ /dev/null @@ -1,6 +0,0 @@ -

This mailbox is no longer monitored.

-

For urgent matters, please contact:

- From f5beacba997ade925e855d462ec9a952b0620b14 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:57:07 +0100 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 index 4e3c9df8..5a9af560 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -62,8 +62,8 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { $normalized = $normalized -replace '(?i)]*>', '' $normalized = $normalized -replace '(?i)', '' - # Remove ... sections entirely - $normalized = $normalized -replace '(?is)]*>.*?', '' + # 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)]*>', '' From dfc3e3e6029ff95c141cc477e845a10ede8fe508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:00:22 +0000 Subject: [PATCH 8/9] Remove short-circuit checks to make idempotency checks truly independent Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleExchangeOnlineProvider.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 5044b58f..875707fd 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -348,6 +348,7 @@ function New-IdleExchangeOnlineProvider { $currentConfig = $this.GetOutOfOffice($mailbox.PrimarySmtpAddress, $AuthSession) # Idempotency check with message normalization for stable comparison + # Check all fields independently to detect any configuration drift $changed = $false # Check mode @@ -356,7 +357,7 @@ function New-IdleExchangeOnlineProvider { } # Check internal message with normalization - if (-not $changed -and $Config.ContainsKey('InternalMessage')) { + if ($Config.ContainsKey('InternalMessage')) { # Use normalization to handle server-side HTML canonicalization $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage'] @@ -366,7 +367,7 @@ function New-IdleExchangeOnlineProvider { } # Check external message with normalization - if (-not $changed -and $Config.ContainsKey('ExternalMessage')) { + if ($Config.ContainsKey('ExternalMessage')) { # Use normalization to handle server-side HTML canonicalization $normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage $normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage'] @@ -376,12 +377,12 @@ function New-IdleExchangeOnlineProvider { } # Check external audience - if (-not $changed -and $Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { + if ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { $changed = $true } # Check scheduled mode dates - if (-not $changed -and $mode -eq 'Scheduled') { + if ($mode -eq 'Scheduled') { # Compare dates (allow small tolerance for serialization differences) # Tolerance: 60 seconds to account for rounding during serialization/deserialization $dateComparisonToleranceSeconds = 60 From 02681aa47301272d0375c2e3e5a137147553b776 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:15:51 +0000 Subject: [PATCH 9/9] Make whitespace normalization more conservative to avoid false negatives Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- ...ize-IdleExchangeOnlineAutoReplyMessage.ps1 | 23 ++++++++++--------- .../ExchangeOnlineProvider.Tests.ps1 | 5 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 index 5a9af560..43536e1b 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/Normalize-IdleExchangeOnlineAutoReplyMessage.ps1 @@ -72,19 +72,20 @@ function Normalize-IdleExchangeOnlineAutoReplyMessage { # 3. Trim leading/trailing whitespace (including newlines) $normalized = $normalized.Trim() - # 4. Normalize multiple consecutive whitespace characters (spaces, tabs) to single space - # This handles cases where Exchange might add extra whitespace + # 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. - # This may affect intentional spacing in preformatted HTML elements (
, ).
-    # For typical OOF messages (paragraphs, links, lists), this is acceptable.
-    # Normalize all sequences of 2+ spaces/tabs to single space
-    $normalized = $normalized -replace '[ \t]{2,}', ' '
-
-    # 5. Normalize excessive empty lines (3+ consecutive newlines to 2)
-    # Preserves intentional double line breaks while removing excessive spacing
-    # This normalization is conservative to avoid collapsing distinct message structures
-    $normalized = $normalized -replace '\n{3,}', "`n`n"
+    
+    # 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()
diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1
index 969fc871..3d2c828f 100644
--- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1
+++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1
@@ -473,11 +473,12 @@ Describe 'ExchangeOnline provider - Unit tests' {
             $normalized | Should -Be '

Test message

' } - It 'normalizes multiple spaces to single space' { + It 'normalizes excessive spaces conservatively' { $input = '

Test message here

' $normalized = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $input - $normalized | Should -Be '

Test message here

' + # 3+ spaces become 2 spaces (conservative normalization) + $normalized | Should -Be '

Test message here

' } It 'handles empty string input' {