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 Start-TimeLog { [CmdletBinding()] param() [System.Diagnostics.Stopwatch]::StartNew() } function Stop-TimeLog { [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 { [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 { 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 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( [string[]] $MemberTypes, [switch] $RequireAccountEnabled, [switch] $RequireAssignedLicenses ) $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 ) $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' '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' '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) { 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 { $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-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] $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 ($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 { [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-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 { [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() 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 $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 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 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 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 $ExistingUsers = Get-O365ExistingMembers -MemberTypes $MemberTypes -RequireAccountEnabled -RequireAssignedLicenses if ($ExistingUsers -eq $false -or $ExistingUsers -is [Array]) { return } foreach ($User in $UserId) { $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-ModuleMember -Function @('Clear-O365PersonalContact', 'Sync-O365Contact', 'Sync-O365PersonalContact') -Alias @() # SIG # Begin signature block # MIItsQYJKoZIhvcNAQcCoIItojCCLZ4CAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAsXP+boOE0z7Ss # 4knnvso6IRea5rc2EMXraf393mIAjKCCJrQwggWNMIIEdaADAgECAhAOmxiO+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 # CSqGSIb3DQEJBDEiBCBUj0JXtL0RxZ8+/dKssK5HGW2zqBGLj4LYWuEYSRx3rTAN # BgkqhkiG9w0BAQEFAASCAgA1WqE0jnoyS0BzErAEsZK8tKldw5dIo2Hhjvy+DXd2 # TNT+i4icP1FjAnQ4v9KX2bJcWCZaEuJBTitTm6LCT80p9lQeZ2oqUmcUsWzC5I0j # 3YcYgtu9YMYBj+ATI+LrJXI7qKISJ3rTKSJj1cxMslOAYfMNDzsG0TV252Kn5RMI # +t8xiYeK9p0ailIhmZu063bcUzAwy5uRsmLX05pOSPfSSkEvnMvHQ0Zk/gXwV/q8 # 8ZOpBW1WbwRjy1kKMekcfrQeMB1e61SLt0T+2xt9zHnYkMUbqrHzuc7o6FtckPQE # DgDFdqDONgDlARHlPsFNRsDHQ4fhUKtSXj5qbzLina1xMD8Kjt+bvNOaV0/NTEI3 # ki5sH6DHQrROFe16I0COSbSJPnTAvEE2SdINuAKO7gVLeVoyjeVgWFJcoLFQDLIK # ePI/aodiqSA6ZAYKmZJwus8dMCFDQmx18GK7zgA7kRX8wDvu+OH9qQQz81zMyEwF # sIcTBuCYKYDF8W2OpAgWAqHCww5gAVCi3FDNSU0nUeE1WkOi7kYBynaZJxOQ/ZEi # iKzI2X9B91/6D3wiF0gSf52dR/eHWcKWgaNAgiYn532uigzzEKSAycC4jqxpZlqJ # CRS9kId2+PFFrt10HEYph+/DWAdGXWJMkecOms0sFLLe7age92tkE2TRTiF3WUEl # XaGCAyAwggMcBgkqhkiG9w0BCQYxggMNMIIDCQIBATB3MGMxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1 # c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAVEr/OUnQg5 # pr/bP1/lYRYwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcN # AQcBMBwGCSqGSIb3DQEJBTEPFw0yMzA5MjUyMDM4NTJaMC8GCSqGSIb3DQEJBDEi # BCCHFJOn/6mVb/InpBiQLe7/uTZAZJrvv2C+1pAVUCXFGTANBgkqhkiG9w0BAQEF # AASCAgBAn6RJgPzVR31qiv6EhDIzPi+34PFkOUGmwNhMxMwkpurW9qZ5nIvMgKIv # kuF9eskHsOD8vhZGieJPMKRIeM2koiRo1EXy6N3swPW3ItRd7qjUH7OubzdWCamQ # I1K6jCFA+klSSGMpEZ6oAmLprDGWyF5n9oGKp+BFHolHH6gENCTZnmcQM2YNH5lM # BCmrcp1QI1bMJmL5KgiY2S0XjxbcsoYJAVeTrCiJa7uOJ2maYGXiz014c8I4yOlN # GeYd5dY74HKRPhduuuUGhBd403b8FezPP7Rq1R3O3NTBcmgYyKQDeCrLz6xt8RfL # bXRTs6e7+ec9vQ0aKJ+a6doEqZuSVdIgMVE6OzXrNkIPe32mPq1k4VhlNGvpRfeD # YSpUqVAj3WY+i1P5JqBsYuCfO5WKBNxVZdnOndll3U2NZLGpPxtHohWYvuBIWaEM # g4WxVUkOIuXzRgXvOcIr3SovSPvH1miFGHU5CJracu5qsyzKWx05iDcXCVkpsiQs # mRtXS2yOm9J8EQt3xlrwjTMmrX8pNTOng0jCEfZQRwjT59wKbx9qFTkB3PPbFYdX # q71KHZ6Dd93+NxfopZsyk75ItqdIljALLXAEdPG+Y+qqn/P8CS4xSiAIhbk478mK # bwhvsLg9u4v3l1aBhEwroQaO5T1VveFfdSc9l14IGNmn7QJzrA== # SIG # End signature block |