O365Synchronizer.psm1
function Remove-EmptyValue { [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 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 } } # Add empty line before if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } # Add TABS before text if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } # Add SPACES before text if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } # Add Time before output if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { # the real deal coloring 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 } # Support for no new line if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line after } if ($Text.Count -and $LogFile) { # Save to file $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 { [CmdletBinding()] param( [string] $UserID, [PSCustomObject] $ExistingContact, [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 $($ExistingContact.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($ExistingContact.$Property) -and [string]::IsNullOrEmpty($TranslatedContact.$Property)) { $SkippedProperties.Add($Property) } else { # $TemporaryComparison = [ordered] @{ # Name = $Property # UserValue = $ExistingContact.$Property # ContactValue = $TranslatedContact.$Property # } # $TemporaryComparison | ConvertTo-Json | Write-Verbose if ($User.$Property -ne $TranslatedContact.$Property) { Write-Verbose -Message "Compare-UserToContact - Property $($Property) for $($ExistingContact.DisplayName) / $($ExistingContact.Mail) different ($($ExistingContact.$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 = $ExistingContact.DisplayName Mail = $ExistingContact.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 Get-O365ExistingMembers { [cmdletbinding()] param( [string[]] $MemberTypes, [switch] $RequireAccountEnabled, [switch] $RequireAssignedLicenses ) # Lets get all users and cache them $ExistingUsers = [ordered] @{} if ($MemberTypes -contains 'Member' -or $MemberTypes -contains 'Guest') { try { $Users = Get-MgUser -Property $Script:PropertiesUsers -All -ErrorAction Stop } catch { Write-Color -Text "[e] ", "Failed to get users. ", "Error: $($_.Exception.Message)" -Color Red, White, Red return $false } foreach ($User in $Users) { if ($RequireAccountEnabled) { if (-not $User.AccountEnabled) { continue } } if ($RequireAssignedLicenses) { if ($User.AssignedLicenses.Count -eq 0) { continue } } Add-Member -MemberType NoteProperty -Name 'Type' -Value $User.UserType -InputObject $User $Entry = $User.Id $ExistingUsers[$Entry] = $User } } if ($MemberTypes -contains 'Contact') { try { $Users = Get-MgContact -Property $Script:PropertiesContacts -All } catch { Write-Color -Text "[e] ", "Failed to get contacts. ", "Error: $($_.Exception.Message)" -Color Red, White, Red return $false } foreach ($User in $Users) { $Entry = $User.Id Add-Member -MemberType NoteProperty -Name 'Type' -Value 'Contact' -InputObject $User $ExistingUsers[$Entry] = $User } } $ExistingUsers } function Get-O365ExistingUserContacts { [cmdletbinding()] param( [string] $UserID, [string] $GuidPrefix ) # Lets get all contacts of given person and cache them $ExistingContacts = [ordered] @{} $CurrentContacts = Get-MgUserContact -UserId $UserId -All 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' 'MobilePhone' 'HomePhone' 'BusinessPhones' 'CompanyName' 'JobTitle' 'EmployeeId' 'Country' 'City' 'State' 'Street' 'PostalCode' ) $Script:PropertiesContacts = @( 'DisplayName' 'GivenName' 'Surname' 'Mail' 'JobTitle' 'MailNickname' #'Phones' 'UserPrincipalName' 'Id', 'CompanyName' 'OnPremisesSyncEnabled' 'Addresses' 'MobilePhone' 'HomePhone' 'BusinessPhones' 'CompanyName' 'JobTitle' 'EmployeeId' 'Country' 'City' 'State' 'Street' 'PostalCode' ) $Script:MappingContactToUser = [ordered] @{ 'MailNickname' = 'NickName' 'DisplayName' = 'DisplayName' 'GivenName' = 'GivenName' 'Surname' = 'Surname' # special treatment for 'Mail' because it's an array '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 New-O365InternalContact { [CmdletBinding()] param( [string] $UserId, [PSCustomObject] $User, [string] $GuidPrefix, [switch] $RequireEmailAddress ) if ($RequireEmailAddress) { if (-not $User.Mail) { #Write-Verbose -Message "Skipping $($User.DisplayName) because they have no email address" 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 } # $newMgUserContactSplat = @{ # FileAs = "$($GuidPrefix)$($User.Id)" # UserId = $UserId # NickName = $User.MailNickname # DisplayName = $User.DisplayName # GivenName = $User.GivenName # Surname = $User.Surname # EmailAddresses = @( # @{ # Address = $User.Mail; # Name = $User.MailNickname; # } # ) # MobilePhone = $User.MobilePhone # HomePhones = $User.HomePhone # BusinessPhones = $User.BusinessPhones # CompanyName = $User.CompanyName # ContactId = $ContactId # AssistantName = $AssistantName # Birthday = $Birthday # BusinessAddress = @{ # Street = $BusinessStreet # City = $BusinessCity # State = $BusinessState # PostalCode = $BusinessPostalCode # CountryOrRegion = $BusinessCountryOrRegion # } # BusinessHomePage = $BusinessHomePage # Categories = $Categories # Children = $Children # Department = $Department # Extensions = $Extensions # Generation = $Generation # HomeAddress = @{ # Street = $HomeStreet # City = $HomeCity # State = $HomeState # PostalCode = $HomePostalCode # CountryOrRegion = $HomeCountryOrRegion # } # ImAddresses = $ImAddresses # Initials = $Initials # JobTitle = $JobTitle # Manager = $Manager # MiddleName = $MiddleName # OfficeLocation = $OfficeLocation # OtherAddress = @{ # Street = $OtherStreet # City = $OtherCity # State = $OtherState # PostalCode = $OtherPostalCode # CountryOrRegion = $OtherCountryOrRegion # } # ParentFolderId = $ParentFolderId # PersonalNotes = $PersonalNotes # Profession = $Profession # SpouseName = $SpouseName # Title = $Title # YomiCompanyName = $YomiCompanyName # YomiGivenName = $YomiGivenName # YomiSurname = $YomiSurname # } # Remove-EmptyValue -Hashtable $newMgUserContactSplat -Recursive -Rerun 2 # try { # $null = New-MgUserContact @newMgUserContactSplat -WhatIf:$WhatIfPreference -ErrorAction Stop # } catch { # $ErrorMessage = $_.Exception.Message # Write-Color -Text "[!] ", "Failed to create contact for ", $User.DisplayName, " / ", $User.Mail, " because: ", $_.Exception.Message -Color Yellow, White, Red, White, Red, White, Red # } $PropertiesToUpdate = [ordered] @{} foreach ($Property in $Script:MappingContactToUser.Keys) { $PropertiesToUpdate[$Property] = $User.$Property } try { $StatusNew = New-O365WrapperPersonalContact -UserId $UserID @PropertiesToUpdate -WhatIf:$WhatIfPreference -FileAs "$($GuidPrefix)$($User.Id)" -ErrorAction SilentlyContinue $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-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] $ParentFolderId, [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 } 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 $null = New-MgUserContact @contactSplat $true } function Remove-O365InternalContact { [CmdletBinding(SupportsShouldProcess)] param( [System.Collections.Generic.List[object]] $ToPotentiallyRemove, [System.Collections.IDictionary] $ExistingUsers, [System.Collections.IDictionary] $ExistingContacts, [string] $UserId ) # foreach ($Contact in $ToPotentiallyRemove) { # Write-Color -Text "[x] ", "Removing (filtered out) ", $Contact.DisplayName -Color Yellow, White, Red, White, Red # try { # Remove-MgUserContact -UserId $UserId -ContactId $Contact.Id -WhatIf:$WhatIfPreference -ErrorAction Stop # $ErrorMessage = '' # if ($WhatIfPreference) { # $Status = 'OK (WhatIf)' # } else { # $Status = 'OK' # } # } 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 = 'Filtered out' # Error = $ErrorMessage # } # $OutputObject # } 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-O365InternalContact { [CmdletBinding(SupportsShouldProcess)] param( [string] $UserID, [PSCustomObject] $User, [PSCustomObject] $Contact ) $OutputObject = Compare-UserToContact -ExistingContact $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-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 { [cmdletBinding(SupportsShouldProcess)] param( [string] $UserId, [ValidateSet('Member', 'Guest', 'Contact')][string[]] $MemberTypes, [switch] $RequireEmailAddress, [string] $GuidPrefix, [System.Collections.IDictionary] $ExistingUsers, [System.Collections.IDictionary] $ExistingContacts ) $ListActions = [System.Collections.Generic.List[object]]::new() # $ToPotentiallyRemove = [System.Collections.Generic.List[object]]::new() # foreach ($ContactID in $ExistingContacts.Keys) { # $Contact = $ExistingContacts[$ContactID] # $Entry = $Contact.FileAs # if ($ExistingUsers[$Entry]) { # $User = $ExistingUsers[$Entry] # if ($User.Type -notin $MemberTypes) { # Write-Color -Text "[i] ", "Skipping ", $User.DisplayName, " because they are not a type: ", $($MemberTypes -join ', ') -Color Yellow, White, DarkYellow, White, DarkYellow # $ToPotentiallyRemove.Add($ExistingContacts[$ContactID]) # } # } else { # Write-Color -Text "[i] ", "Skipping ", $Contact.DisplayName, " because user does not exist" -Color Yellow, White, DarkYellow, White, DarkYellow # #$ToPotentiallyRemove.Add($ExistingContacts[$ContactID]) # } # } 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] # lets check if user is a member or guest # if ($User.Type -notin $MemberTypes) { # Write-Color -Text "[i] ", "Skipping ", $User.DisplayName, " because they are not a ", $($MemberTypes -join ', ') -Color Yellow, White, DarkYellow, White, DarkYellow # if ($Contact) { # $ToPotentiallyRemove.Add($ExistingContacts[$Entry]) # } # continue # } if ($Contact) { # Contact exists, lets check if we need to update it $OutputObject = Set-O365InternalContact -UserID $UserId -User $User -Contact $Contact $ListActions.Add($OutputObject) } else { # Contact does not exist, lets create it $OutputObject = New-O365InternalContact -UserId $UserId -User $User -GuidPrefix $GuidPrefix -RequireEmailAddress:$RequireEmailAddress $ListActions.Add($OutputObject) } } # now lets remove any contacts that are not required or filtered out $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 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, [switch] $FullLogging, [switch] $All ) $CurrentContacts = Get-MgUserContact -UserId $Identity -All 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 } } function Set-O365Credentials { [cmdletbinding()] param( [string] $TenantID, [string] $Domain, [string] $ClientID, [string] $ClientSecret ) $Credentials = @{ TenantID = $TenantID Domain = $Domain ClientID = $ClientID ClientSecret = $ClientSecret } Remove-EmptyValue -Hashtable $Credentials } function Sync-O365Guest { [cmdletBinding()] param( [parameter(Mandatory)][scriptblock] $ConfigurationBlock ) } 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 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 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. .EXAMPLE Sync-O365PersonalContact -UserId 'przemyslaw.klys@test.pl' -Verbose -MemberTypes 'Contact', 'Member' -WhatIf .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [string[]] $UserId, [ValidateSet('Member', 'Guest', 'Contact')][string[]] $MemberTypes = @('Member'), [switch] $RequireEmailAddress, [string] $GuidPrefix ) Initialize-DefaultValuesO365 # Lets get all users and cache them $ExistingUsers = Get-O365ExistingMembers -MemberTypes $MemberTypes -RequireAccountEnabled -RequireAssignedLicenses if ($ExistingUsers -eq $false -or $ExistingUsers -is [Array]) { return } foreach ($User in $UserId) { # Lets get all contacts of given person and cache them $ExistingContacts = Get-O365ExistingUserContacts -UserID $User -GuidPrefix $GuidPrefix $Actions = Sync-InternalO365PersonalContact -UserId $User -ExistingUsers $ExistingUsers -ExistingContacts $ExistingContacts -MemberTypes $MemberTypes -RequireEmailAddress:$RequireEmailAddress.IsPresent -GuidPrefix $GuidPrefix -WhatIf:$WhatIfPreference $Actions } } # Export functions and aliases as required Export-ModuleMember -Function @('Clear-O365PersonalContact', 'Set-O365Credentials', 'Sync-O365Guest', 'Sync-O365PersonalContact') -Alias @() # SIG # Begin signature block # MIInPgYJKoZIhvcNAQcCoIInLzCCJysCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDn3zt7hb3Qctew # 2EFzAZFTste7dkAgKxtk5muBiymZl6CCITcwggO3MIICn6ADAgECAhAM5+DlF9hG # /o/lYPwb8DA5MA0GCSqGSIb3DQEBBQUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBa # Fw0zMTExMTAwMDAwMDBaMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNVBAMTG0RpZ2lD # ZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC # AQoCggEBAK0OFc7kQ4BcsYfzt2D5cRKlrtwmlIiq9M71IDkoWGAM+IDaqRWVMmE8 # tbEohIqK3J8KDIMXeo+QrIrneVNcMYQq9g+YMjZ2zN7dPKii72r7IfJSYd+fINcf # 4rHZ/hhk0hJbX/lYGDW8R82hNvlrf9SwOD7BG8OMM9nYLxj+KA+zp4PWw25EwGE1 # lhb+WZyLdm3X8aJLDSv/C3LanmDQjpA1xnhVhyChz+VtCshJfDGYM2wi6YfQMlqi # uhOCEe05F52ZOnKh5vqk2dUXMXWuhX0irj8BRob2KHnIsdrkVxfEfhwOsLSSplaz # vbKX7aqn8LfFqD+VFtD/oZbrCF8Yd08CAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEXroq/0ksuCMS1Ri6enIZ3zbcgP # MB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBBQUA # A4IBAQCiDrzf4u3w43JzemSUv/dyZtgy5EJ1Yq6H6/LV2d5Ws5/MzhQouQ2XYFwS # TFjk0z2DSUVYlzVpGqhH6lbGeasS2GeBhN9/CTyU5rgmLCC9PbMoifdf/yLil4Qf # 6WXvh+DfwWdJs13rsgkq6ybteL59PyvztyY1bV+JAbZJW58BBZurPSXBzLZ/wvFv # hsb6ZGjrgS2U60K3+owe3WLxvlBnt2y98/Efaww2BxZ/N3ypW2168RJGYIPXJwS+ # S86XvsNnKmgR34DnDDNmvxMNFG7zfx9jEB76jRslbWyPpbdhAbHSoyahEHGdreLD # +cOZUbcrBwjOLuZQsqf6CkUvovDyMIIFMDCCBBigAwIBAgIQBAkYG1/Vu2Z1U0O1 # b5VQCDANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln # aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtE # aWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMTMxMDIyMTIwMDAwWhcNMjgx # MDIyMTIwMDAwWjByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j # MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBT # SEEyIEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMIIBIjANBgkqhkiG9w0BAQEF # AAOCAQ8AMIIBCgKCAQEA+NOzHH8OEa9ndwfTCzFJGc/Q+0WZsTrbRPV/5aid2zLX # cep2nQUut4/6kkPApfmJ1DcZ17aq8JyGpdglrA55KDp+6dFn08b7KSfH03sjlOSR # I5aQd4L5oYQjZhJUM1B0sSgmuyRpwsJS8hRniolF1C2ho+mILCCVrhxKhwjfDPXi # TWAYvqrEsq5wMWYzcT6scKKrzn/pfMuSoeU7MRzP6vIK5Fe7SrXpdOYr/mzLfnQ5 # Ng2Q7+S1TqSp6moKq4TzrGdOtcT3jNEgJSPrCGQ+UpbB8g8S9MWOD8Gi6CxR93O8 # vYWxYoNzQYIH5DiLanMg0A9kczyen6Yzqf0Z3yWT0QIDAQABo4IBzTCCAckwEgYD # VR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYB # BQUHAwMweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwgYEGA1UdHwR6MHgwOqA4 # oDaGNGh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJv # b3RDQS5jcmwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dEFzc3VyZWRJRFJvb3RDQS5jcmwwTwYDVR0gBEgwRjA4BgpghkgBhv1sAAIEMCow # KAYIKwYBBQUHAgEWHGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwCgYIYIZI # AYb9bAMwHQYDVR0OBBYEFFrEuXsqCqOl6nEDwGD5LfZldQ5YMB8GA1UdIwQYMBaA # FEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBCwUAA4IBAQA+7A1aJLPz # ItEVyCx8JSl2qB1dHC06GsTvMGHXfgtg/cM9D8Svi/3vKt8gVTew4fbRknUPUbRu # pY5a4l4kgU4QpO4/cY5jDhNLrddfRHnzNhQGivecRk5c/5CxGwcOkRX7uq+1UcKN # JK4kxscnKqEpKBo6cSgCPC6Ro8AlEeKcFEehemhor5unXCBc2XGxDI+7qPjFEmif # z0DLQESlE/DmZAwlCEIysjaKJAL+L3J+HNdJRZboWR3p+nRka7LrZkPas7CM1ekN # 3fYBIM6ZMWM9CBoYs4GbT8aTEAb8B4H6i9r5gkn3Ym6hU/oSlBiFLpKR6mhsRDKy # ZqHnGKSaZFHvMIIFPTCCBCWgAwIBAgIQBNXcH0jqydhSALrNmpsqpzANBgkqhkiG # 9w0BAQsFADByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkw # FwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBTSEEy # IEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMB4XDTIwMDYyNjAwMDAwMFoXDTIz # MDcwNzEyMDAwMFowejELMAkGA1UEBhMCUEwxEjAQBgNVBAgMCcWabMSFc2tpZTER # MA8GA1UEBxMIS2F0b3dpY2UxITAfBgNVBAoMGFByemVteXPFgmF3IEvFgnlzIEVW # T1RFQzEhMB8GA1UEAwwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMIIBIjANBgkq # hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7KB3iyBrhkLUbbFe9qxhKKPBYqDBqln # r3AtpZplkiVjpi9dMZCchSeT5ODsShPuZCIxJp5I86uf8ibo3vi2S9F9AlfFjVye # 3dTz/9TmCuGH8JQt13ozf9niHecwKrstDVhVprgxi5v0XxY51c7zgMA2g1Ub+3ti # i0vi/OpmKXdL2keNqJ2neQ5cYly/GsI8CREUEq9SZijbdA8VrRF3SoDdsWGf3tZZ # zO6nWn3TLYKQ5/bw5U445u/V80QSoykszHRivTj+H4s8ABiforhi0i76beA6Ea41 # zcH4zJuAp48B4UhjgRDNuq8IzLWK4dlvqrqCBHKqsnrF6BmBrv+BXQIDAQABo4IB # xTCCAcEwHwYDVR0jBBgwFoAUWsS5eyoKo6XqcQPAYPkt9mV1DlgwHQYDVR0OBBYE # FBixNSfoHFAgJk4JkDQLFLRNlJRmMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK # BggrBgEFBQcDAzB3BgNVHR8EcDBuMDWgM6Axhi9odHRwOi8vY3JsMy5kaWdpY2Vy # dC5jb20vc2hhMi1hc3N1cmVkLWNzLWcxLmNybDA1oDOgMYYvaHR0cDovL2NybDQu # ZGlnaWNlcnQuY29tL3NoYTItYXNzdXJlZC1jcy1nMS5jcmwwTAYDVR0gBEUwQzA3 # BglghkgBhv1sAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQu # Y29tL0NQUzAIBgZngQwBBAEwgYQGCCsGAQUFBwEBBHgwdjAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tME4GCCsGAQUFBzAChkJodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRTSEEyQXNzdXJlZElEQ29kZVNpZ25p # bmdDQS5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAmr1sz4ls # LARi4wG1eg0B8fVJFowtect7SnJUrp6XRnUG0/GI1wXiLIeow1UPiI6uDMsRXPHU # F/+xjJw8SfIbwava2eXu7UoZKNh6dfgshcJmo0QNAJ5PIyy02/3fXjbUREHINrTC # vPVbPmV6kx4Kpd7KJrCo7ED18H/XTqWJHXa8va3MYLrbJetXpaEPpb6zk+l8Rj9y # G4jBVRhenUBUUj3CLaWDSBpOA/+sx8/XB9W9opYfYGb+1TmbCkhUg7TB3gD6o6ES # Jre+fcnZnPVAPESmstwsT17caZ0bn7zETKlNHbc1q+Em9kyBjaQRcEQoQQNpezQu # g9ufqExx6lHYDjCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI # hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ # MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz # dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow # YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290 # IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww # IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5 # 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH # hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6 # Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ # ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b # A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9 # WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU # tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo # ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J # vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP # orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB # Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr # oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt # MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF # BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl # ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw # BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH # vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8 # UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn # f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU # jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j # LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDCCBq4w # ggSWoAMCAQICEAc2N7ckVHzYR6z9KGYqXlswDQYJKoZIhvcNAQELBQAwYjELMAkG # A1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRp # Z2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4X # DTIyMDMyMzAwMDAwMFoXDTM3MDMyMjIzNTk1OVowYzELMAkGA1UEBhMCVVMxFzAV # BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVk # IEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTCCAiIwDQYJKoZIhvcN # AQEBBQADggIPADCCAgoCggIBAMaGNQZJs8E9cklRVcclA8TykTepl1Gh1tKD0Z5M # om2gsMyD+Vr2EaFEFUJfpIjzaPp985yJC3+dH54PMx9QEwsmc5Zt+FeoAn39Q7SE # 2hHxc7Gz7iuAhIoiGN/r2j3EF3+rGSs+QtxnjupRPfDWVtTnKC3r07G1decfBmWN # lCnT2exp39mQh0YAe9tEQYncfGpXevA3eZ9drMvohGS0UvJ2R/dhgxndX7RUCyFo # bjchu0CsX7LeSn3O9TkSZ+8OpWNs5KbFHc02DVzV5huowWR0QKfAcsW6Th+xtVhN # ef7Xj3OTrCw54qVI1vCwMROpVymWJy71h6aPTnYVVSZwmCZ/oBpHIEPjQ2OAe3Vu # JyWQmDo4EbP29p7mO1vsgd4iFNmCKseSv6De4z6ic/rnH1pslPJSlRErWHRAKKtz # Q87fSqEcazjFKfPKqpZzQmiftkaznTqj1QPgv/CiPMpC3BhIfxQ0z9JMq++bPf4O # uGQq+nUoJEHtQr8FnGZJUlD0UfM2SU2LINIsVzV5K6jzRWC8I41Y99xh3pP+OcD5 # sjClTNfpmEpYPtMDiP6zj9NeS3YSUZPJjAw7W4oiqMEmCPkUEBIDfV8ju2TjY+Cm # 4T72wnSyPx4JduyrXUZ14mCjWAkBKAAOhFTuzuldyF4wEr1GnrXTdrnSDmuZDNIz # tM2xAgMBAAGjggFdMIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS6 # FtltTYUvcyl2mi91jGogj57IbzAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYB # BQUHAQEEazBpMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w # QQYIKwYBBQUHMAKGNWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dFRydXN0ZWRSb290RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwz # LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZ # MBcwCAYGZ4EMAQQCMAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAfVmO # wJO2b5ipRCIBfmbW2CFC4bAYLhBNE88wU86/GPvHUF3iSyn7cIoNqilp/GnBzx0H # 6T5gyNgL5Vxb122H+oQgJTQxZ822EpZvxFBMYh0MCIKoFr2pVs8Vc40BIiXOlWk/ # R3f7cnQU1/+rT4osequFzUNf7WC2qk+RZp4snuCKrOX9jLxkJodskr2dfNBwCnzv # qLx1T7pa96kQsl3p/yhUifDVinF2ZdrM8HKjI/rAJ4JErpknG6skHibBt94q6/ae # sXmZgaNWhqsKRcnfxI2g55j7+6adcq/Ex8HBanHZxhOACcS2n82HhyS7T6NJuXdm # kfFynOlLAlKnN36TU6w7HQhJD5TNOXrd/yVjmScsPT9rp/Fmw0HNT7ZAmyEhQNC3 # EyTN3B14OuSereU0cZLXJmvkOHOrpgFPvT87eK1MrfvElXvtCl8zOYdBeHo46Zzh # 3SP9HSjTx/no8Zhf+yvYfvJGnXUsHicsJttvFXseGYs2uJPU5vIXmVnKcPA3v5gA # 3yAWTyf7YGcWoWa63VXAOimGsJigK+2VQbc61RWYMbRiCQ8KvYHZE/6/pNHzV9m8 # BPqC3jLfBInwAM1dwvnQI38AC+R2AibZ8GV2QqYphwlHK+Z/GqSFD/yYlvZVVCsf # gPrA8g4r5db7qS9EFUrnEw4d2zc4GqEr9u3WfPwwggbAMIIEqKADAgECAhAMTWly # S5T6PCpKPSkHgD1aMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYD # VQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBH # NCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjIwOTIxMDAwMDAw # WhcNMzMxMTIxMjM1OTU5WjBGMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNl # cnQxJDAiBgNVBAMTG0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIyIC0gMjCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM/spSY6xqnya7uNwQ2a26HoFIV0Mxom # rNAcVR4eNm28klUMYfSdCXc9FZYIL2tkpP0GgxbXkZI4HDEClvtysZc6Va8z7GGK # 6aYo25BjXL2JU+A6LYyHQq4mpOS7eHi5ehbhVsbAumRTuyoW51BIu4hpDIjG8b7g # L307scpTjUCDHufLckkoHkyAHoVW54Xt8mG8qjoHffarbuVm3eJc9S/tjdRNlYRo # 44DLannR0hCRRinrPibytIzNTLlmyLuqUDgN5YyUXRlav/V7QG5vFqianJVHhoV5 # PgxeZowaCiS+nKrSnLb3T254xCg/oxwPUAY3ugjZNaa1Htp4WB056PhMkRCWfk3h # 3cKtpX74LRsf7CtGGKMZ9jn39cFPcS6JAxGiS7uYv/pP5Hs27wZE5FX/NurlfDHn # 88JSxOYWe1p+pSVz28BqmSEtY+VZ9U0vkB8nt9KrFOU4ZodRCGv7U0M50GT6Vs/g # 9ArmFG1keLuY/ZTDcyHzL8IuINeBrNPxB9ThvdldS24xlCmL5kGkZZTAWOXlLimQ # prdhZPrZIGwYUWC6poEPCSVT8b876asHDmoHOWIZydaFfxPZjXnPYsXs4Xu5zGcT # B5rBeO3GiMiwbjJ5xwtZg43G7vUsfHuOy2SJ8bHEuOdTXl9V0n0ZKVkDTvpd6kVz # HIR+187i1Dp3AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/ # BAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEE # AjALBglghkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8w # HQYDVR0OBBYEFGKK3tBh/I8xFO2XC809KpQU31KcMFoGA1UdHwRTMFEwT6BNoEuG # SWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQw # OTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQG # CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKG # TGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJT # QTQwOTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIB # AFWqKhrzRvN4Vzcw/HXjT9aFI/H8+ZU5myXm93KKmMN31GT8Ffs2wklRLHiIY1UJ # RjkA/GnUypsp+6M/wMkAmxMdsJiJ3HjyzXyFzVOdr2LiYWajFCpFh0qYQitQ/Bu1 # nggwCfrkLdcJiXn5CeaIzn0buGqim8FTYAnoo7id160fHLjsmEHw9g6A++T/350Q # p+sAul9Kjxo6UrTqvwlJFTU2WZoPVNKyG39+XgmtdlSKdG3K0gVnK3br/5iyJpU4 # GYhEFOUKWaJr5yI+RCHSPxzAm+18SLLYkgyRTzxmlK9dAlPrnuKe5NMfhgFknADC # 6Vp0dQ094XmIvxwBl8kZI4DXNlpflhaxYwzGRkA7zl011Fk+Q5oYrsPJy8P7mxNf # arXH4PMFw1nfJ2Ir3kHJU7n/NBBn9iYymHv+XEKUgZSCnawKi8ZLFUrTmJBFYDOA # 4CPe+AOk9kVH5c64A0JH6EE2cXet/aLol3ROLtoeHYxayB6a1cLwxiKoT5u92Bya # UcQvmvZfpyeXupYuhVfAYOd4Vn9q78KVmksRAsiCnMkaBXy6cbVOepls9Oie1FqY # yJ+/jbsYXEP10Cro4mLueATbvdH7WwqocH7wl4R44wgDXUcsY6glOJcB0j862uXl # 9uab3H4szP8XTE0AotjWAQ64i+7m4HJViSwnGWH2dwGMMYIFXTCCBVkCAQEwgYYw # cjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTExMC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVk # IElEIENvZGUgU2lnbmluZyBDQQIQBNXcH0jqydhSALrNmpsqpzANBglghkgBZQME # AgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEM # BgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqG # SIb3DQEJBDEiBCBVZFdtZsqehqbNNxmzWrL2Qs5RdhosHitDdpEc9uDXxDANBgkq # hkiG9w0BAQEFAASCAQCSM8JkyMOFnrj984CAGPyPE41+g4Bn+8T7eCGKNLRilS8X # +abfgRtjAonVnVnoSTu0ThWrXjQ1iT0vQ9l9Gp1PQHqz9ozzGkN7+e0Vv7KFAE95 # rihHG6vLQaF3lyug43NppbDxjCgqlROr+cLaXsypRgcd1yzL8SZD/grx34RqPniJ # J0Sf0PX0rSrsM1c3efpw/RAH9jWa/IJmuFF1ODFGs0mIlOCW6sKxYudMPgBgF5mh # O2AyWQlhFqhFdY0iukMIdXelXrLYRhUgcLE58JQyzcHRkRdMIxxXjhutk31K1mub # r1cOmjXnsFZajCRYsJuBnDbR3MNAfECDmn14IIMvoYIDIDCCAxwGCSqGSIb3DQEJ # BjGCAw0wggMJAgEBMHcwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0 # LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hB # MjU2IFRpbWVTdGFtcGluZyBDQQIQDE1pckuU+jwqSj0pB4A9WjANBglghkgBZQME # AgEFAKBpMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8X # DTIzMDUzMDEyMTQwMlowLwYJKoZIhvcNAQkEMSIEIChsY05hvCUPk6blw778Mggl # o4jza5k7a5TlFS+No8szMA0GCSqGSIb3DQEBAQUABIICAKsYrFvcDXtLrUeiFrlb # inMeUrbAR1yZkSqXMlseA204KyNHb6qSbtU7hcLkNPSYNZ+Fb6b4NtRjqzWHGQwI # HIrNXh5gn0UpcOcGkZyEgVPnGXhWsTX/bprmyDGsd2MkH0TUVmTzRmjOqfcgRYSh # fPij2j6cb9M/B0HHrFU8AZtI7AgOCScquNC6Z6M0QTL5TM4ttj1YFfX+1IVsA67N # 5KfCRCdM2hwPKm1iGQEJqLV6E/9aE8BKhwRyWnhto065NW71MpmDPWVoDXL1XFVI # 0Q7R8Zi+NUSpexYImeINtXbGRyXWnBnEiyzpCU9a9OpD2cVnOvFV1WPfhcPR5l98 # Zw3eahsy2RdxX+x4nX/OcIOL5x0ypC20DlG5IX011fnXmxE+1fuN61H4pCQTiwpz # +aV/r2/hTsxHoi81c1iCVm7W7Y6g6cBh+5s8tmHQJF4noWKBkp5rIT5RLkmrXImX # 0n5o2A65UBrEEkSS85FY2Nr5wSMV5qpWIsO50IKofCrOHiffQVxsqD5TQ2DtVwYo # 59IGZ+lvRf7DNeVxkv8X2gWIVh73fX6rqEw7esTCJ8vJfKpH73OPf4oB8EKHKDtT # PP45HFoai8TyaMzlBqFWwOINRNQLv8MabwC9fpAl98REqoDG0jGy37yivTW3npZ8 # O8Pp3mWI1/jknzSTdYRokBMc # SIG # End signature block |