O365Synchronizer.psm1
function Remove-EmptyValue { <# .SYNOPSIS Removes empty values from a hashtable recursively. .DESCRIPTION This function removes empty values from a given hashtable. It can be used to clean up a hashtable by removing keys with null, empty string, empty array, or empty dictionary values. The function supports recursive removal of empty values. .PARAMETER Hashtable The hashtable from which empty values will be removed. .PARAMETER ExcludeParameter An array of keys to exclude from the removal process. .PARAMETER Recursive Indicates whether to recursively remove empty values from nested hashtables. .PARAMETER Rerun Specifies the number of times to rerun the removal process recursively. .PARAMETER DoNotRemoveNull If specified, null values will not be removed. .PARAMETER DoNotRemoveEmpty If specified, empty string values will not be removed. .PARAMETER DoNotRemoveEmptyArray If specified, empty array values will not be removed. .PARAMETER DoNotRemoveEmptyDictionary If specified, empty dictionary values will not be removed. .EXAMPLE $hashtable = @{ 'Key1' = ''; 'Key2' = $null; 'Key3' = @(); 'Key4' = @{} } Remove-EmptyValue -Hashtable $hashtable -Recursive Description ----------- This example removes empty values from the $hashtable recursively. #> [alias('Remove-EmptyValues')] [CmdletBinding()] param( [alias('Splat', 'IDictionary')][Parameter(Mandatory)][System.Collections.IDictionary] $Hashtable, [string[]] $ExcludeParameter, [switch] $Recursive, [int] $Rerun, [switch] $DoNotRemoveNull, [switch] $DoNotRemoveEmpty, [switch] $DoNotRemoveEmptyArray, [switch] $DoNotRemoveEmptyDictionary ) foreach ($Key in [string[]] $Hashtable.Keys) { if ($Key -notin $ExcludeParameter) { if ($Recursive) { if ($Hashtable[$Key] -is [System.Collections.IDictionary]) { if ($Hashtable[$Key].Count -eq 0) { if (-not $DoNotRemoveEmptyDictionary) { $Hashtable.Remove($Key) } } else { Remove-EmptyValue -Hashtable $Hashtable[$Key] -Recursive:$Recursive } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } } if ($Rerun) { for ($i = 0; $i -lt $Rerun; $i++) { Remove-EmptyValue -Hashtable $Hashtable -Recursive:$Recursive } } } function Start-TimeLog { <# .SYNOPSIS Starts a new stopwatch for logging time. .DESCRIPTION This function starts a new stopwatch that can be used for logging time durations. .EXAMPLE Start-TimeLog Starts a new stopwatch for logging time. #> [CmdletBinding()] param() [System.Diagnostics.Stopwatch]::StartNew() } function Stop-TimeLog { <# .SYNOPSIS Stops the stopwatch and returns the elapsed time in a specified format. .DESCRIPTION The Stop-TimeLog function stops the provided stopwatch and returns the elapsed time in a specified format. The function can output the elapsed time as a single string or an array of days, hours, minutes, seconds, and milliseconds. .PARAMETER Time Specifies the stopwatch object to stop and retrieve the elapsed time from. .PARAMETER Option Specifies the format in which the elapsed time should be returned. Valid values are 'OneLiner' (default) or 'Array'. .PARAMETER Continue Indicates whether the stopwatch should continue running after retrieving the elapsed time. .EXAMPLE $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Perform some operations Stop-TimeLog -Time $stopwatch # Output: "0 days, 0 hours, 0 minutes, 5 seconds, 123 milliseconds" .EXAMPLE $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() # Perform some operations Stop-TimeLog -Time $stopwatch -Option Array # Output: ["0 days", "0 hours", "0 minutes", "5 seconds", "123 milliseconds"] #> [CmdletBinding()] param ( [Parameter(ValueFromPipeline = $true)][System.Diagnostics.Stopwatch] $Time, [ValidateSet('OneLiner', 'Array')][string] $Option = 'OneLiner', [switch] $Continue ) Begin { } Process { if ($Option -eq 'Array') { $TimeToExecute = "$($Time.Elapsed.Days) days", "$($Time.Elapsed.Hours) hours", "$($Time.Elapsed.Minutes) minutes", "$($Time.Elapsed.Seconds) seconds", "$($Time.Elapsed.Milliseconds) milliseconds" } else { $TimeToExecute = "$($Time.Elapsed.Days) days, $($Time.Elapsed.Hours) hours, $($Time.Elapsed.Minutes) minutes, $($Time.Elapsed.Seconds) seconds, $($Time.Elapsed.Milliseconds) milliseconds" } } End { if (-not $Continue) { $Time.Stop() } return $TimeToExecute } } function Write-Color { <# .SYNOPSIS Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. .DESCRIPTION Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. It provides: - Easy manipulation of colors, - Logging output to file (log) - Nice formatting options out of the box. - Ability to use aliases for parameters .PARAMETER Text Text to display on screen and write to log file if specified. Accepts an array of strings. .PARAMETER Color Color of the text. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER BackGroundColor Color of the background. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER StartTab Number of tabs to add before text. Default is 0. .PARAMETER LinesBefore Number of empty lines before text. Default is 0. .PARAMETER LinesAfter Number of empty lines after text. Default is 0. .PARAMETER StartSpaces Number of spaces to add before text. Default is 0. .PARAMETER LogFile Path to log file. If not specified no log file will be created. .PARAMETER DateTimeFormat Custom date and time format string. Default is yyyy-MM-dd HH:mm:ss .PARAMETER LogTime If set to $true it will add time to log file. Default is $true. .PARAMETER LogRetry Number of retries to write to log file, in case it can't write to it for some reason, before skipping. Default is 2. .PARAMETER Encoding Encoding of the log file. Default is Unicode. .PARAMETER ShowTime Switch to add time to console output. Default is not set. .PARAMETER NoNewLine Switch to not add new line at the end of the output. Default is not set. .PARAMETER NoConsoleOutput Switch to not output to console. Default all output goes to console. .EXAMPLE Write-Color -Text "Red ", "Green ", "Yellow " -Color Red,Green,Yellow .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan -StartTab 3 -LinesBefore 1 -LinesAfter 1 .EXAMPLE Write-Color "1. ", "Option 1" -Color Yellow, Green Write-Color "2. ", "Option 2" -Color Yellow, Green Write-Color "3. ", "Option 3" -Color Yellow, Green Write-Color "4. ", "Option 4" -Color Yellow, Green Write-Color "9. ", "Press 9 to exit" -Color Yellow, Gray -LinesBefore 1 .EXAMPLE Write-Color -LinesBefore 2 -Text "This little ","message is ", "written to log ", "file as well." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" -TimeFormat "yyyy-MM-dd HH:mm:ss" Write-Color -Text "This can get ","handy if ", "want to display things, and log actions to file ", "at the same time." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" .EXAMPLE Write-Color -T "My text", " is ", "all colorful" -C Yellow, Red, Green -B Green, Green, Yellow Write-Color -t "my text" -c yellow -b green Write-Color -text "my text" -c red .EXAMPLE Write-Color -Text "Testuję czy się ładnie zapisze, czy będą problemy" -Encoding unicode -LogFile 'C:\temp\testinggg.txt' -Color Red -NoConsoleOutput .NOTES Understanding Custom date and time format strings: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings Project support: https://github.com/EvotecIT/PSWriteColor Original idea: Josh (https://stackoverflow.com/users/81769/josh) #> [alias('Write-Colour')] [CmdletBinding()] param ( [alias ('T')] [String[]]$Text, [alias ('C', 'ForegroundColor', 'FGC')] [ConsoleColor[]]$Color = [ConsoleColor]::White, [alias ('B', 'BGC')] [ConsoleColor[]]$BackGroundColor = $null, [alias ('Indent')][int] $StartTab = 0, [int] $LinesBefore = 0, [int] $LinesAfter = 0, [int] $StartSpaces = 0, [alias ('L')] [string] $LogFile = '', [Alias('DateFormat', 'TimeFormat')][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [alias ('LogTimeStamp')][bool] $LogTime = $true, [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf7', 'utf32', 'ascii', 'default', 'oem')][string]$Encoding = 'Unicode', [switch] $ShowTime, [switch] $NoNewLine, [alias('HideConsole')][switch] $NoConsoleOutput ) if (-not $NoConsoleOutput) { $DefaultColor = $Color[0] if ($null -ne $BackGroundColor -and $BackGroundColor.Count -ne $Color.Count) { Write-Error "Colors, BackGroundColors parameters count doesn't match. Terminated." return } if ($LinesBefore -ne 0) { for ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host -Object "`n" -NoNewline } } if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } } else { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } } } else { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -NoNewline } } else { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -BackgroundColor $BackGroundColor[0] -NoNewline } } } } if ($NoNewLine -eq $true) { Write-Host -NoNewline } else { Write-Host } if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } } if ($Text.Count -and $LogFile) { $TextToFile = "" for ($i = 0; $i -lt $Text.Length; $i++) { $TextToFile += $Text[$i] } $Saved = $false $Retry = 0 Do { $Retry++ try { if ($LogTime) { "[$([datetime]::Now.ToString($DateTimeFormat))] $TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } else { "$TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } $Saved = $true } catch { if ($Saved -eq $false -and $Retry -eq $LogRetry) { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Tried ($Retry/$LogRetry))" } else { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Retrying... ($Retry/$LogRetry)" } } } Until ($Saved -eq $true -or $Retry -ge $LogRetry) } } function Compare-UserToContact { <# .SYNOPSIS Short description .DESCRIPTION Long description .PARAMETER UserID Identity of the user to synchronize contacts to. It can be UserID or UserPrincipalName .PARAMETER ExistingContactGAL User/Contact object from GAL .PARAMETER Contact Existing contact in user's personal contacts .EXAMPLE An example .NOTES General notes #> [CmdletBinding()] param( [string] $UserID, [PSCustomObject] $ExistingContactGAL, [PSCustomObject] $Contact ) $AddressProperties = 'City', 'State', 'Street', 'PostalCode', 'Country' if ($Contact.PSObject.Properties.Name -contains 'MailNickName') { $TranslatedContact = $Contact } elseif ($Contact.PSObject.Properties.Name -contains 'Nickname') { $TranslatedContact = [ordered] @{} foreach ($Property in $Script:MappingContactToUser.Keys) { if ($Property -eq 'Mail') { $TranslatedContact[$Property] = $Contact.EmailAddresses | ForEach-Object { $_.Address } } elseif ($Script:MappingContactToUser[$Property] -like "*.*") { $TranslatedContact[$Property] = $Contact.$($Script:MappingContactToUser[$Property].Split('.')[0]).$($Script:MappingContactToUser[$Property].Split('.')[1]) } else { $TranslatedContact[$Property] = $Contact.$($Script:MappingContactToUser[$Property]) } } } else { throw "Compare-UserToContact - Unknown user object $($ExistingContactGAL.PSObject.Properties.Name)" } $SkippedProperties = [System.Collections.Generic.List[string]]::new() $UpdateProperties = [System.Collections.Generic.List[string]]::new() foreach ($Property in $Script:MappingContactToUser.Keys) { if ([string]::IsNullOrEmpty($ExistingContactGAL.$Property) -and [string]::IsNullOrEmpty($TranslatedContact.$Property)) { $SkippedProperties.Add($Property) } else { if ($ExistingContactGAL.$Property -ne $TranslatedContact.$Property) { Write-Verbose -Message "Compare-UserToContact - Property $($Property) for $($ExistingContactGAL.DisplayName) / $($ExistingContactGAL.Mail) different ($($ExistingContactGAL.$Property) vs $($Contact.$Property))" if ($Property -in $AddressProperties) { foreach ($Address in $AddressProperties) { if ($UpdatedProperties -notcontains $Address) { $UpdateProperties.Add($Address) } } } else { $UpdateProperties.Add($Property) } } else { $SkippedProperties.Add($Property) } } } [PSCustomObject] @{ UserId = $UserId Action = 'Update' DisplayName = $ExistingContactGAL.DisplayName Mail = $ExistingContactGAL.Mail Update = $UpdateProperties | Sort-Object -Unique Skip = $SkippedProperties | Sort-Object -Unique Details = '' Error = '' } } function Convert-ConfigurationToSettings { [CmdletBinding()] param( [scriptblock] $ConfigurationBlock ) $Configuration = & $ConfigurationBlock foreach ($C in $ConfigurationBlock) { } } function Convert-GraphObjectToContact { [cmdletbinding()] param( $SourceObject ) $MappingMailContact = [ordered] @{ DisplayName = 'DisplayName' Name = 'DisplayName' PrimarySmtpAddress = 'Mail' CustomAttribute1 = 'CustomAttribute1' CustomAttribute2 = 'CustomAttribute2' ExtensionCustomAttribute1 = 'ExtensionCustomAttribute1' } $MappingContact = [ordered] @{ DisplayName = 'DisplayName' Name = 'DisplayName' WindowsEmailAddress = 'Mail' Title = 'JobTitle' FirstName = 'GivenName' LastName = 'SurName' HomePhone = 'HomePhone' MobilePhone = 'MobilePhone' Phone = 'BusinessPhones' CompanyName = 'CompanyName' Department = 'Department' Office = 'Office' StreetAddress = 'StreetAddress' City = 'City' StateOrProvince = 'StateOrProvince' PostalCode = 'PostalCode' CountryOrRegion = 'CountryOrRegion' } $NewContact = [ordered] @{} foreach ($Property in $MappingContact.Keys) { $PropertyName = $MappingContact[$Property] if ($PropertyName -eq 'BusinessPhones') { $NewContact[$Property] = [string] $SourceObject.$PropertyName } else { $NewContact[$Property] = $SourceObject.$PropertyName } } $NewMailContact = [ordered] @{} foreach ($Property in $MappingMailContact.Keys) { $PropertyName = $MappingMailContact[$Property] $NewMailContact[$Property] = $SourceObject.$PropertyName } $Output = [ordered] @{ Contact = [PSCustomObject] $NewContact MailContact = [PSCustomObject] $NewMailContact } $Output } function Get-O365ContactsFromTenant { [cmdletbinding()] param( [Array] $Domains ) $CurrentContactsCache = [ordered]@{} Write-Color -Text "[>] ", "Getting current contacts" -Color Yellow, White, Cyan try { $CurrentContacts = Get-Contact -ResultSize Unlimited -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Failed to get current contacts. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Yellow, White, Red return } Write-Color -Text "[>] ", "Getting current mail contacts (improving dataset)" -Color Yellow, White, Cyan try { $CurrentMailContacts = Get-MailContact -ResultSize Unlimited -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Failed to get current contacts. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Yellow, White, Red return } Write-Color -Text "[i] ", "Preparing ", $CurrentContacts.Count, " (", "Mail contacts: ", $CurrentMailContacts.Count , ")", " contacts for comparison" -Color Yellow, White, Cyan, White, white, Cyan, White, Yellow foreach ($Contact in $CurrentMailContacts) { $Found = $false foreach ($Domain in $Domains) { if ($Contact.PrimarySmtpAddress -notlike "*@$Domain") { continue } else { $Found = $true } } if ($Found) { $CurrentContactsCache[$Contact.PrimarySmtpAddress] = [ordered] @{ MailContact = $Contact Contact = $null } } } foreach ($Contact in $CurrentContacts) { if ($CurrentContactsCache[$Contact.WindowsEmailAddress]) { $CurrentContactsCache[$Contact.WindowsEmailAddress].Contact = $Contact } else { } } $CurrentContactsCache } function Get-O365ExistingMembers { [cmdletbinding()] param( [scriptblock] $UserProvidedFilter, [string[]] $MemberTypes, [switch] $RequireAccountEnabled, [switch] $RequireAssignedLicenses ) if ($UserProvidedFilter) { try { $FilterInformation = & $UserProvidedFilter } catch { Write-Color -Text "[e] ", "Failed to execute user provided filter because of error in line ", $_.InvocationInfo.ScriptLineNumber, " with message: ", $_.Exception.Message -Color Yellow, White, Red return $false } } else { $FilterInformation = @() } $GroupIDs = [ordered] @{} $GroupIDsExclude = [ordered] @{} $PropertyFilter = [ordered] @{} $PropertyFilterExclude = [ordered] @{} foreach ($Filter in $FilterInformation) { if ($Filter.FilterType -eq 'Group') { if ($Filter.Type -eq 'Include') { foreach ($GroupID in $Filter.GroupID) { $GroupIDs[$GroupID] = $true } } elseif ($Filter.Type -eq 'Exclude') { foreach ($GroupID in $Filter.GroupID) { $GroupIDsExclude[$GroupID] = $true } } } elseif ($Filter.FilterType -eq 'Property') { if ($Filter.Type -eq 'Include') { $PropertyFilter[$Filter.Property] = $Filter } elseif ($Filter.Type -eq 'Exclude') { $PropertyFilterExclude[$Filter.Property] = $Filter } } else { Write-Color -Text "[e] ", "Unknown filter type: $($Filter.FilterType)" -Color Red, White return $false } } $ExistingUsers = [ordered] @{} if ($MemberTypes -contains 'Member' -or $MemberTypes -contains 'Guest') { try { $getMgUserSplat = @{ Property = $Script:PropertiesUsers All = $true ErrorAction = 'Stop' } if ($GroupIDs.Keys.Count -gt 0) { $getMgUserSplat.ExpandProperty = 'memberOf' } $Users = Get-MgUser @getMgUserSplat } catch { Write-Color -Text "[e] ", "Failed to get users. ", "Error: $($_.Exception.Message)" -Color Red, White, Red return $false } :NextUser foreach ($User in $Users) { if ($RequireAccountEnabled) { if (-not $User.AccountEnabled) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by account is disabled" continue } } if ($RequireAssignedLicenses) { if ($User.AssignedLicenses.Count -eq 0) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by no assigned licenses" continue } } if ($GroupIDs.Keys.Count -gt 0) { if ($User.MemberOf.Count -eq 0) { continue } $GroupExclude = $false foreach ($Group in $User.MemberOf) { if ($GroupIDsExclude.Keys -contains $Group.Id) { $GroupExclude = $true break } } if ($GroupExclude -eq $true) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by group exclusion" continue } $GroupInclude = $false foreach ($Group in $User.MemberOf) { if ($GroupIDs.Keys -contains $Group.Id) { $GroupInclude = $true break } } if ($GroupInclude -eq $false) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by group inclusion" continue } } foreach ($Property in $PropertyFilterExclude.Keys) { $Filter = $PropertyFilterExclude[$Property] $Value = $User.$Property if ($Filter.Operator -eq 'Like') { $Find = $false foreach ($FilterValue in $Filter.Value) { if ($Value -like $FilterValue) { $Find = $true } } if ($Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'Equal') { $Find = $false if ($Filter.Value -contains $Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'NotEqual') { $Find = $false if ($Filter.Value -notcontains $Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'LessThan') { $Find = $false if ($Value -lt $Filter.Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'MoreThan') { $Find = $false if ($Value -gt $Filter.Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) matching $($Filter.Value)" continue NextUser } } else { Write-Color -Text "[e] ", "Unknown operator: $($Filter.Operator)" -Color Red, White return $false } } foreach ($Property in $PropertyFilter.Keys) { $Filter = $PropertyFilter[$Property] $Value = $User.$Property if ($Filter.Operator -eq 'Like') { $Find = $false foreach ($FilterValue in $Filter.Value) { if ($Value -like $FilterValue) { $Find = $true } } if (-not $Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'Equal') { $Find = $false if ($Filter.Value -contains $Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'NotEqual') { $Find = $false if ($Filter.Value -notcontains $Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'LessThan') { $Find = $false if ($Value -lt $Filter.Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'MoreThan') { $Find = $false if ($Value -gt $Filter.Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out user $($User.UserPrincipalName) by property $($Property) not matching $($Filter.Value)" continue NextUser } } else { Write-Color -Text "[e] ", "Unknown operator: $($Filter.Operator)" -Color Red, White return $false } } Add-Member -MemberType NoteProperty -Name 'Type' -Value $User.UserType -InputObject $User $Entry = $User.Id $ExistingUsers[$Entry] = $User } } if ($MemberTypes -contains 'Contact') { try { $getMgContactSplat = @{ Property = $Script:PropertiesContacts All = $true ErrorAction = 'Stop' } if ($GroupIDs.Keys.Count -gt 0) { $getMgContactSplat.ExpandProperty = 'memberOf' } $Users = Get-MgContact @getMgContactSplat } catch { Write-Color -Text "[e] ", "Failed to get contacts. ", "Error: $($_.Exception.Message)" -Color Red, White, Red return $false } :NextUser foreach ($User in $Users) { $Entry = $User.Id if ($GroupIDs.Keys.Count -gt 0) { if ($User.MemberOf.Count -eq 0) { continue } $GroupExclude = $false foreach ($Group in $User.MemberOf) { if ($GroupIDsExclude.Keys -contains $Group.Id) { $GroupExclude = $true break } } if ($GroupExclude -eq $true) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by group exclusion" continue } $GroupInclude = $false foreach ($Group in $User.MemberOf) { if ($GroupIDs.Keys -contains $Group.Id) { $GroupInclude = $true break } } if ($GroupInclude -eq $false) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by group inclusion" continue } } foreach ($Property in $PropertyFilterExclude.Keys) { $Filter = $PropertyFilterExclude[$Property] $Value = $User.$Property if ($Filter.Operator -eq 'Like') { $Find = $false foreach ($FilterValue in $Filter.Value) { if ($Value -like $FilterValue) { $Find = $true } } if ($Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'Equal') { $Find = $false if ($Filter.Value -contains $Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'NotEqual') { $Find = $false if ($Filter.Value -notcontains $Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'LessThan') { $Find = $false if ($Value -lt $Filter.Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'MoreThan') { $Find = $false if ($Value -gt $Filter.Value) { $Find = $true } if ($Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) matching $($Filter.Value)" continue NextUser } } else { Write-Color -Text "[e] ", "Unknown operator: $($Filter.Operator)" -Color Red, White return $false } } foreach ($Property in $PropertyFilter.Keys) { $Filter = $PropertyFilter[$Property] $Value = $User.$Property if ($Filter.Operator -eq 'Like') { $Find = $false foreach ($FilterValue in $Filter.Value) { if ($Value -like $FilterValue) { $Find = $true } } if (-not $Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'Equal') { $Find = $false if ($Filter.Value -contains $Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'NotEqual') { $Find = $false if ($Filter.Value -notcontains $Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'LessThan') { $Find = $false if ($Value -lt $Filter.Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) not matching $($Filter.Value)" continue NextUser } } elseif ($Filter.Operator -eq 'MoreThan') { $Find = $false if ($Value -gt $Filter.Value) { $Find = $true } if (-not $Find) { Write-Verbose -Message "Filtering out contact $($User.MailNickname) by property $($Property) not matching $($Filter.Value)" continue NextUser } } else { Write-Color -Text "[e] ", "Unknown operator: $($Filter.Operator)" -Color Red, White return $false } } $NewUser = [ordered] @{ Id = $User.Id Type = 'Contact' MobilePhone = $User.MobilePhone BusinessPhones = $User.BusinessPhones Country = $User.Addresses.CountryOrRegion City = $User.Addresses.City State = $User.Addresses.State Street = $User.Addresses.Street PostalCode = $User.Addresses.PostalCode CompanyName = $User.CompanyName DisplayName = $User.DisplayName GivenName = $User.GivenName JobTitle = $User.JobTitle Mail = $User.Mail MailNickname = $User.MailNickname MemberOf = $User.MemberOf Mobile = $User.Mobile Surname = $User.Surname } foreach ($Phone in $User.Phones) { if ($Phone.Type -eq 'Mobile') { $NewUser.MobilePhone = $Phone.Number } elseif ($Phone.Type -eq 'Business') { $NewUser.BusinessPhones = $Phone.Number } elseif ($Phone.Type -eq 'Home') { $NewUser.HomePhone = $Phone.Number } } $ExistingUsers[$Entry] = [PSCustomObject] $NewUser } } $ExistingUsers } function Get-O365ExistingUserContacts { [cmdletbinding()] param( [string] $UserID, [string] $GuidPrefix, [string] $FolderName ) $ExistingContacts = [ordered] @{} if ($FolderName) { try { $CurrentContactsFolder = Get-MgUserContactFolder -UserId $UserId -Filter "DisplayName eq '$FolderName'" -ExpandProperty Contacts -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Getting user folder ", $FolderName, " failed for ", $UserId, ". Error: ", $_.Exception.Message -Color Red, White, Red, White return } if (-not $CurrentContactsFolder) { Write-Color -Text "[!] ", "User folder ", $FolderName, " not found for ", $UserId -Color Yellow, Yellow, Red, Yellow, Red return } $CurrentContacts = $CurrentContactsFolder.Contacts } else { try { $CurrentContacts = Get-MgUserContact -UserId $UserId -All -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Getting user contacts for ", $UserId, " failed. Error: ", $_.Exception.Message -Color Red, White, Red return } } foreach ($Contact in $CurrentContacts) { if (-not $Contact.FileAs) { continue } if ($GuidPrefix -and -not $Contact.FileAs.StartsWith($GuidPrefix)) { continue } elseif ($GuidPrefix -and $Contact.FileAs.StartsWith($GuidPrefix)) { $Contact.FileAs = $Contact.FileAs.Substring($GuidPrefix.Length) } $Guid = [guid]::Empty $ConversionWorked = [guid]::TryParse($Contact.FileAs, [ref]$Guid) if (-not $ConversionWorked) { continue } $Entry = [string]::Concat($Contact.FileAs) $ExistingContacts[$Entry] = $Contact } Write-Color -Text "[i] ", "User ", $UserId, " has ", $CurrentContacts.Count, " contacts, out of which ", $ExistingContacts.Count, " synchronized." -Color Yellow, White, Cyan, White, Cyan, White, Cyan, White Write-Color -Text "[i] ", "Users to process: ", $ExistingUsers.Count, " Contacts to process: ", $ExistingContacts.Count -Color Yellow, White, Cyan, White, Cyan $ExistingContacts } function Initialize-DefaultValuesO365 { [cmdletBinding()] param( ) $Script:PropertiesUsers = @( 'DisplayName' 'GivenName' 'Surname' 'Mail' 'Nickname' 'MobilePhone' 'HomePhone' 'BusinessPhones' 'UserPrincipalName' 'Id', 'UserType' 'EmployeeType' 'AccountEnabled' 'CreatedDateTime' 'AssignedLicenses' 'MemberOf' 'MobilePhone' 'HomePhone' 'BusinessPhones' 'CompanyName' 'JobTitle' 'EmployeeId' 'Country' 'City' 'State' 'Street' 'PostalCode' ) $Script:PropertiesContacts = @( 'DisplayName' 'GivenName' 'Surname' 'Mail' 'JobTitle' 'MailNickname' 'UserPrincipalName' 'Id', 'CompanyName' 'OnPremisesSyncEnabled' 'Addresses' 'MemberOf' 'MobilePhone' 'Phones' 'HomePhone' 'BusinessPhones' 'CompanyName' 'JobTitle' 'EmployeeId' 'Country' 'City' 'State' 'Street' 'PostalCode' ) $Script:MappingContactToUser = [ordered] @{ 'MailNickname' = 'NickName' 'DisplayName' = 'DisplayName' 'GivenName' = 'GivenName' 'Surname' = 'Surname' 'Mail' = 'EmailAddresses.Address' 'MobilePhone' = 'MobilePhone' 'HomePhone' = 'HomePhone' 'CompanyName' = 'CompanyName' 'BusinessPhones' = 'BusinessPhones' 'JobTitle' = 'JobTitle' 'Country' = 'BusinessAddress.CountryOrRegion' 'City' = 'BusinessAddress.City' 'State' = 'BusinessAddress.State' 'Street' = 'BusinessAddress.Street' 'PostalCode' = 'BusinessAddress.PostalCode' } } function Initialize-FolderName { [cmdletbinding()] param( [string] $UserId, [string] $FolderName ) if ($FolderName) { $FolderInformation = Get-MgUserContactFolder -UserId $UserId -Filter "DisplayName eq '$FolderName'" if (-not $FolderInformation) { Write-Color -Text "[!] ", "User folder ", $FolderName, " not found for ", $UserId -Color Yellow, Yellow, Red, Yellow, Red try { $FolderInformation = New-MgUserContactFolder -UserId $UserId -DisplayName $FolderName -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Creating user folder ", $FolderName, " failed for ", $UserId, ". Error: ", $_.Exception.Message -Color Red, White, Red, White, Red, White return $false } if (-not $FolderInformation) { Write-Color -Text "[!] ", "Creating user folder ", $FolderName, " failed for ", $UserId -Color Red, White, Red, White return $false } else { Write-Color -Text "[+] ", "User folder ", $FolderName, " created for ", $UserId -Color Yellow, White, Green, White } } $FolderInformation } } function New-O365InternalContact { [CmdletBinding()] param( [string] $UserId, [PSCustomObject] $User, [string] $GuidPrefix, [switch] $RequireEmailAddress, [object] $FolderInformation ) if ($RequireEmailAddress) { if (-not $User.Mail) { continue } } if ($User.Mail) { Write-Color -Text "[+] ", "Creating ", $User.DisplayName, " / ", $User.Mail -Color Yellow, White, Green, White, Green } else { Write-Color -Text "[+] ", "Creating ", $User.DisplayName -Color Yellow, White, Green, White, Green } $PropertiesToUpdate = [ordered] @{} foreach ($Property in $Script:MappingContactToUser.Keys) { $PropertiesToUpdate[$Property] = $User.$Property } try { $newO365WrapperPersonalContactSplat = @{ UserId = $UserID WhatIf = $WhatIfPreference FileAs = "$($GuidPrefix)$($User.Id)" ErrorAction = 'SilentlyContinue' } if ($FolderInformation) { $newO365WrapperPersonalContactSplat['ContactFolderID'] = $FolderInformation.Id } $StatusNew = New-O365WrapperPersonalContact @newO365WrapperPersonalContactSplat @PropertiesToUpdate $ErrorMessage = '' } catch { $ErrorMessage = $_.Exception.Message if ($User.Mail) { Write-Color -Text "[!] ", "Failed to create contact for ", $User.DisplayName, " / ", $User.Mail, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red } else { Write-Color -Text "[!] ", "Failed to create contact for ", $User.DisplayName, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red } } if ($WhatIfPreference) { $Status = 'OK (WhatIf)' } elseif ($StatusNew -eq $true) { $Status = 'OK' } else { $Status = 'Failed' } [PSCustomObject] @{ UserId = $UserId Action = 'New' Status = $Status DisplayName = $User.DisplayName Mail = $User.Mail Skip = '' Update = $newMgUserContactSplat.Keys | Sort-Object Details = '' Error = $ErrorMessage } } function New-O365InternalGuest { [CmdletBinding()] param( ) } function New-O365InternalHTMLReport { [CmdletBinding()] param( ) } function New-O365OrgContact { [CmdletBinding(SupportsShouldProcess)] param( [Object] $Source ) Write-Color -Text "[+] ", "Adding ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress -Color Yellow, White, Cyan, White, Cyan try { $Created = New-MailContact -DisplayName $Source.DisplayName -ExternalEmailAddress $Source.PrimarySmtpAddress -Name $Source.Name -WhatIf:$WhatIfPreference -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Failed to create contact. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Yellow, White, Red } if ($Created) { $null = Set-O365OrgContact -MailContact $Created -Contact @{} -Source $Source -SourceContact $SourceContact } } function New-O365WrapperPersonalContact { [cmdletBinding(SupportsShouldProcess)] param( [string] $UserId, [string] $AssistantName, [DateTime] $Birthday, [alias('Street', 'StreetAddress')][string] $BusinessStreet, [alias('City')][string] $BusinessCity, [alias('State')][string] $BusinessState, [alias('PostalCode')][string] $BusinessPostalCode, [alias('Country')][string] $BusinessCountryOrRegion, [string] $HomeStreet, [string] $HomeCity, [string] $HomeState, [string] $HomePostalCode, [string] $HomeCountryOrRegion, [string] $OtherAddress, [string] $OtherCity, [string] $OtherState, [string] $OtherPostalCode, [string] $OtherCountryOrRegion, [string] $BusinessHomePage, [string[]] $BusinessPhones, [string[]] $Categories, [string[]] $Children, [string] $CompanyName, [string] $Department, [string] $DisplayName, [alias('Mail')][string[]] $EmailAddresses, [parameter(Mandatory)][string] $FileAs, [string] $Generation, [string] $GivenName, [string[]]$HomePhones, [string[]] $ImAddresses, [string] $Initials, [string] $JobTitle, [string] $Manager, [string] $MiddleName, [string] $MobilePhone, [alias('MailNickname')][string] $NickName, [string] $OfficeLocation, [string] $ContactFolderID, [string] $PersonalNotes, #$Photo, [string] $Profession, [string] $SpouseName, [string] $Surname, [string] $Title, [string] $YomiCompanyName, [string] $YomiGivenName, [string] $YomiSurname ) $ContactSplat = [ordered] @{ UserId = $UserId AssistantName = $AssistantName Birthday = $Birthday BusinessAddress = @{ Street = $BusinessStreet City = $BusinessCity State = $BusinessState PostalCode = $BusinessPostalCode CountryOrRegion = $BusinessCountryOrRegion } BusinessHomePage = $BusinessHomePage BusinessPhones = $BusinessPhones Categories = $Categories Children = $Children CompanyName = $CompanyName Department = $Department DisplayName = $DisplayName EmailAddresses = @( foreach ($Email in $EmailAddresses) { @{ Address = $Email } } ) Extensions = $Extensions FileAs = $FileAs Generation = $Generation GivenName = $GivenName HomeAddress = @{ Street = $HomeStreet City = $HomeCity State = $HomeState PostalCode = $HomePostalCode CountryOrRegion = $HomeCountryOrRegion } HomePhones = $HomePhones ImAddresses = $ImAddresses Initials = $Initials JobTitle = $JobTitle Manager = $Manager MiddleName = $MiddleName MobilePhone = $MobilePhone NickName = $NickName OfficeLocation = $OfficeLocation OtherAddress = @{ Street = $OtherStreet City = $OtherCity State = $OtherState PostalCode = $OtherPostalCode CountryOrRegion = $OtherCountryOrRegion } ContactFolderID = $ContactFolderID PersonalNotes = $PersonalNotes Profession = $Profession SpouseName = $SpouseName Surname = $Surname Title = $Title YomiCompanyName = $YomiCompanyName YomiGivenName = $YomiGivenName YomiSurname = $YomiSurname WhatIf = $WhatIfPreference ErrorAction = 'Stop' } Remove-EmptyValue -Hashtable $ContactSplat -Recursive -Rerun 2 if ($null -eq $ContactFolderID) { try { $null = New-MgUserContact @contactSplat $true } catch { Write-Color -Text "[!] ", "Failed to create contact for ", $DisplayName, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red $false } } else { try { $null = New-MgUserContactFolderContact @contactSplat $true } catch { Write-Color -Text "[!] ", "Failed to create contact for ", $DisplayName, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red $false } } } function Remove-O365InternalContact { [CmdletBinding(SupportsShouldProcess)] param( [System.Collections.Generic.List[object]] $ToPotentiallyRemove, [System.Collections.IDictionary] $ExistingUsers, [System.Collections.IDictionary] $ExistingContacts, [string] $UserId ) foreach ($ContactID in $ExistingContacts.Keys) { $Contact = $ExistingContacts[$ContactID] $Entry = $Contact.FileAs if ($ExistingUsers[$Entry]) { } else { Write-Color -Text "[x] ", "Removing (not required) ", $Contact.DisplayName -Color Yellow, White, Red, White, Red try { Remove-MgUserContact -UserId $UserId -ContactId $Contact.Id -WhatIf:$WhatIfPreference -ErrorAction Stop if ($WhatIfPreference) { $Status = 'OK (WhatIf)' } else { $Status = 'OK' } $ErrorMessage = '' } catch { $Status = 'Failed' $ErrorMessage = $_.Exception.Message Write-Color -Text "[!] ", "Failed to remove contact for ", $Contact.DisplayName, " / ", $Contact.Mail, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red } $OutputObject = [PSCustomObject] @{ UserId = $UserId Action = 'Remove' Status = $Status DisplayName = $Contact.DisplayName Mail = $Contact.Mail Skip = '' Update = '' Details = 'Not required' Error = $ErrorMessage } $OutputObject } } } function Set-LoggingCapabilities { [CmdletBinding()] param( [string] $LogPath, [int] $LogMaximum, [switch] $ShowTime, [string] $TimeFormat ) $Script:PSDefaultParameterValues = @{ "Write-Color:LogFile" = $LogPath "Write-Color:ShowTime" = if ($PSBoundParameters.ContainsKey('ShowTime')) { $ShowTime.IsPresent } else { $null } "Write-Color:TimeFormat" = $TimeFormat } Remove-EmptyValue -Hashtable $Script:PSDefaultParameterValues if ($LogPath) { $FolderPath = [io.path]::GetDirectoryName($LogPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false } if ($LogMaximum -gt 0) { $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $LogMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Logs directory has more than ", $LogMaximum, " log files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Log in $CurrentLogs) { try { Remove-Item -LiteralPath $Log.FullName -Confirm:$false -WhatIf:$false Write-Color -Text '[+] ', "Deleted ", "$($Log.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete log file $($Log.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } else { Write-Color -Text '[i] ', "LogMaximum is set to 0 (Unlimited). No log files will be deleted." -Color Yellow, DarkCyan } } } function Set-O365InternalContact { <# .SYNOPSIS Short description .DESCRIPTION Long description .PARAMETER UserID Identity of the user to synchronize contacts to. It can be UserID or UserPrincipalName. .PARAMETER User User/Contact object from GAL .PARAMETER FolderName .PARAMETER Contact Existing contact in user's personal contacts .EXAMPLE An example .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [string] $UserID, [PSCustomObject] $User, [PSCustomObject] $Contact, [string] $FolderName ) $OutputObject = Compare-UserToContact -ExistingContactGAL $User -Contact $Contact -UserID $UserID if ($OutputObject.Update.Count -gt 0) { if ($User.Mail) { Write-Color -Text "[i] ", "Updating ", $User.DisplayName, " / ", $User.Mail, " properties to update: ", $($OutputObject.Update -join ', '), " properties to skip: ", $($OutputObject.Skip -join ', ') -Color Yellow, White, Green, White, Green, White, Green, White, Cyan } else { Write-Color -Text "[i] ", "Updating ", $User.DisplayName, " properties to update: ", $($OutputObject.Update -join ', '), " properties to skip: ", $($OutputObject.Skip -join ', ') -Color Yellow, White, Green, White, Green, White, Green, White, Cyan } } if ($OutputObject.Update.Count -gt 0) { $PropertiesToUpdate = [ordered] @{} foreach ($Property in $OutputObject.Update) { $PropertiesToUpdate[$Property] = $User.$Property } $StatusSet = Set-O365WrapperPersonalContact -UserId $UserID -ContactId $Contact.Id @PropertiesToUpdate -WhatIf:$WhatIfPreference if ($WhatIfPreference) { $Status = 'OK (WhatIf)' } elseif ($StatusSet -eq $true) { $Status = 'OK' } else { $Status = 'Failed' } } else { $Status = 'Not required' } $OutputObject = [PSCustomObject] @{ UserId = $UserId Action = 'Update' Status = $Status DisplayName = $User.DisplayName Mail = $User.Mail Skip = '' Update = '' Details = '' Error = $ErrorMessage } $OutputObject } function Set-O365OrgContact { [CmdletBinding(SupportsShouldProcess)] param( [System.Collections.IDictionary] $CurrentContactsCache, [Object] $MailContact, [Object] $Contact, [Object] $Source, [Object] $SourceContact ) Write-Color -Text "[i] ", "Checking ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress, " for updates" -Color Yellow, White, Cyan, White, Cyan if ($Source -and $SourceContact) { if (-not $MailContact) { $MailContact = $CurrentContactsCache[$Source.PrimarySmtpAddress].MailContact } $MismatchedMailContact = [ordered] @{} [Array] $MismatchedPropertiesMailContact = foreach ($Property in $Source.PSObject.Properties.Name) { if ($Source.$Property -ne $MailContact.$Property) { if ([string]::IsNullOrEmpty($Source.$Property) -and [string]::IsNullOrEmpty($MailContact.$Property) ) { } else { $Property $MismatchedMailContact[$Property] = $Source.$Property } } } if (-not $Contact) { $Contact = $CurrentContactsCache[$Source.PrimarySmtpAddress].Contact } $MismatchedContact = [ordered] @{} [Array] $MismatchedPropertiesContact = foreach ($Property in $SourceContact.PSObject.Properties.Name) { if ($SourceContact.$Property -ne $Contact.$Property) { if ([string]::IsNullOrEmpty($SourceContact.$Property) -and [string]::IsNullOrEmpty($Contact.$Property) ) { } else { $Property $MismatchedContact[$Property] = $SourceContact.$Property } } } if ($MismatchedPropertiesMailContact.Count -gt 0 -or $MismatchedPropertiesContact.Count -gt 0) { Write-Color -Text "[i] ", "Mismatched properties for ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress, " are: ", ($MismatchedPropertiesMailContact + $MismatchedPropertiesContact -join ', ') -Color Yellow, White, DarkCyan, White, Cyan $ErrorValue = $false if ($MismatchedPropertiesMailContact.Count -gt 0) { Write-Color -Text "[*] ", "Updating mail contact for ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress -Color Yellow, Green, DarkCyan, White, Cyan try { Set-MailContact -Identity $MailContact.Identity -WhatIf:$WhatIfPreference -ErrorAction Stop @MismatchedMailContact } catch { $ErrorValue = $true Write-Color -Text "[e] ", "Failed to update mail contact. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Red, White, Red } } if ($MismatchedPropertiesContact.Count -gt 0) { Write-Color -Text "[*] ", "Updating contact for ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress -Color Yellow, Green, DarkCyan, White, Cyan try { Set-Contact -Identity $MailContact.Identity -WhatIf:$WhatIfPreference -ErrorAction Stop @MismatchedContact } catch { $ErrorValue = $true Write-Color -Text "[e] ", "Failed to update contact. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Red, White, Red } } if ($ErrorValue -eq $false) { $true } } else { } } } function Set-O365WrapperPersonalContact { [cmdletBinding(SupportsShouldProcess)] param( [string] $ContactId, [string] $UserId, [string] $AssistantName, [DateTime] $Birthday, [alias('Street', 'StreetAddress')][string] $BusinessStreet, [alias('City')][string] $BusinessCity, [alias('State')][string] $BusinessState, [alias('PostalCode')][string] $BusinessPostalCode, [alias('Country')][string] $BusinessCountryOrRegion, [string] $HomeStreet, [string] $HomeCity, [string] $HomeState, [string] $HomePostalCode, [string] $HomeCountryOrRegion, [string] $OtherAddress, [string] $OtherCity, [string] $OtherState, [string] $OtherPostalCode, [string] $OtherCountryOrRegion, [string] $BusinessHomePage, [string[]] $BusinessPhones, [string[]] $Categories, [string[]] $Children, [string] $CompanyName, [string] $Department, [string] $DisplayName, [alias('Mail')][string[]] $EmailAddresses, [string] $FileAs, [string] $Generation, [string] $GivenName, [string[]]$HomePhones, [string[]] $ImAddresses, [string] $Initials, [string] $JobTitle, [string] $Manager, [string] $MiddleName, [string] $MobilePhone, [string] $NickName, [string] $OfficeLocation, [string] $ParentFolderId, [string] $PersonalNotes, #$Photo, [string] $Profession, [string] $SpouseName, [string] $Surname, [string] $Title, [string] $YomiCompanyName, [string] $YomiGivenName, [string] $YomiSurname ) $ContactSplat = [ordered] @{ ContactId = $ContactId UserId = $UserId AssistantName = $AssistantName Birthday = $Birthday BusinessAddress = @{ Street = $BusinessStreet City = $BusinessCity State = $BusinessState PostalCode = $BusinessPostalCode CountryOrRegion = $BusinessCountryOrRegion } BusinessHomePage = $BusinessHomePage BusinessPhones = $BusinessPhones Categories = $Categories Children = $Children CompanyName = $CompanyName Department = $Department DisplayName = $DisplayName EmailAddresses = @( foreach ($Email in $EmailAddresses) { @{ Address = $Email } } ) Extensions = $Extensions FileAs = $FileAs Generation = $Generation GivenName = $GivenName HomeAddress = @{ Street = $HomeStreet City = $HomeCity State = $HomeState PostalCode = $HomePostalCode CountryOrRegion = $HomeCountryOrRegion } HomePhones = $HomePhones ImAddresses = $ImAddresses Initials = $Initials JobTitle = $JobTitle Manager = $Manager MiddleName = $MiddleName MobilePhone = $MobilePhone NickName = $NickName OfficeLocation = $OfficeLocation OtherAddress = @{ Street = $OtherStreet City = $OtherCity State = $OtherState PostalCode = $OtherPostalCode CountryOrRegion = $OtherCountryOrRegion } ParentFolderId = $ParentFolderId PersonalNotes = $PersonalNotes Profession = $Profession SpouseName = $SpouseName Surname = $Surname Title = $Title YomiCompanyName = $YomiCompanyName YomiGivenName = $YomiGivenName YomiSurname = $YomiSurname WhatIf = $WhatIfPreference ErrorAction = 'Stop' } Remove-EmptyValue -Hashtable $ContactSplat -Recursive -Rerun 2 try { $null = Update-MgUserContact @contactSplat $true } catch { $false Write-Color -Text "[!] ", "Failed to update contact for ", $ContactSplat.DisplayName, " / ", $ContactSplat.EmailAddresses, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red } } function Sync-InternalO365PersonalContact { <# .SYNOPSIS Short description .DESCRIPTION Long description .PARAMETER UserId Parameter description .PARAMETER MemberTypes Parameter description .PARAMETER RequireEmailAddress Parameter description .PARAMETER GuidPrefix Parameter description .PARAMETER FolderInformation Parameter description .PARAMETER ExistingUsers Users and contacts in GAL that will be synchronized to user's personal contacts .PARAMETER ExistingContacts Existing contacts in user's personal contacts .EXAMPLE An example .NOTES General notes #> [cmdletBinding(SupportsShouldProcess)] param( [string] $UserId, [ValidateSet('Member', 'Guest', 'Contact')][string[]] $MemberTypes, [switch] $RequireEmailAddress, [string] $GuidPrefix, [object] $FolderInformation, [System.Collections.IDictionary] $ExistingUsers, [System.Collections.IDictionary] $ExistingContacts ) $ListActions = [System.Collections.Generic.List[object]]::new() foreach ($UsersInternalID in $ExistingUsers.Keys) { $User = $ExistingUsers[$UsersInternalID] if ($User.Mail) { Write-Color -Text "[i] ", "Processing ", $User.DisplayName, " / ", $User.Mail -Color Yellow, White, Cyan, White, Cyan } else { Write-Color -Text "[i] ", "Processing ", $User.DisplayName -Color Yellow, White, Cyan } $Entry = $User.Id $Contact = $ExistingContacts[$Entry] if ($Contact) { $OutputObject = Set-O365InternalContact -UserID $UserId -User $User -Contact $Contact $ListActions.Add($OutputObject) } else { $OutputObject = New-O365InternalContact -UserId $UserId -User $User -GuidPrefix $GuidPrefix -RequireEmailAddress:$RequireEmailAddress -FolderInformation $FolderInformation $ListActions.Add($OutputObject) } } $RemoveActions = Remove-O365InternalContact -ExistingUsers $ExistingUsers -ExistingContacts $ExistingContacts -UserId $UserId foreach ($Remove in $RemoveActions) { $ListActions.Add($Remove) } $ListActions } function Clear-O365PersonalContact { <# .SYNOPSIS Removes personal contacts from user on Office 365. .DESCRIPTION Removes personal contacts from user on Office 365. By default it will only remove contacts that were synchronized by O365Synchronizer. If you want to remove all contacts use -All parameter. .PARAMETER Identity Identity of the user to remove contacts from. .PARAMETER GuidPrefix Prefix of the GUID that is used to identify contacts that were synchronized by O365Synchronizer. By default no prefix is used, meaning GUID of the user will be used as File, As property of the contact. .PARAMETER FolderName Name of the folder to remove contacts from. If not set it will remove contacts from the main folder. .PARAMETER FolderRemove If set it will remove the folder as well, once the contacts are removed. .PARAMETER FullLogging If set it will log all actions. By default it will only log actions that meant contact is getting removed or an error happens. .PARAMETER All If set it will remove all contacts. By default it will only remove contacts that were synchronized by O365Synchronizer. .EXAMPLE Clear-O365PersonalContact -Identity 'przemyslaw.klys@test.pl' -WhatIf .EXAMPLE Clear-O365PersonalContact -Identity 'przemyslaw.klys@test.pl' -GuidPrefix 'O365' -WhatIf .EXAMPLE Clear-O365PersonalContact -Identity 'przemyslaw.klys@test.pl' -All -WhatIf .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][string] $Identity, [string] $GuidPrefix, [string] $FolderName, [switch] $FolderRemove, [switch] $FullLogging, [switch] $All ) if ($FolderName) { try { $CurrentContactsFolder = Get-MgUserContactFolder -UserId $Identity -Filter "DisplayName eq '$FolderName'" -ExpandProperty Contacts -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Getting user folder ", $FolderName, " failed for ", $Identity, ". Error: ", $_.Exception.Message -Color Red, White, Red, White return } if (-not $CurrentContactsFolder) { Write-Color -Text "[!] ", "User folder ", $FolderName, " not found for ", $Identity -Color Yellow, Yellow, Red, Yellow, Red return } $CurrentContacts = $CurrentContactsFolder.Contacts } else { try { $CurrentContacts = Get-MgUserContact -UserId $Identity -All -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Getting user contacts for ", $Identity, " failed. Error: ", $_.Exception.Message -Color Red, White, Red return } } foreach ($Contact in $CurrentContacts) { if ($GuidPrefix -and -not $Contact.FileAs.StartsWith($GuidPrefix)) { if (-not $All) { if ($FullLogging) { Write-Color -Text "[i] ", "Skipping ", $Contact.Id, " because it is not created as part of O365Synchronizer." -Color Yellow, White, DarkYellow, White } continue } } elseif ($GuidPrefix -and $Contact.FileAs.StartsWith($GuidPrefix)) { $Contact.FileAs = $Contact.FileAs.Substring($GuidPrefix.Length) } $Guid = [guid]::Empty $ConversionWorked = [guid]::TryParse($Contact.FileAs, [ref]$Guid) if (-not $ConversionWorked) { if (-not $All) { if ($FullLogging) { Write-Color -Text "[i] ", "Skipping ", $Contact.Id, " because it is not created as part of O365Synchronizer." -Color Yellow, White, DarkYellow, White } continue } } Write-Color -Text "[i] ", "Removing ", $Contact.DisplayName, " from ", $Identity, " (WhatIf: $WhatIfPreference)" -Color Yellow, White, Cyan, White, Cyan Remove-MgUserContact -UserId $Identity -ContactId $Contact.Id -WhatIf:$WhatIfPreference } if ($CurrentContactsFolder -and $FolderName -and $FolderRemove) { Write-Color -Text "[i] ", "Removing folder ", $FolderName, " from ", $Identity, " (WhatIf: $WhatIfPreference)" -Color Yellow, White, Cyan, White, Cyan try { Remove-MgUserContactFolder -UserId $Identity -ContactFolderId $CurrentContactsFolder.Id -WhatIf:$WhatIfPreference -ErrorAction Stop } catch { Write-Color -Text "[!] ", "Failed to remove folder ", $FolderName, " from ", $Identity, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red } } } function Sync-O365Contact { <# .SYNOPSIS Synchronize contacts between source and target Office 365 tenant. .DESCRIPTION Synchronize contacts between source and target Office 365 tenant. Get users from source tenant using Get-MgUser (Microsoft Graph) and provide them as source objects. You can specify domains to synchronize. If you don't specify domains, it will use all domains from source objects. During synchronization new contacts will be created matching given domains in target tenant on Exchange Online. If contact already exists, it will be updated if needed, even if it wasn't synchronized by this module. It will asses whether it needs to add/update/remove contacts based on provided domain names from source objects. .PARAMETER SourceObjects Source objects to synchronize. You can use Get-MgUser to get users from Microsoft Graph and provide them as source objects. Any filtering you apply to them is valid and doesn't have to be 1:1 conversion. .PARAMETER Domains Domains to synchronize. If not specified, it will use all domains from source objects. .PARAMETER SkipAdd Disable the adding of new contacts functionality. This is useful if you want to only update existing contacts or remove non-existing contacts. .PARAMETER SkipUpdate Disable the updating of existing contacts functionality. This is useful if you want to only add new contacts or remove non-existing contacts. .PARAMETER SkipRemove Disable the removing of non-existing contacts functionality. This is useful if you want to only add new contacts or update existing contacts. .EXAMPLE # Source tenant $ClientID = '9e1b3c36' $TenantID = 'ceb371f6' $ClientSecret = 'NDE8Q' $Credentials = [pscredential]::new($ClientID, (ConvertTo-SecureString $ClientSecret -AsPlainText -Force)) Connect-MgGraph -ClientSecretCredential $Credentials -TenantId $TenantID -NoWelcome $UsersToSync = Get-MgUser | Select-Object -First 5 # Destination tenant $ClientID = 'edc4302e' Connect-ExchangeOnline -AppId $ClientID -CertificateThumbprint '2EC710' -Organization 'xxxxx.onmicrosoft.com' Sync-O365Contact -SourceObjects $UsersToSync -Domains 'evotec.pl', 'gmail.com' -Verbose -WhatIf .NOTES General notes #> [cmdletbinding(SupportsShouldProcess)] param( [Parameter(Mandatory)][Array] $SourceObjects, [Parameter()][Array] $Domains, [switch] $SkipAdd, [switch] $SkipUpdate, [switch] $SkipRemove, [string] $LogPath, [int] $LogMaximum ) Write-Color -Text "[i] ", "Starting synchronization of ", $SourceObjects.Count, " objects" -Color Yellow, White, Cyan, White, Cyan Set-LoggingCapabilities -LogPath $LogPath -LogMaximum $LogMaximum $StartTimeLog = Start-TimeLog Write-Color -Text "[i] ", "Starting synchronization of ", $SourceObjects.Count, " objects" -Color Yellow, White, Cyan, White, Cyan -NoConsoleOutput $SourceObjectsCache = [ordered]@{} if (-not $Domains) { Write-Color -Text "[i] ", "No domains specified, will use all domains from given user base" -Color Yellow, White, Cyan $DomainsCache = [ordered]@{} [Array] $Domains = foreach ($Source in $SourceObjects) { if ($Source.Mail) { $Domain = $Source.Mail.Split('@')[1] if ($Domain -and -not $DomainsCache[$Domain]) { $Domain $DomainsCache[$Domain] = $true Write-Color -Text "[i] ", "Adding ", $Domain, " to list of domains to synchronize" -Color Yellow, White, Cyan } } } } [Array] $ConvertedObjects = foreach ($Source in $SourceObjects) { Convert-GraphObjectToContact -SourceObject $Source } $CurrentContactsCache = Get-O365ContactsFromTenant -Domains $Domains if ($null -eq $CurrentContactsCache) { return } $CountAdd = 0 $CountRemove = 0 $CountUpdate = 0 foreach ($Object in $ConvertedObjects) { $Source = $Object.MailContact $SourceContact = $Object.Contact if ($Source.PrimarySmtpAddress) { $Skip = $true foreach ($Domain in $Domains) { if ($Source.PrimarySmtpAddress -like "*@$Domain") { $Skip = $false break } } if ($Skip) { Write-Color -Text "[s] ", "Skipping ", $Source.DisplayName, " / ", $Source.PrimarySmtpAddress, " as it's not in domains to synchronize ", $($Domains -join ', ') -Color Yellow, White, Red, White, Red continue } $SourceObjectsCache[$Source.PrimarySmtpAddress] = $Source if ($CurrentContactsCache[$Source.PrimarySmtpAddress]) { if (-not $SkipUpdate) { $Updated = Set-O365OrgContact -CurrentContactsCache $CurrentContactsCache -Source $Source -SourceContact $SourceContact if ($Updated) { $CountUpdate++ } } } else { if (-not $SkipAdd) { New-O365OrgContact -Source $Source $CountAdd++ } } } else { } } if (-not $SkipRemove) { foreach ($C in $CurrentContactsCache.Keys) { $Contact = $CurrentContactsCache[$C].MailContact if ($SourceObjectsCache[$Contact.PrimarySmtpAddress]) { continue } else { Write-Color -Text "[-] ", "Removing ", $Contact.DisplayName, " / ", $Contact.PrimarySmtpAddress -Color Yellow, Red, DarkCyan, White, Cyan try { Remove-MailContact -Identity $Contact.PrimarySmtpAddress -WhatIf:$WhatIfPreference -Confirm:$false -ErrorAction Stop $CountRemove++ } catch { Write-Color -Text "[e] ", "Failed to remove contact. Error: ", ($_.Exception.Message -replace ([Environment]::NewLine), " " )-Color Yellow, White, Red } } } } Write-Color -Text "[i] ", "Synchronization summary: ", $CountAdd, " added, ", $CountUpdate, " updated, ", $CountRemove, " removed" -Color Yellow, White, Cyan, White, Cyan, White, Cyan, White, Cyan $EndTimeLog = Stop-TimeLog -Time $StartTimeLog Write-Color -Text "[i] ", "Finished synchronization of ", $SourceObjects.Count, " objects. ", "Time: ", $EndTimeLog -Color Yellow, White, Cyan, White, Yellow, Cyan } function Sync-O365PersonalContact { <# .SYNOPSIS Synchronizes Users, Contacts and Guests to Personal Contacts of given user. .DESCRIPTION Synchronizes Users, Contacts and Guests to Personal Contacts of given user. .PARAMETER Filter Filters to apply to users. It can be used to filter out users that you don't want to synchronize. You should use Sync-O365PersonalContactFilter or/and Sync-O365PersonalContactFilterGroup to create filter(s). .PARAMETER UserId Identity of the user to synchronize contacts to. It can be UserID or UserPrincipalName. .PARAMETER MemberTypes Member types to synchronize. By default it will synchronize only 'Member'. You can also specify 'Guest' and 'Contact'. .PARAMETER RequireEmailAddress Sync only users that have email address. .PARAMETER DoNotRequireAccountEnabled Do not require account to be enabled. By default account must be enabled, otherwise it will be skipped. .PARAMETER DoNotRequireAssignedLicenses Do not require assigned licenses. By default user must have assigned licenses, otherwise it will be skipped. The licenses are checked by looking at AssignedLicenses property of the user, and not the actual license types. .PARAMETER GuidPrefix Prefix of the GUID that is used to identify contacts that were synchronized by O365Synchronizer. By default no prefix is used, meaning GUID of the user will be used as File, As property of the contact. .PARAMETER FolderName Name of the folder to synchronize contacts to. If not set it will synchronize contacts to the main folder. .EXAMPLE Sync-O365PersonalContact -UserId 'przemyslaw.klys@test.pl' -Verbose -MemberTypes 'Contact', 'Member' -WhatIf .EXAMPLE Sync-O365PersonalContact -UserId 'przemyslaw.klys@evotec.pl' -MemberTypes 'Contact', 'Member' -GuidPrefix 'O365Synchronizer' -PassThru { Sync-O365PersonalContactFilter -Type Include -Property 'CompanyName' -Value 'Evotec*','Ziomek*' -Operator 'like' Sync-O365PersonalContactFilterGroup -Type Include -GroupID 'e7772951-4b0e-4f10-8f38-eae9b8f55962' } -FolderName 'O365Sync' | Format-Table .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [scriptblock] $Filter, [string[]] $UserId, [ValidateSet('Member', 'Guest', 'Contact')][string[]] $MemberTypes = @('Member'), [switch] $RequireEmailAddress, [string] $GuidPrefix, [string] $FolderName, [switch] $DoNotRequireAccountEnabled, [switch] $DoNotRequireAssignedLicenses, [switch] $PassThru ) Initialize-DefaultValuesO365 $getO365ExistingMembersSplat = @{ MemberTypes = $MemberTypes RequireAccountEnabled = -not $DoNotRequireAccountEnabled.IsPresent RequireAssignedLicenses = -not $DoNotRequireAssignedLicenses.IsPresent UserProvidedFilter = $Filter } $ExistingUsers = Get-O365ExistingMembers @getO365ExistingMembersSplat if ($ExistingUsers -eq $false -or $ExistingUsers -is [Array]) { return } foreach ($User in $UserId) { $FolderInformation = Initialize-FolderName -UserId $User -FolderName $FolderName if ($FolderInformation -eq $false) { return } $ExistingContacts = Get-O365ExistingUserContacts -UserID $User -GuidPrefix $GuidPrefix -FolderName $FolderName $Actions = Sync-InternalO365PersonalContact -FolderInformation $FolderInformation -UserId $User -ExistingUsers $ExistingUsers -ExistingContacts $ExistingContacts -MemberTypes $MemberTypes -RequireEmailAddress:$RequireEmailAddress.IsPresent -GuidPrefix $GuidPrefix -WhatIf:$WhatIfPreference if ($PassThru) { $Actions } } } function Sync-O365PersonalContactFilter { <# .SYNOPSIS Provides a way to filter out users/contacts based on properties. .DESCRIPTION Provides a way to filter out users/contacts based on properties. Only users/contacts that match the filter will be included/excluded. .PARAMETER Type Type of the filter. It can be 'Include' or 'Exclude'. .PARAMETER Operator Operator to use. It can be 'Equal', 'NotEqual', 'LessThan', 'MoreThan', 'Like'. .PARAMETER Property Property to use for comparison. Keep in mind that it has to exists on the object. .PARAMETER Value Value to compare against. It can be single value or multiple values. .EXAMPLE Sync-O365PersonalContact -UserId 'przemyslaw.klys@test.pl' -Verbose -MemberTypes 'Contact', 'Member' -GuidPrefix 'O365Synchronizer' -WhatIf -PassThru { Sync-O365PersonalContactFilter -Type Include -Property 'CompanyName' -Value 'OtherCompany*','Evotec*' -Operator 'like' # filter out on CompanyName Sync-O365PersonalContactFilterGroup -Type Include -GroupID 'e7772951-4b0e-4f10-8f38-eae9b8f55962' # filter out on GroupID } | Format-Table .NOTES General notes #> [cmdletBinding()] param( [ValidateSet('Include', 'Exclude')][Parameter(Mandatory)][string] $Type, [ValidateSet('Equal', 'NotEqual', 'LessThan', 'MoreThan', 'Like')][Parameter(Mandatory)][string] $Operator, [Parameter(Mandatory)][string] $Property, [Parameter()][Object] $Value ) $Filter = [ordered] @{ FilterType = 'Property' Type = $Type Operator = $Operator Property = $Property Value = $Value } $Filter } function Sync-O365PersonalContactFilterGroup { <# .SYNOPSIS Provides a way to filter out users/contacts based on groups. .DESCRIPTION Provides a way to filter out users/contacts based on groups. Only users/contacts that are part of the group will be included/excluded. .PARAMETER Type Type of the filter. It can be 'Include' or 'Exclude'. .PARAMETER GroupID One or multiple GroupID's to filter out users/contacts. Keep in mind that it's not the name of the group, but the actual ID of the group. Due to performance reasons it's better to use GroupID instead of GroupName. .EXAMPLE Sync-O365PersonalContact -UserId 'przemyslaw.klys@test.pl' -Verbose -MemberTypes 'Contact', 'Member' -GuidPrefix 'O365Synchronizer' -WhatIf -PassThru { Sync-O365PersonalContactFilter -Type Include -Property 'CompanyName' -Value 'OtherCompany*','Evotec*' -Operator 'like' # filter out on CompanyName Sync-O365PersonalContactFilterGroup -Type Include -GroupID 'e7772951-4b0e-4f10-8f38-eae9b8f55962' # filter out on GroupID } | Format-Table .NOTES General notes #> [cmdletBinding()] param( [ValidateSet('Include', 'Exclude')][Parameter(Mandatory)][string] $Type, [Parameter(Mandatory)][string[]] $GroupID ) $Filter = [ordered] @{ FilterType = 'Group' Type = $Type GroupID = $GroupID } $Filter } Export-ModuleMember -Function @('Clear-O365PersonalContact', 'Sync-O365Contact', 'Sync-O365PersonalContact', 'Sync-O365PersonalContactFilter', 'Sync-O365PersonalContactFilterGroup') -Alias @() # SIG # Begin signature block # MIItsQYJKoZIhvcNAQcCoIItojCCLZ4CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAfSWuXhIl43i2e # /9wnKYfjMDp5mCGxd+clpgCLAdW/PaCCJrQwggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggWQMIIDeKADAgECAhAFmxtXno4hMuI5B72nd3VcMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/mkHNo3rvkXUo8MCIw # aTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/zG6Q4FutWxpdtHauyefLK # EdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZanMylNEQRBAu34LzB4Tm # dDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7sWxq868nPzaw0QF+xembu # d8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL2pNe3I6PgNq2kZhAkHnD # eMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfbBHMqbpEBfCFM1LyuGwN1 # XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3JFxGj2T3wWmIdph2PVld # QnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3cAORFJYm2mkQZK37AlLTS # YW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqxYxhElRp2Yn72gLD76GSm # M9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0viastkF13nqsX40/ybzT # QRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aLT8LWRV+dIPyhHsXAj6Kx # fgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD # VR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwPTzANBgkq # hkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNkaA9Wz3eucPn9mkqZucl4 # XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjSPMFDQK4dUPVS/JA7u5iZ # aWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK7VB6fWIhCoDIc2bRoAVg # X+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eBcg3AFDLvMFkuruBx8lbk # apdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp5aPNoiBB19GcZNnqJqGL # FNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msgdDDS4Dk0EIUhFQEI6FUy # 3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vriRbgjU2wGb2dVf0a1TD9u # KFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ79ARj6e/CVABRoIoqyc54 # zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5nLGbsQAe79APT0JsyQq8 # 7kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3i0objwG2J5VT6LaJbVu8 # aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0HEEcRrYc9B9F1vM/zZn4w # ggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1 # c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqG # SIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbS # g9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9 # /UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXn # HwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0 # VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4f # sbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40Nj # gHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0 # QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvv # mz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T # /jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk # 42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5r # mQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4E # FgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5n # P+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcG # CCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu # Y29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8v # Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNV # HSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIB # AH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxp # wc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIl # zpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQ # cAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfe # Kuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+j # Sbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJsh # IUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6 # OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDw # N7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR # 81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2 # VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIGsDCCBJigAwIBAgIQ # CK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEV # MBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t # MSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAw # MDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT # aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+k # jmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9 # NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9 # URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegY # E2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS # 4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJa # wv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+w # c86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eR # Gv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF2 # 3r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCK # ZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhEC # AwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2 # O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9P # MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcB # AQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggr # BgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1 # c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGln # aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAH # BgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6 # mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/ # SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzY # gBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9 # kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ # 9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAew # Q3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5Lm # Tl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HA # SIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xr # y7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhR # ILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFu # v2jiJmCG6sivqf6UHedjGzqGVnhOMIIGwjCCBKqgAwIBAgIQBUSv85SdCDmmv9s/ # X+VhFjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5 # NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIzMDcxNDAwMDAwMFoXDTM0MTAx # MzIzNTk1OVowSDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu # MSAwHgYDVQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMzCCAiIwDQYJKoZIhvcN # AQEBBQADggIPADCCAgoCggIBAKNTRYcdg45brD5UsyPgz5/X5dLnXaEOCdwvSKOX # ejsqnGfcYhVYwamTEafNqrJq3RApih5iY2nTWJw1cb86l+uUUI8cIOrHmjsvlmbj # aedp/lvD1isgHMGXlLSlUIHyz8sHpjBoyoNC2vx/CSSUpIIa2mq62DvKXd4ZGIX7 # ReoNYWyd/nFexAaaPPDFLnkPG2ZS48jWPl/aQ9OE9dDH9kgtXkV1lnX+3RChG4PB # uOZSlbVH13gpOWvgeFmX40QrStWVzu8IF+qCZE3/I+PKhu60pCFkcOvV5aDaY7Mu # 6QXuqvYk9R28mxyyt1/f8O52fTGZZUdVnUokL6wrl76f5P17cz4y7lI0+9S769Sg # LDSb495uZBkHNwGRDxy1Uc2qTGaDiGhiu7xBG3gZbeTZD+BYQfvYsSzhUa+0rRUG # FOpiCBPTaR58ZE2dD9/O0V6MqqtQFcmzyrzXxDtoRKOlO0L9c33u3Qr/eTQQfqZc # ClhMAD6FaXXHg2TWdc2PEnZWpST618RrIbroHzSYLzrqawGw9/sqhux7UjipmAmh # cbJsca8+uG+W1eEQE/5hRwqM/vC2x9XH3mwk8L9CgsqgcT2ckpMEtGlwJw1Pt7U2 # 0clfCKRwo+wK8REuZODLIivK8SgTIUlRfgZm0zu++uuRONhRB8qUt+JQofM604qD # y0B7AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAW # BgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg # hkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0O # BBYEFKW27xPn783QZKHVVqllMaPe1eNJMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6 # Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEy # NTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUF # BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6 # Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZT # SEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAIEa1t6g # qbWYF7xwjU+KPGic2CX/yyzkzepdIpLsjCICqbjPgKjZ5+PF7SaCinEvGN1Ott5s # 1+FgnCvt7T1IjrhrunxdvcJhN2hJd6PrkKoS1yeF844ektrCQDifXcigLiV4JZ0q # BXqEKZi2V3mP2yZWK7Dzp703DNiYdk9WuVLCtp04qYHnbUFcjGnRuSvExnvPnPp4 # 4pMadqJpddNQ5EQSviANnqlE0PjlSXcIWiHFtM+YlRpUurm8wWkZus8W8oM3NG6w # QSbd3lqXTzON1I13fXVFoaVYJmoDRd7ZULVQjK9WvUzF4UbFKNOt50MAcN7MmJ4Z # iQPq1JE3701S88lgIcRWR+3aEUuMMsOI5ljitts++V+wQtaP4xeR0arAVeOGv6wn # LEHQmjNKqDbUuXKWfpd5OEhfysLcPTLfddY2Z1qJ+Panx+VPNTwAvb6cKmx5Adza # ROY63jg7B145WPR8czFVoIARyxQMfq68/qTreWWqaNYiyjvrmoI1VygWy2nyMpqy # 0tg6uLFGhmu6F/3Ed2wVbK6rr3M66ElGt9V/zLY4wNjsHPW2obhDLN9OTH0eaHDA # dwrUAuBcYLso/zjlUlrWrBciI0707NMX+1Br/wd3H3GXREHJuEbTbDJ8WC9nR2Xl # G3O2mflrLAZG70Ee8PBf4NvZrZCARK+AEEGKMIIHXzCCBUegAwIBAgIQB8JSdCgU # otar/iTqF+XdLjANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UE # ChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQg # Q29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMB4XDTIzMDQxNjAw # MDAwMFoXDTI2MDcwNjIzNTk1OVowZzELMAkGA1UEBhMCUEwxEjAQBgNVBAcMCU1p # a2/FgsOzdzEhMB8GA1UECgwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMSEwHwYD # VQQDDBhQcnplbXlzxYJhdyBLxYJ5cyBFVk9URUMwggIiMA0GCSqGSIb3DQEBAQUA # A4ICDwAwggIKAoICAQCUmgeXMQtIaKaSkKvbAt8GFZJ1ywOH8SwxlTus4McyrWmV # OrRBVRQA8ApF9FaeobwmkZxvkxQTFLHKm+8knwomEUslca8CqSOI0YwELv5EwTVE # h0C/Daehvxo6tkmNPF9/SP1KC3c0l1vO+M7vdNVGKQIQrhxq7EG0iezBZOAiukNd # GVXRYOLn47V3qL5PwG/ou2alJ/vifIDad81qFb+QkUh02Jo24SMjWdKDytdrMXi0 # 235CN4RrW+8gjfRJ+fKKjgMImbuceCsi9Iv1a66bUc9anAemObT4mF5U/yQBgAuA # o3+jVB8wiUd87kUQO0zJCF8vq2YrVOz8OJmMX8ggIsEEUZ3CZKD0hVc3dm7cWSAw # 8/FNzGNPlAaIxzXX9qeD0EgaCLRkItA3t3eQW+IAXyS/9ZnnpFUoDvQGbK+Q4/bP # 0ib98XLfQpxVGRu0cCV0Ng77DIkRF+IyR1PcwVAq+OzVU3vKeo25v/rntiXCmCxi # W4oHYO28eSQ/eIAcnii+3uKDNZrI15P7VxDrkUIc6FtiSvOhwc3AzY+vEfivUkFK # RqwvSSr4fCrrkk7z2Qe72Zwlw2EDRVHyy0fUVGO9QMuh6E3RwnJL96ip0alcmhKA # BGoIqSW05nXdCUbkXmhPCTT5naQDuZ1UkAXbZPShKjbPwzdXP2b8I9nQ89VSgQID # AQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYD # VR0OBBYEFHrxaiVZuDJxxEk15bLoMuFI5233MA4GA1UdDwEB/wQEAwIHgDATBgNV # HSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3Js # My5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQw # OTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQu # Y29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAy # MUNBMS5jcmwwPgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0 # cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMIGUBggrBgEFBQcBAQSBhzCBhDAkBggr # BgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBo # dHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2Rl # U2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqG # SIb3DQEBCwUAA4ICAQC3EeHXUPhpe31K2DL43Hfh6qkvBHyR1RlD9lVIklcRCR50 # ZHzoWs6EBlTFyohvkpclVCuRdQW33tS6vtKPOucpDDv4wsA+6zkJYI8fHouW6Tqa # 1W47YSrc5AOShIcJ9+NpNbKNGih3doSlcio2mUKCX5I/ZrzJBkQpJ0kYha/pUST2 # CbE3JroJf2vQWGUiI+J3LdiPNHmhO1l+zaQkSxv0cVDETMfQGZKKRVESZ6Fg61b0 # djvQSx510MdbxtKMjvS3ZtAytqnQHk1ipP+Rg+M5lFHrSkUlnpGa+f3nuQhxDb7N # 9E8hUVevxALTrFifg8zhslVRH5/Df/CxlMKXC7op30/AyQsOQxHW1uNx3tG1DMgi # zpwBasrxh6wa7iaA+Lp07q1I92eLhrYbtw3xC2vNIGdMdN7nd76yMIjdYnAn7r38 # wwtaJ3KYD0QTl77EB8u/5cCs3ShZdDdyg4K7NoJl8iEHrbqtooAHOMLiJpiL2i9Y # n8kQMB6/Q6RMO3IUPLuycB9o6DNiwQHf6Jt5oW7P09k5NxxBEmksxwNbmZvNQ65Z # n3exUAKqG+x31Egz5IZ4U/jPzRalElEIpS0rgrVg8R8pEOhd95mEzp5WERKFyXhe # 6nB6bSYHv8clLAV0iMku308rpfjMiQkqS3LLzfUJ5OHqtKKQNMLxz9z185UCszGC # BlMwggZPAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ # bmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBS # U0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQB8JSdCgUotar/iTqF+XdLjANBglghkgB # ZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJ # AzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8G # CSqGSIb3DQEJBDEiBCCgeqakb/+6mG0Nd+7erSF3Y062XdFeESXnQHHa70p3gDAN # BgkqhkiG9w0BAQEFAASCAgCEAGac+8DEm7bnZCVB5qsui2k50UCby/WcThIZv7R+ # YbgQbifkccZiO+3T44PwRLnhpngMyTy8AzPJOAt7QiPlu7VHO8DjC+dha9av/9hD # 4iKA1tdout6SRa+o2uGYVZLPADzQLRx7w3tYEEirAd65zKs7zkyFvGbxKFyiKfKU # o6/7t3NqUJ1p2ZRV6/SFASPEkaUAuwXqncmeN7ZjVI4mR2dzWNztNazEXOlQ1e9h # wG3K502exep5FdZvURdaOLJikLKrZ8XrK1xocqZjVN2xb9TbdhxAz4GPVftVqLgt # vMhhmjkwrQKvxClIt43p5y5yMyVn8MscSuDmHs3QWgsZAc9GzM+4ydQI6JFdF4sF # s01mEtYNCbg/7mcS8TMM+OPPNxw0P68dWxERdWbSS5wpEgvYrgcziwbiQADDFUhz # ArA8i+fxfb3FUtLcsOTOYxUT1bGN4ed6ZJLDiY64Z0hh8lF6ZfHEvpWbjFRz3lXv # /pwbLggbmEDemMJhKuYin0lqjF7RInBcCpMpJoTz56TIt12O4QuEee7eX8Q+cHH+ # b4btofoHCLh8sO/gkxTcbG5qzQIr+jQATO/NwioTjQagoQjVuL00d1wslvgOrdd8 # cIACA3jrHxS6a/JrO6FSpdfRgjz7FIVa0Kmdy179sBdTUbnBte9fXHUnXUSMFpMY # mqGCAyAwggMcBgkqhkiG9w0BCQYxggMNMIIDCQIBATB3MGMxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1 # c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAVEr/OUnQg5 # pr/bP1/lYRYwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcN # AQcBMBwGCSqGSIb3DQEJBTEPFw0yNDA5MTgxMTQyMzNaMC8GCSqGSIb3DQEJBDEi # BCCR42ioOwIC7+8LEXHtVcjlZTVbgQI4hDKqR7geVPfLzDANBgkqhkiG9w0BAQEF # AASCAgAHGA6Xj11WGDjn0efgqNPAqRUGE7DN8egiYTNs2iOIcy5nF/wAwNRehW1L # 5Mby1cdfqFMvwMlYKlqiir07sCu2ClVxGMuvq6RtvC5+shYTz/TltTphmL53RU7r # As5jAHMOVaEj+YCE9VL2Z93JXcW57qeTolpzwdD16FL/mVODU/n54K1ElYiatNo1 # YIhgITxr3uZ+aapGpvViRE6V2eQLShWi7GYSQfN6Ukx9rIrE4Q68HOAAjQ4oPW3q # yQxI4B9bdQIWLqiFgNDPUEJun9CW1cRkrJS23I/AUfpek0Vr7JFiXFbJBnDRT0IQ # sMiFX9kBRPQjpw2m189IeTDkPmUyA3MPUh52sB5pMQ5hXEvhYVJBFpIwbFGEm3l7 # 7VBZHber0b7dOxRgkMdIgpu46vNKgJlDJnk92QdH7yTjkXbGJPb63T2QNKV2Bcya # MWGE6YoOFhXJJfe7JA/F2bHcTaSwXkiJiAeduuJD8rEAFemUANkWxhGUkOsHjkFl # L1ebvmheRl0EwtWz/MTdJUMXx4jTrPwFCkdLWcOO14cNQ+vgitB103QdOvmZUDTD # pw7o60AdGEgSPtaOQdO7Z/uKj6xS2EeuL/0lwBMFkqPQ6JHL6xjJ7Wek1F4bYvhG # xY0E1BuTcLnrXnvI2fAlNTRC4VSzdGChF5/UO8IHdeLKoGY85A== # SIG # End signature block |