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`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 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:
+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:
+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:
+
+ - Manager: {{Request.DesiredState.Manager.DisplayName}}
+ - Service Desk: Service Desk
+
+```
+
+**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:
+
+ - Manager: {{Request.DesiredState.Manager.DisplayName}}
+ - Service Desk: Service Desk
+
+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:
+
+ - Manager: {{Request.DesiredState.Manager.DisplayName}}
+ - Service Desk: Service Desk
+
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:
-
- - Manager: {{Request.DesiredState.Manager.DisplayName}}
- - Service Desk: Service Desk
-
-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:
-
- - Manager: {{Request.DesiredState.Manager.DisplayName}}
- - Service Desk: Service Desk
-
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' {