Skip to content
Merged
124 changes: 124 additions & 0 deletions docs/reference/providers/provider-exchangeonline.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,130 @@ $req = New-IdleLifecycleRequest `
}
```

### HTML formatted Out of Office messages

Exchange Online supports formatted automatic reply messages with HTML markup (bold, links, lists, line breaks).
IdLE provides stable idempotency for HTML messages by normalizing server-side canonicalization.

**Example with HTML formatted messages:**

```powershell
@{
Name = 'Set formatted OOF for Leaver'
Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice'
With = @{
Provider = 'ExchangeOnline'
IdentityKey = 'user@contoso.com'
Config = @{
Mode = 'Enabled'
MessageFormat = 'Html'
InternalMessage = @'
<p>This mailbox is no longer monitored.</p>
<p>For urgent matters, please contact:</p>
<ul>
<li><strong>Manager:</strong> <a href="mailto:manager@contoso.com">Jane Manager</a></li>
<li><strong>Service Desk:</strong> <a href="mailto:servicedesk@contoso.com">servicedesk@contoso.com</a></li>
</ul>
'@
ExternalMessage = @'
<p>This mailbox is no longer monitored.</p>
<p>Please contact our <strong>Service Desk</strong> at <a href="mailto:servicedesk@contoso.com">servicedesk@contoso.com</a>.</p>
'@
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 (`<html>`, `<head>`, `<body>`)
- 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 = @'
<p>This mailbox is no longer monitored.</p>
<p>For urgent matters, please contact <a href="mailto:{{Request.DesiredState.Manager.Mail}}">{{Request.DesiredState.Manager.DisplayName}}</a>.</p>
'@
ExternalMessage = @'
<p>This mailbox is no longer monitored.</p>
<p>Please contact our <strong>Service Desk</strong> at <a href="mailto:servicedesk@contoso.com">servicedesk@contoso.com</a>.</p>
'@
ExternalAudience = 'All'
}
}
}
```

**Loading messages from external files (host-side approach):**

For long or complex HTML messages, you can load content from external files in your host script before creating the plan:

```powershell
# Host script - load templates before planning
$internalMessageTemplate = Get-Content -Path './templates/oof-internal.html' -Raw -Encoding UTF8
$externalMessageTemplate = Get-Content -Path './templates/oof-external.html' -Raw -Encoding UTF8

# Build request with template content
$req = New-IdleLifecycleRequest `
-LifecycleEvent 'Leaver' `
-Actor $env:USERNAME `
-Input @{ UserPrincipalName = 'user@contoso.com' } `
-DesiredState @{
InternalOOFMessage = $internalMessageTemplate
ExternalOOFMessage = $externalMessageTemplate
Manager = @{
DisplayName = 'Jane Manager'
Mail = 'jmanager@contoso.com'
}
}

# Workflow definition references the loaded content
@{
Name = 'Set OOF with templates'
Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice'
With = @{
Provider = 'ExchangeOnline'
IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' }
Config = @{
Mode = 'Enabled'
MessageFormat = 'Html'
InternalMessage = '{{Request.DesiredState.InternalOOFMessage}}'
ExternalMessage = '{{Request.DesiredState.ExternalOOFMessage}}'
ExternalAudience = 'All'
}
}
}
```

**Template file example** (`./templates/oof-internal.html`):

```html
<p>This mailbox is no longer monitored.</p>
<p>For urgent matters, please contact:</p>
<ul>
<li><strong>Manager:</strong> <a href="mailto:{{Request.DesiredState.Manager.Mail}}">{{Request.DesiredState.Manager.DisplayName}}</a></li>
<li><strong>Service Desk:</strong> <a href="mailto:servicedesk@contoso.com">Service Desk</a></li>
</ul>
```

This approach keeps workflow definitions clean, allows template reuse, and maintains the data-only principle by loading files at the host level before planning.

---

## Limitations and known issues
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/steps/step-mailbox-ensure-out-of-office.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# IdLE.Step.Mailbox.EnsureOutOfOffice

> Generated file. Do not edit by hand.
Expand Down Expand Up @@ -37,6 +37,11 @@

- 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
Expand Down
17 changes: 14 additions & 3 deletions examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 = @'
<p>This mailbox is no longer monitored.</p>
<p>For urgent matters, please contact:</p>
<ul>
<li><strong>Manager:</strong> <a href="mailto:{{Request.DesiredState.Manager.Mail}}">{{Request.DesiredState.Manager.DisplayName}}</a></li>
<li><strong>Service Desk:</strong> <a href="mailto:servicedesk@contoso.com">Service Desk</a></li>
</ul>
'@
ExternalMessage = @'
<p>This mailbox is no longer monitored.</p>
<p>Please contact our <strong>Service Desk</strong> at <a href="mailto:servicedesk@contoso.com">servicedesk@contoso.com</a>.</p>
'@
ExternalAudience = 'All'
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
function Normalize-IdleExchangeOnlineAutoReplyMessage {
<#
.SYNOPSIS
Normalizes Exchange Online auto-reply messages for stable idempotency comparison.

.DESCRIPTION
Exchange Online may introduce server-side canonicalization when storing automatic reply messages,
such as adding HTML/body wrappers, normalizing line endings, or adjusting whitespace.

This helper performs minimal, deterministic normalization to ensure that functionally equivalent
messages are recognized as identical during idempotency checks.

Normalization operations:
- Normalize line endings (CRLF to LF)
- Remove common HTML wrappers added by Exchange (<html>, <head>, <body>)
- 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 <!DOCTYPE ...> declarations
$normalized = $normalized -replace '(?i)<!DOCTYPE[^>]*>', ''

# Remove <html> opening and closing tags (with optional attributes)
$normalized = $normalized -replace '(?i)<html[^>]*>', ''
$normalized = $normalized -replace '(?i)</html>', ''

# Remove <head> wrapper tags while preserving their inner content
$normalized = $normalized -replace '(?is)<head[^>]*>\s*(.*?)\s*</head>', '$1'

# Remove <body> opening and closing tags (with optional attributes)
$normalized = $normalized -replace '(?i)<body[^>]*>', ''
$normalized = $normalized -replace '(?i)</body>', ''

# 3. Trim leading/trailing whitespace (including newlines)
$normalized = $normalized.Trim()

# 4. Normalize whitespace conservatively
# Only collapse truly excessive whitespace that Exchange commonly adds
# This is conservative to avoid making intentionally different messages compare equal
# NOTE: This normalization is ONLY used for idempotency comparison, not for modifying
# the actual message sent to Exchange. The original message formatting is preserved.

# Normalize 3+ consecutive spaces/tabs to 2 (preserves intentional double-spacing)
# This handles Exchange adding extra whitespace without collapsing intentional formatting
$normalized = $normalized -replace '[ \t]{3,}', ' '

# 5. Normalize excessive empty lines (4+ consecutive newlines to 3)
# This is very conservative - only removes truly excessive blank lines
# Preserves intentional spacing while handling Exchange-added excessive gaps
$normalized = $normalized -replace '\n{4,}', "`n`n`n"

# 6. Final trim to remove any whitespace introduced by previous operations
$normalized = $normalized.Trim()

return $normalized
}
Original file line number Diff line number Diff line change
Expand Up @@ -347,21 +347,42 @@ function New-IdleExchangeOnlineProvider {
# Get current config for idempotency check
$currentConfig = $this.GetOutOfOffice($mailbox.PrimarySmtpAddress, $AuthSession)

# Simple idempotency check: if mode matches and messages match, skip update
# Idempotency check with message normalization for stable comparison
# Check all fields independently to detect any configuration drift
$changed = $false

# Check mode
if ($currentConfig.Mode -ne $mode) {
$changed = $true
}
elseif ($Config.ContainsKey('InternalMessage') -and $currentConfig.InternalMessage -ne $Config['InternalMessage']) {
$changed = $true

# Check internal message with normalization
if ($Config.ContainsKey('InternalMessage')) {
# Use normalization to handle server-side HTML canonicalization
$normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.InternalMessage
$normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['InternalMessage']
if ($normalizedCurrent -ne $normalizedDesired) {
$changed = $true
}
}
elseif ($Config.ContainsKey('ExternalMessage') -and $currentConfig.ExternalMessage -ne $Config['ExternalMessage']) {
$changed = $true

# Check external message with normalization
if ($Config.ContainsKey('ExternalMessage')) {
# Use normalization to handle server-side HTML canonicalization
$normalizedCurrent = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $currentConfig.ExternalMessage
$normalizedDesired = Normalize-IdleExchangeOnlineAutoReplyMessage -Message $Config['ExternalMessage']
if ($normalizedCurrent -ne $normalizedDesired) {
$changed = $true
}
}
elseif ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) {

# Check external audience
if ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) {
$changed = $true
}
Comment thread
blindzero marked this conversation as resolved.
elseif ($mode -eq 'Scheduled') {

# Check scheduled mode dates
if ($mode -eq 'Scheduled') {
# Compare dates (allow small tolerance for serialization differences)
# Tolerance: 60 seconds to account for rounding during serialization/deserialization
$dateComparisonToleranceSeconds = 60
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +52,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure {
InternalMessage = 'I am out of office.'
ExternalMessage = 'I am currently unavailable.'
ExternalAudience = 'All'
MessageFormat = 'Text'
}
}
}
Expand Down Expand Up @@ -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 = '<p>I am out of office.</p><p>For urgent matters, contact <a href="mailto:manager@contoso.com">my manager</a>.</p>'
ExternalMessage = '<p>I am currently unavailable.</p><p>Please contact our <strong>Service Desk</strong> at servicedesk@contoso.com.</p>'
ExternalAudience = 'All'
}
}
}

.EXAMPLE
# Template usage with dynamic manager attributes (Leaver scenario):
# Note: Templates are resolved during planning against the request object.
Expand Down Expand Up @@ -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')) {
Expand Down
Loading
Loading