<# .SYNOPSIS Synchronizes mail-enabled public folder (MEPF) objects from Exchange Online to an on-premises Exchange organization. .DESCRIPTION Hardened rewrite of Microsoft's Sync-MailPublicFoldersCloudToOnprem.ps1. Exchange Online is authoritative. For every mail-enabled public folder in Exchange Online, a corresponding SyncMailPublicFolder object is created or updated in the on-premises Active Directory so that on-premises senders can resolve the address and route the message to Exchange Online. Differences from the original script: * Matching between cloud and on-premises objects happens BEFORE any write. In the original, a failing Set-MailPublicFolder call removed the folder from the "valid" list, which caused that same folder to be mail-disabled later in the run. That failure mode is structurally impossible here. * The routing domain of every external address is validated against the on-premises accepted domains and the hybrid send connector address spaces. An address that would loop back into the local organization is rejected instead of being written. * The delete phase is guarded by an absolute minimum folder count, a maximum deletion ratio, and a hard stop if any error occurred earlier in the run. * Full ShouldProcess support (-WhatIf / -Confirm) and a -Force switch for unattended execution. * The on-premises inventory is exported before any modification. * Object names are sanitized for Active Directory and, on collision, get a random numeric suffix appended. The base name is shortened before the suffix is added, so the result always stays within the 64 character limit. Existing objects are never renamed into a collision. * An Exchange Online session that already exists in the current PowerShell session is reused instead of triggering a new sign-in. Use -KeepSession to leave the session open afterwards. * Certificate-based authentication is supported for scheduled tasks. * Localized string files were dropped. All messages are inline, which removes the fragile Import-LocalizedData dependency. Must be executed from the Exchange Management Shell on an Exchange server (Exchange 2013 or later). Requires the ExchangeOnlineManagement module v3 or later. .PARAMETER Credential Credentials for Exchange Online. Omit to authenticate interactively. Not usable when conditional access requires an interactive sign-in. .PARAMETER AppId Application (client) ID for certificate-based authentication. .PARAMETER CertificateThumbprint Thumbprint of the authentication certificate in the local certificate store. .PARAMETER Organization Tenant domain for certificate-based authentication, for example contoso.onmicrosoft.com. .PARAMETER CsvSummaryFile Path of the operation summary. Defaults to a timestamped file in the current directory. .PARAMETER BackupFolder Directory for the pre-run inventory export of all on-premises MEPF objects. .PARAMETER SendConnectorName Name or wildcard pattern of the hybrid send connector towards Exchange Online. Used to determine which routing domains are reachable. .PARAMETER MinimumCloudFolderCount Abort before the delete phase if Exchange Online returns fewer folders than this. Protects against a partially failed cloud query wiping the local objects. .PARAMETER MaxDeletePercent Abort the delete phase if more than this percentage of the on-premises MEPF objects would be mail-disabled. .PARAMETER KeepSession Do not disconnect from Exchange Online when the script finishes, so the next run can reuse the session without signing in again. A session that was already open before the script started is always left open. .PARAMETER SkipDeletePhase Create and update only. Never mail-disable anything. .PARAMETER Force Suppress the interactive confirmation before the delete phase. Intended for scheduled execution. Does not disable the safety thresholds. .EXAMPLE .\Sync-MepfCloudToOnprem.ps1 -WhatIf Shows what would be created, updated and removed without changing anything. .EXAMPLE .\Sync-MepfCloudToOnprem.ps1 -SendConnectorName "Outbound to Office 365*" Interactive run against the named hybrid connector. .EXAMPLE .\Sync-MepfCloudToOnprem.ps1 -AppId $appId -CertificateThumbprint $thumb ` -Organization "contoso.onmicrosoft.com" -Force Unattended run for a scheduled task. .NOTES Exit codes: 0 Success, no errors. 1 Completed with errors. See the summary CSV. 2 Aborted by a safety check. Nothing was written. #> #Requires -Version 5.1 [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Interactive')] param ( [Parameter(ParameterSetName = 'Credential', Mandatory = $true)] [ValidateNotNull()] [PSCredential] $Credential, [Parameter(ParameterSetName = 'CertificateAuth', Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $AppId, [Parameter(ParameterSetName = 'CertificateAuth', Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $CertificateThumbprint, [Parameter(ParameterSetName = 'CertificateAuth', Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Organization, [Parameter()] [ValidateNotNullOrEmpty()] [string] $CsvSummaryFile, [Parameter()] [ValidateNotNullOrEmpty()] [string] $BackupFolder = (Join-Path $PWD 'MepfBackup'), [Parameter()] [ValidateNotNullOrEmpty()] [string] $SendConnectorName = 'Outbound to Office 365*', [Parameter()] [ValidateRange(0, 100000)] [int] $MinimumCloudFolderCount = 1, [Parameter()] [ValidateRange(0, 100)] [int] $MaxDeletePercent = 20, [Parameter()] [switch] $KeepSession, [Parameter()] [switch] $SkipDeletePhase, [Parameter()] [switch] $Force ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Script scope state # --------------------------------------------------------------------------- $script:CloudPrefix = 'Cloud' $script:IsConnected = $false $script:SessionWasReused = $false $script:ErrorCount = 0 $script:CreatedCount = 0 $script:UpdatedCount = 0 $script:RemovedCount = 0 $script:SkippedCount = 0 $script:SummaryPath = $null # Characters that are rejected in an alias because they cannot appear in a # valid SMTP proxy address. Deliberately conservative: a folder that trips this # check is skipped and reported rather than written in a broken state. $script:UnsafeAliasPattern = '[^A-Za-z0-9!#\$%&''*+\-/=^_`{|}~.]' # Active Directory limits the cn attribute to 64 characters and rejects a # number of characters outright. Names are sanitized and, on collision, get a # random numeric suffix appended - the same approach Exchange uses when it # encounters a duplicate recipient name. $script:MaxRecipientNameLength = 64 $script:MaxNameAttempts = 5 $script:InvalidNameChars = @(',', '\', '#', '+', '<', '>', ';', '"', '=', '/') # --------------------------------------------------------------------------- # Logging and reporting # --------------------------------------------------------------------------- function Write-Log { param ( [Parameter(Mandatory = $true)][string] $Message, [ValidateSet('Info', 'Warn', 'Error', 'Success')][string] $Level = 'Info' ) $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') switch ($Level) { 'Warn' { Write-Host "[$stamp] WARN $Message" -ForegroundColor Yellow } 'Error' { Write-Host "[$stamp] ERROR $Message" -ForegroundColor Red } 'Success' { Write-Host "[$stamp] OK $Message" -ForegroundColor Green } default { Write-Host "[$stamp] INFO $Message" } } } function ConvertTo-CsvField { param ([string] $Text) if ([string]::IsNullOrEmpty($Text)) { return '' } if ($Text.IndexOfAny([char[]]@('"', ',', "`r", "`n")) -ge 0) { return '"' + $Text.Replace('"', '""') + '"' } return $Text } function Write-Summary { param ( [string] $Identity, [string] $Operation, [string] $Result, [string] $Detail ) if (-not $script:SummaryPath) { return } $line = '{0},{1},{2},{3},{4}' -f ` (ConvertTo-CsvField (Get-Date).ToString('s')), (ConvertTo-CsvField $Identity), (ConvertTo-CsvField $Operation), (ConvertTo-CsvField $Result), (ConvertTo-CsvField $Detail) Add-Content -Path $script:SummaryPath -Value $line -Encoding UTF8 } function Write-SummaryError { param ( [string] $Identity, [string] $Operation, [string] $Message, [string] $Detail ) Write-Summary -Identity $Identity -Operation $Operation -Result $Message -Detail $Detail $script:ErrorCount++ } # --------------------------------------------------------------------------- # Connection # --------------------------------------------------------------------------- function Get-ExistingCloudSession { <# Returns an active Exchange Online connection that already carries our prefix, or $null. Reusing it avoids a fresh sign-in on every run within the same PowerShell session. #> if (-not (Get-Command Get-ConnectionInformation -ErrorAction SilentlyContinue)) { return $null } try { $existing = Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.ModulePrefix -eq $script:CloudPrefix -and $_.State -eq 'Connected' -and $_.TokenStatus -ne 'Expired' } | Select-Object -First 1 } catch { return $null } if (-not $existing) { return $null } if (-not (Get-Command "Get-$($script:CloudPrefix)MailPublicFolder" -ErrorAction SilentlyContinue)) { return $null } return $existing } function Connect-CloudSession { Import-Module ExchangeOnlineManagement -ErrorAction Stop $reusable = Get-ExistingCloudSession if ($reusable) { Write-Log ("Reusing the existing Exchange Online session for {0}." -f $reusable.UserPrincipalName) $script:IsConnected = $true $script:SessionWasReused = $true return } Write-Log 'No reusable session found. Connecting to Exchange Online.' $module = Get-Module ExchangeOnlineManagement if ($module.Version.Major -lt 3) { throw (('ExchangeOnlineManagement {0} is installed. Version 3.0.0 or ' + 'later is required.') -f $module.Version) } $connectParams = @{ Prefix = $script:CloudPrefix ShowBanner = $false ErrorAction = 'Stop' } switch ($PSCmdlet.ParameterSetName) { 'CertificateAuth' { $connectParams['AppId'] = $AppId $connectParams['CertificateThumbprint'] = $CertificateThumbprint $connectParams['Organization'] = $Organization } 'Credential' { $connectParams['Credential'] = $Credential } } Connect-ExchangeOnline @connectParams # Verify the session really exists. The original script swallowed # connection failures and then continued against on-premises cmdlets. $info = $null try { $info = Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.ModulePrefix -eq $script:CloudPrefix } } catch { $info = Get-ConnectionInformation -ErrorAction SilentlyContinue } if (-not $info) { throw 'No active Exchange Online connection was established.' } if (-not (Get-Command "Get-$($script:CloudPrefix)MailPublicFolder" -ErrorAction SilentlyContinue)) { throw (("The prefixed cmdlet Get-{0}MailPublicFolder is not available. " + "The Exchange Online session did not import correctly.") -f $script:CloudPrefix) } $script:IsConnected = $true Write-Log 'Exchange Online session established.' -Level Success } function Disconnect-CloudSession { if ($script:IsConnected -and ($script:SessionWasReused -or $KeepSession)) { Write-Log 'Leaving the Exchange Online session open for reuse.' return } if ($script:IsConnected) { try { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue Write-Log 'Exchange Online session closed.' } catch { Write-Log "Failed to close the Exchange Online session: $($_.Exception.Message)" -Level Warn } $script:IsConnected = $false } } # --------------------------------------------------------------------------- # Routing domain discovery # --------------------------------------------------------------------------- function Get-RoutingDomainSet { <# Returns the set of domains that are safe to use as an ExternalEmailAddress: present as an address space on the hybrid send connector and NOT authoritative in the local organization. Writing an external address in a locally authoritative domain produces a mail loop. The original script only checked for an ".onmicrosoft.com" suffix, which does not catch this. #> $authoritative = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($domain in (Get-AcceptedDomain -ErrorAction Stop)) { if ($domain.DomainType -eq 'Authoritative') { [void]$authoritative.Add($domain.DomainName.ToString().TrimStart('*', '.')) } } $connector = Get-SendConnector -ErrorAction Stop | Where-Object { $_.Name -like $SendConnectorName } | Select-Object -First 1 if (-not $connector) { throw (("No send connector matching '{0}' was found. Use " + "-SendConnectorName to specify the hybrid connector.") -f $SendConnectorName) } Write-Log "Using send connector '$($connector.Name)'." $routing = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($space in $connector.AddressSpaces) { $address = $space.Address.ToString() if ($address -eq '*') { continue } $address = $address.TrimStart('*', '.') # An address space that is also an authoritative accepted domain is # normal in a Hybrid Configuration Wizard deployment: the tenant # routing domain is stamped on local objects as a proxy address while # the send connector still routes it to Exchange Online. Reported for # visibility, not treated as an error. if ($authoritative.Contains($address)) { Write-Log (("Address space '{0}' is also an authoritative accepted " + "domain. This is expected in a hybrid deployment.") -f $address) } [void]$routing.Add($address) } if ($routing.Count -eq 0) { throw (("Send connector '{0}' has only wildcard address spaces, so no " + "specific routing domain could be determined. Add the tenant " + "routing domain to the connector.") -f $connector.Name) } Write-Log ("Usable routing domains: {0}" -f (($routing | Sort-Object) -join ', ')) return @{ Routing = $routing Authoritative = $authoritative } } function Get-AddressDomain { param ([string] $Address) if ([string]::IsNullOrEmpty($Address)) { return '' } $index = $Address.LastIndexOf('@') if ($index -lt 0) { return '' } return $Address.Substring($index + 1) } function Remove-SmtpPrefix { param ([string] $Address) if ([string]::IsNullOrEmpty($Address)) { return '' } if ($Address.StartsWith('smtp:', [StringComparison]::OrdinalIgnoreCase)) { return $Address.Substring(5) } return $Address } function Resolve-ExternalAddress { <# Determines the address that on-premises should route to. Prefers a proxy address in a valid routing domain whose local part matches the primary address, because a mismatch there is the most common cause of silent misrouting. #> param ( [Parameter(Mandatory = $true)] $CloudFolder, [Parameter(Mandatory = $true)] $DomainSet ) $primary = $CloudFolder.PrimarySmtpAddress.ToString() $primaryLocal = $primary.Split('@')[0] $candidates = @() foreach ($raw in $CloudFolder.EmailAddresses) { $text = $raw.ToString() if (-not $text.StartsWith('smtp:', [StringComparison]::OrdinalIgnoreCase)) { continue } $address = Remove-SmtpPrefix $text $domain = Get-AddressDomain $address if ($DomainSet.Routing.Contains($domain)) { $candidates += $address } } if ($candidates.Count -eq 0) { return $null } foreach ($candidate in $candidates) { if ($candidate.Split('@')[0] -eq $primaryLocal) { return $candidate } } # A routable address exists but its local part differs from the primary # address. Usable, but worth reporting. Write-Log (("'{0}': routing address '{1}' has a different local part than " + "the primary address '{2}'.") -f $CloudFolder.Name, $candidates[0], $primary) -Level Warn return $candidates[0] } function ConvertTo-SafeRecipientName { <# Strips characters that Active Directory rejects in a cn and enforces the 64 character limit. #> param ([Parameter(Mandatory = $true)][string] $Name) $builder = New-Object System.Text.StringBuilder foreach ($char in $Name.ToCharArray()) { if (($script:InvalidNameChars -contains $char) -or ([int][char]$char -lt 32)) { [void]$builder.Append('_') } else { [void]$builder.Append($char) } } $safe = $builder.ToString().Trim() if ($safe.Length -gt $script:MaxRecipientNameLength) { $safe = $safe.Substring(0, $script:MaxRecipientNameLength).Trim() } if ([string]::IsNullOrEmpty($safe)) { $safe = 'MailPublicFolder' } return $safe } function Get-UniqueRecipientName { <# Returns a name that is not yet taken and reserves it in the supplied set. On collision a random numeric suffix is appended; the base name is shortened first so the result always stays within the 64 character limit. The original Microsoft script truncated after appending, which could cut the uniqueness suffix off entirely. #> param ( [Parameter(Mandatory = $true)][string] $DesiredName, [Parameter(Mandatory = $true)] $UsedNames, [switch] $ForceSuffix ) $base = ConvertTo-SafeRecipientName $DesiredName if ((-not $ForceSuffix) -and (-not $UsedNames.Contains($base))) { [void]$UsedNames.Add($base) return $base } for ($attempt = 0; $attempt -lt 100; $attempt++) { $suffix = '-' + (Get-Random -Minimum 1000 -Maximum 99999) $maxBase = $script:MaxRecipientNameLength - $suffix.Length $trimmed = $base if ($trimmed.Length -gt $maxBase) { $trimmed = $trimmed.Substring(0, $maxBase).TrimEnd() } $candidate = $trimmed + $suffix if (-not $UsedNames.Contains($candidate)) { [void]$UsedNames.Add($candidate) return $candidate } } # Practically unreachable, but never return a non-unique name. $suffix = '-' + [guid]::NewGuid().ToString('N') $maxBase = $script:MaxRecipientNameLength - $suffix.Length $trimmed = $base if ($trimmed.Length -gt $maxBase) { $trimmed = $trimmed.Substring(0, $maxBase).TrimEnd() } $candidate = $trimmed + $suffix [void]$UsedNames.Add($candidate) return $candidate } function Test-AliasSafe { param ([string] $Alias) if ([string]::IsNullOrWhiteSpace($Alias)) { return $false } return -not ($Alias -match $script:UnsafeAliasPattern) } # --------------------------------------------------------------------------- # On-premises inventory # --------------------------------------------------------------------------- function Get-OnPremIndex { <# Builds a lookup of the existing on-premises MEPF objects keyed by every address they are known under. Matching cloud to on-premises happens entirely against this index, before any write occurs. #> $folders = @(Get-MailPublicFolder -ResultSize Unlimited -ErrorAction Stop) $byAddress = New-Object 'System.Collections.Hashtable' ([StringComparer]::OrdinalIgnoreCase) foreach ($folder in $folders) { $keys = New-Object System.Collections.ArrayList if ($folder.PrimarySmtpAddress) { [void]$keys.Add($folder.PrimarySmtpAddress.ToString()) } if ($folder.ExternalEmailAddress) { [void]$keys.Add((Remove-SmtpPrefix $folder.ExternalEmailAddress.ToString())) } foreach ($raw in $folder.EmailAddresses) { $text = $raw.ToString() if ($text.StartsWith('smtp:', [StringComparison]::OrdinalIgnoreCase)) { [void]$keys.Add((Remove-SmtpPrefix $text)) } } foreach ($key in $keys) { if (-not [string]::IsNullOrEmpty($key) -and -not $byAddress.ContainsKey($key)) { $byAddress[$key] = $folder } } } $usedNames = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($folder in $folders) { [void]$usedNames.Add($folder.Name.ToString()) } Write-Log ("Found {0} mail-enabled public folder object(s) on-premises." -f $folders.Count) return @{ Folders = $folders ByAddress = $byAddress UsedNames = $usedNames } } function Backup-OnPremInventory { param ([Parameter(Mandatory = $true)] $Folders) if (-not (Test-Path $BackupFolder)) { New-Item -Path $BackupFolder -ItemType Directory -Force | Out-Null } $file = Join-Path $BackupFolder ('mepf-onprem-{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) $Folders | Select-Object Name, Alias, DisplayName, PrimarySmtpAddress, ExternalEmailAddress, HiddenFromAddressListsEnabled, LegacyExchangeDN, EntryId, DistinguishedName, @{ n = 'EmailAddresses'; e = { ($_.EmailAddresses | ForEach-Object { $_.ToString() }) -join ';' } } | Export-Csv -Path $file -NoTypeInformation -Encoding UTF8 Write-Log "Inventory backup written to $file" return $file } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- $exitCode = 0 try { # -- Preflight --------------------------------------------------------- $server = Get-ExchangeServer $env:COMPUTERNAME -ErrorAction Stop if ($server.AdminDisplayVersion.Major -lt 15) { throw (("This script requires Exchange 2013 or later. The local server " + "reports version {0}.") -f $server.AdminDisplayVersion) } Write-Log "Local Exchange server: $($server.Name) ($($server.AdminDisplayVersion))" if (-not $CsvSummaryFile) { $CsvSummaryFile = Join-Path $PWD ('mepf-sync-{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) } $domainSet = Get-RoutingDomainSet Connect-CloudSession # -- Read both sides before writing anything --------------------------- $getCloudFolders = Get-Command "Get-$($script:CloudPrefix)MailPublicFolder" $cloudFolders = @(& $getCloudFolders -ResultSize Unlimited -ErrorAction Stop) Write-Log ("Found {0} mail-enabled public folder(s) in Exchange Online." -f $cloudFolders.Count) if ($cloudFolders.Count -lt $MinimumCloudFolderCount) { throw (("Exchange Online returned {0} folder(s), which is below the " + "configured minimum of {1}. Aborting without any change.") -f ` $cloudFolders.Count, $MinimumCloudFolderCount) } $onPrem = Get-OnPremIndex if ($onPrem.Folders.Count -gt 0 -and $PSCmdlet.ShouldProcess('on-premises MEPF inventory', 'Export backup')) { Backup-OnPremInventory -Folders $onPrem.Folders | Out-Null } # -- Open the summary file --------------------------------------------- $script:SummaryPath = $CsvSummaryFile Set-Content -Path $script:SummaryPath ` -Value 'Timestamp,Identity,Operation,Result,Detail' -Encoding UTF8 # -- Create and update ------------------------------------------------- $matched = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) $usedNames = $onPrem.UsedNames $processed = 0 foreach ($cloudFolder in $cloudFolders) { $processed++ Write-Progress -Activity 'Synchronizing mail-enabled public folders' ` -Status ("{0} of {1}" -f $processed, $cloudFolders.Count) ` -PercentComplete ([int](($processed / $cloudFolders.Count) * 100)) $name = $cloudFolder.Name.ToString().Trim() $primary = $cloudFolder.PrimarySmtpAddress.ToString() $alias = $cloudFolder.Alias.ToString().Trim() Write-Verbose "Processing '$name' ($primary)" # --- validate before touching anything --- if (-not (Test-AliasSafe $alias)) { Write-SummaryError -Identity $primary -Operation 'Validate' ` -Message "Alias '$alias' contains characters that are invalid in an SMTP proxy address." ` -Detail 'Rename the folder and correct the alias in Exchange Online.' Write-Log "Skipping '$name': unsafe alias '$alias'." -Level Error $script:SkippedCount++ continue } $external = Resolve-ExternalAddress -CloudFolder $cloudFolder -DomainSet $domainSet if (-not $external) { Write-SummaryError -Identity $primary -Operation 'Validate' ` -Message 'No address in a valid routing domain was found.' ` -Detail (("Add a proxy address in one of: {0}") -f (($domainSet.Routing | Sort-Object) -join ', ')) Write-Log "Skipping '$name': no routable address." -Level Error $script:SkippedCount++ continue } # --- match against the on-premises index --- $existing = $null foreach ($key in @($external, $primary)) { if ($onPrem.ByAddress.ContainsKey($key)) { $existing = $onPrem.ByAddress[$key] break } } if (-not $existing) { foreach ($raw in $cloudFolder.EmailAddresses) { $candidate = Remove-SmtpPrefix $raw.ToString() if ($onPrem.ByAddress.ContainsKey($candidate)) { $existing = $onPrem.ByAddress[$candidate] break } } } # Protect the matched object from the delete phase regardless of # whether the write below succeeds. if ($existing) { [void]$matched.Add($existing.DistinguishedName.ToString()) } # --- build the property set --- $addresses = @() foreach ($raw in $cloudFolder.EmailAddresses) { $addresses += $raw.ToString() } if ($cloudFolder.LegacyExchangeDN) { $addresses += ('X500:' + $cloudFolder.LegacyExchangeDN.ToString()) } $windowsAddress = $external if ($cloudFolder.WindowsEmailAddress -and $cloudFolder.WindowsEmailAddress.ToString() -ne '') { $windowsAddress = $cloudFolder.WindowsEmailAddress.ToString() } $displayName = $name if ($cloudFolder.DisplayName) { $displayName = $cloudFolder.DisplayName.ToString().Trim() } # --- write --- if (-not $existing) { $createName = Get-UniqueRecipientName -DesiredName $name -UsedNames $usedNames if ($createName -ne $name) { Write-Log ("Name '{0}' is already taken. Using '{1}'." -f $name, $createName) -Level Warn Write-Summary -Identity $primary -Operation 'NameCollision' ` -Result 'Renamed' -Detail ("{0} -> {1}" -f $name, $createName) } $target = "$createName ($external)" if ($PSCmdlet.ShouldProcess($target, 'Create sync mail public folder')) { $attempt = 0 $created = $false $lastError = $null while ((-not $created) -and ($attempt -lt $script:MaxNameAttempts)) { $attempt++ try { New-SyncMailPublicFolder ` -Name $createName ` -Alias $alias ` -ExternalEmailAddress $external ` -EntryId $cloudFolder.EntryId.ToString() ` -WindowsEmailAddress $windowsAddress ` -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null $created = $true } catch { $lastError = $_ # Language-neutral collision detection: instead of # matching the localized error text, check whether a # recipient of that name now exists. Only then is a # retry under a new name justified. $clash = Get-Recipient -Identity $createName ` -ErrorAction SilentlyContinue -WarningAction SilentlyContinue if ($clash -and ($attempt -lt $script:MaxNameAttempts)) { $newName = Get-UniqueRecipientName -DesiredName $name ` -UsedNames $usedNames -ForceSuffix Write-Log ("Create failed for '{0}', name is in use. Retrying as '{1}'." -f ` $createName, $newName) -Level Warn Write-Summary -Identity $primary -Operation 'NameCollision' ` -Result 'Retry' -Detail ("{0} -> {1}" -f $createName, $newName) $createName = $newName } else { break } } } if ($created) { try { Set-MailPublicFolder -Identity $createName ` -EmailAddresses $addresses ` -DisplayName $displayName ` -HiddenFromAddressListsEnabled $cloudFolder.HiddenFromAddressListsEnabled ` -ErrorAction Stop -WarningAction SilentlyContinue Write-Summary -Identity $primary -Operation 'Create' -Result 'Success' -Detail $createName Write-Log "Created '$createName'." -Level Success $script:CreatedCount++ } catch { Write-SummaryError -Identity $primary -Operation 'CreateSetProperties' ` -Message $_.Exception.Message -Detail $createName Write-Log ("Created '{0}' but failed to set its properties: {1}" -f ` $createName, $_.Exception.Message) -Level Error } } else { $message = 'Unknown error.' if ($lastError) { $message = $lastError.Exception.Message } Write-SummaryError -Identity $primary -Operation 'Create' ` -Message $message -Detail $external Write-Log "Failed to create '$name': $message" -Level Error } } } else { $target = "$($existing.Name) ($($existing.PrimarySmtpAddress))" $setParams = @{ Identity = $existing.DistinguishedName.ToString() Alias = $alias DisplayName = $displayName ExternalEmailAddress = $external EmailAddresses = $addresses WindowsEmailAddress = $windowsAddress HiddenFromAddressListsEnabled = $cloudFolder.HiddenFromAddressListsEnabled ErrorAction = 'Stop' WarningAction = 'SilentlyContinue' } # Rename only when the desired name is genuinely free. An object # that was created earlier under a collision-resolved name must # keep it, otherwise every subsequent run would try to rename it # back into the collision and fail. $existingName = $existing.Name.ToString() $safeName = ConvertTo-SafeRecipientName $name if ($existingName -ne $safeName) { if ($usedNames.Contains($safeName)) { Write-Log ("Keeping name '{0}': '{1}' is already in use." -f $existingName, $safeName) } else { $setParams['Name'] = $safeName [void]$usedNames.Add($safeName) [void]$usedNames.Remove($existingName) } } if ($PSCmdlet.ShouldProcess($target, 'Update sync mail public folder')) { try { Set-MailPublicFolder @setParams Write-Summary -Identity $primary -Operation 'Update' -Result 'Success' -Detail $external Write-Log "Updated '$name'." -Level Success $script:UpdatedCount++ } catch { Write-SummaryError -Identity $primary -Operation 'Update' ` -Message $_.Exception.Message -Detail $external Write-Log "Failed to update '$name': $($_.Exception.Message)" -Level Error } } } } Write-Progress -Activity 'Synchronizing mail-enabled public folders' -Completed # -- Delete phase ------------------------------------------------------ $orphans = @($onPrem.Folders | Where-Object { -not $matched.Contains($_.DistinguishedName.ToString()) }) if ($SkipDeletePhase) { if ($orphans.Count -gt 0) { Write-Log (("{0} on-premises object(s) have no counterpart in the " + "cloud. Delete phase skipped by request.") -f $orphans.Count) -Level Warn } } elseif ($orphans.Count -eq 0) { Write-Log 'No orphaned on-premises objects. Nothing to remove.' } elseif ($script:ErrorCount -gt 0) { Write-Log (("Delete phase skipped: {0} error(s) occurred earlier in this " + "run, so the set of orphaned objects cannot be trusted. " + "{1} candidate(s) were identified.") -f $script:ErrorCount, $orphans.Count) -Level Warn foreach ($orphan in $orphans) { Write-Summary -Identity $orphan.PrimarySmtpAddress.ToString() ` -Operation 'Delete' -Result 'Skipped' -Detail 'Errors occurred earlier in the run.' } } else { $ratio = 0 if ($onPrem.Folders.Count -gt 0) { $ratio = [math]::Round(($orphans.Count / $onPrem.Folders.Count) * 100, 1) } $listFile = Join-Path $BackupFolder ('mepf-to-disable-{0}.txt' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) if (-not (Test-Path $BackupFolder)) { New-Item -Path $BackupFolder -ItemType Directory -Force | Out-Null } $orphans | Select-Object Name, PrimarySmtpAddress, ExternalEmailAddress, DistinguishedName | Format-List | Out-File -FilePath $listFile -Encoding UTF8 Write-Log (("{0} of {1} on-premises object(s) ({2}%) have no counterpart " + "in Exchange Online. Details: {3}") -f ` $orphans.Count, $onPrem.Folders.Count, $ratio, $listFile) -Level Warn if ($ratio -gt $MaxDeletePercent) { Write-Log (("Delete phase aborted: {0}% exceeds the configured " + "maximum of {1}%. Review the list and re-run with a " + "higher -MaxDeletePercent if this is intended.") -f ` $ratio, $MaxDeletePercent) -Level Error $exitCode = 2 } else { $proceed = $Force -or $WhatIfPreference if (-not $proceed) { Write-Host '' $answer = '' while ($answer -notmatch '^[YyNn]$') { $answer = Read-Host ("Mail-disable {0} object(s) listed in {1}? (Y/N)" -f $orphans.Count, $listFile) } $proceed = $answer -match '^[Yy]$' } if ($proceed) { foreach ($orphan in $orphans) { $identity = $orphan.DistinguishedName.ToString() $label = "$($orphan.Name) ($($orphan.PrimarySmtpAddress))" if ($PSCmdlet.ShouldProcess($label, 'Mail-disable public folder')) { try { Disable-MailPublicFolder -Identity $identity ` -Confirm:$false -ErrorAction Stop -WarningAction SilentlyContinue Write-Summary -Identity $orphan.PrimarySmtpAddress.ToString() ` -Operation 'Delete' -Result 'Success' -Detail $label Write-Log "Mail-disabled '$($orphan.Name)'." -Level Success $script:RemovedCount++ } catch { Write-SummaryError -Identity $orphan.PrimarySmtpAddress.ToString() ` -Operation 'Delete' -Message $_.Exception.Message -Detail $label Write-Log "Failed to mail-disable '$($orphan.Name)': $($_.Exception.Message)" -Level Error } } } } else { Write-Log 'Delete phase cancelled by operator.' } } } } catch { Write-Log $_.Exception.Message -Level Error if ($script:SummaryPath) { Write-Summary -Identity '' -Operation 'Abort' -Result $_.Exception.Message -Detail '' } $exitCode = 2 } finally { Disconnect-CloudSession Write-Host '' Write-Log ("Created: {0} Updated: {1} Mail-disabled: {2} Skipped: {3} Errors: {4}" -f ` $script:CreatedCount, $script:UpdatedCount, $script:RemovedCount, $script:SkippedCount, $script:ErrorCount) if ($script:SummaryPath) { Write-Log "Summary: $($script:SummaryPath)" } if ($exitCode -eq 0 -and $script:ErrorCount -gt 0) { $exitCode = 1 } } exit $exitCode