Centrally Manage Company Contacts and Deploy to Built-In Contacts App Using Intune, SharePoint, PowerShell and Graph API.
Table of Contents
I recently met with a company that was looking for a better way to get contacts to their employee’s work phones. Currently, they are sending a .vcf file and then having the employees manually save the contacts. While this works, the problem is if you need to send a new contact, you now need to send a new .vcf file to every employee and instruct them on how to save it. Similarly, if you ever need to remove a contact, you need to instruct your employees to manually delete that contact.
One of the first things I thought about, is creating an App Configuration Policy to force Outlook to sync contacts to native apps. Most of the contacts I need to sync to the phone were employees of the company so I figured it would sync from the Global Address List and then maybe I could create contacts in Azure Active Directory / Entra ID.
Testing this however, I found that the only contacts it syncs are contacts found in the users personal Contacts list within Outlook. Contacts here are manually created by the end-user. The Global Address List is not included.
A solution needed to be found that would meet the following citeria:
- Automatically send the contacts to the users work-phones.
- Contacts need to be saved onto the default “Contacts” app on the phone.
- Contacts need to be deleted as well as created.
- If a phone number got assigned to a new employee, we need to be able to modify the old/existing contact.
- Leave any other contacts unchanged.
With that, I created a new SharePoint List where employees or managers of the company can simply add new contacts, edit contacts, or delete contacts, and leveraging the Graph API, it will automatically create, edit or delete those contacts in all of our users Outlook contacts list. To ensure that I do not touch any contacts the end-user may have created, I use contact categories (also known as contact folders that it seems to be replacing).
For this article, I will be walking you through how every piece of the automation works. If you just want to grab the fully working script, feel free to jump to the end.
Create the SharePoint Site
First, I created a new List in a SharePoint Site that I wanted to house my Shared Contacts in. In this example, I am creating a new List in our main Company SharePoint Site.
Next, I created several columns.
- Givenname
- Surname
- Phone Number
You can add other fields as needed, but for my use-case I just needed these three. I will end up using the PhoneNumber as my ‘source anchor’ (more on this later).
I also made the new columns mandatory when entering new data, this way I can ensure that when users are entering in new contacts, I can guarantee it has all of the information I require.
Lastly, you need to enter at least 1 item to your new list in order to get the correct property names in the next step.
Get SharePoint List Property Names
First, we must install the module PnP.PowerShell if you do not already have it.
Install-Module -Name PnP.PowerShell
Next, using the new PnP module, connect to your SharePoint site that contains your new List.
$Site = 'https://bwya77.sharepoint.com/sites/AllCompany/'
Connect-PnPOnline -Url $Site -DeviceLogin -LaunchBrowser
Now that we have properly authenticated, we need to get our SharePoint List. In my example, I created a list called “Company Contacts” so using the PowerShell command below, I can get details on that list.
Note: Take note of the ID and for later
Get-PnPList -Identity "Company Contacts"
Using that information, I now need to get the column names. From the image below my column names are LinkTitle, Surname and PhoneNumber. Notate these for later.
Note: I believe LinkTitle may always be the first column by default.
Get-PnPField -List "Company Contacts"
Create Azure AD App for Graph API Access
The next thing we must do is create an Azure AD Application so we can interact with the Microsoft Graph API. For this we will use PowerShell.
First, connect to Azure using Connect-AzAccount. You may need to install Az.Accounts if you do not already have this cmdlet.
Connect-AzAccount
Next, we will create our Azure AD Application. For this we need to supply a Name, IdentifierURLs (For this I just create DisplayName+DefaultTenantURL), and a ReplyURL.
$AppRegistrationSplat = @{
DisplayName = "CompanyContacts"
IdentifierURIs = "http://companycontacts.bwya77.onmicrosoft.com"
ReplyUrls = "https://www.localhost/"
}
$AzureADApp = New-AzADApplication @AppRegistrationSplat
Now that we have the Azure AD Application created, we need to give it the proper Graph API permissions
- Contacts.ReadWrite
- Directory.ReadWrite.All
- Sites.ReadWrite.All
$AppPermissions = @(
"9492366f-7969-46a4-8d15-ed1a20078fff"
"6918b873-d17a-4dc1-b314-35f528134491"
"19dbc75e-c2e2-444c-a770-ec69d8559fc7"
)
$AppPermissions | ForEach-Object {
Add-AzADAppPermission -ObjectId $AzureADApp.ID -ApiId '00000003-0000-0000-c000-000000000000' -PermissionId $_ -Type Role
}
Next, go to the Azure Portal at portal.azure.com and go to Azure Active Directory (or Entra ID depending on when you are reading this) > App Registrations > Click on your newly created application > API Permissions > and then click Grant Admin Consent
Next, we need to create an App Secret for our Azure AD Application. We will use this app secret like a password to connect and interface with the Graph API.
In my example, the secret will expire in a year from its creation date, but you can adjust this to your needs.
[System.DateTime]$startDate = Get-Date
[System.DateTime]$endDate = $startDate.AddYears(1)
$AppSecret = Get-AzADApplication -ApplicationId $AzureADApp.AppID | New-AzADAppCredential -StartDate $startDate -EndDate $endDate
Run the following command to have it display the AppID, AppSecret and TenantID.
Note all for these for later.
$TenantID = Get-AzTenant | Select-Object -ExpandProperty ID
Write-Host "
ApplicationId: $($AzureADApp.AppId)
ApplicationSecret: $($AppSecret.SecretText)
TenantID: $($TenantID)
"
Using our AppID (clientID), TenantID and clientSecret, run the following command to get the siteID of the SharePoint site that contains your list.
Note the siteID for late.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
$clientID = ''
$tenantID = ''
$clientSecret = ''
$Token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientsecret
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'
$Data = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Token.access_token)"} -Uri $apiUrl -Method Get
$Sites = ($Data | select-object Value).Value
$Sites
View List Items
Next, using all of the information we gathered above, we can view our test item in our SharePoint List. With the code below you just need to enter the siteID, listID, clientID, tenantID, and clientSecret
Function Get-ListItems {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$siteID,
[Parameter(Mandatory)]
[string]$listID,
[Parameter(Mandatory)]
[string]$accessToken
)
begin {
$allListItems = @()
Write-Output "Getting list items from $listID"
$headers = @{
Authorization = "Bearer $accessToken"
}
$apiUrl = "https://graph.microsoft.com/v1.0/sites/$siteID/lists/$listID/items?expand=fields"
}
process {
$listItems = Invoke-RestMethod -Uri $apiURL -Headers $headers -Method GET
$allListItems += $listItems.value.fields
if ($listItems.'@odata.nextLink') {
do {
$listItems = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allListItems += $listItems.value.fields
} Until (!$listItems.'@odata.nextLink')
}
}
end {
return $allListItems
}
}
$siteID = ''
$listID = ''
$clientID = ''
$tenantID = ''
$clientSecret = ''
$accessToken = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
Get-ListItems -siteID $siteID -listID $listID -accessToken $accessToken.access_token
Create Contacts Based on List Items
Next step is to take the data in the SharePoint List and create a new contact for a user, to achieve this we will be utilizing the Microsoft Graph API. The documentation for doing this can be found here.
Create New Contact
To create a new contact, we need to supply a user ID or UPN. In my example I use my test user, [email protected]. For test purposes, we are passing through a test contact named Pavel Bansky. We just want to confirm that we have everything working properly.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
$clientID = ''
$tenantID = ''
$clientSecret = ''
$userPrincipalName = ''
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
$body = @"
{
"givenName": "Pavel",
"surname": "Bansky",
"emailAddresses": [
{
"address": "[email protected]",
"name": "Pavel Bansky"
}
],
"businessPhones": [
"+1 732 555 0102"
]
}
"@
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$userPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($Token.access_token)" }
Body = $Body
}
Invoke-RestMethod @request
Once I run that I can see that it ran successfully.
Jumping over to Outlook I can view my contacts list and see my newly created contact.
Create Contacts from SharePoint List
Now that we know our test is working as expected, the next step in the process is to parse the SharePoint List and create contacts based on the List data. For this test, I will be creating all the contacts in the List against a single user.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
Function Get-ListItems {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$siteID,
[Parameter(Mandatory)]
[string]$listID,
[Parameter(Mandatory)]
[string]$accessToken
)
begin {
$allListItems = @()
Write-Output "Getting list items from $listID"
$headers = @{
Authorization = "Bearer $accessToken"
}
$apiUrl = "https://graph.microsoft.com/v1.0/sites/$siteID/lists/$listID/items?expand=fields"
}
process {
$listItems = Invoke-RestMethod -Uri $apiURL -Headers $headers -Method GET
$allListItems += $listItems.value.fields
if ($listItems.'@odata.nextLink') {
do {
$listItems = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allListItems += $listItems.value.fields
} Until (!$listItems.'@odata.nextLink')
}
}
end {
return $allListItems
}
}
$clientID = ''
$tenantID = ''
$clientSecret = ''
$userPrincipalName = '[email protected]'
$SiteID = ''
$listID = ''
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
# Get Listitems
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
foreach ($i in $listItems) {
$body = @"
{
"givenName": "$($i.title)",
"surname": "$($i.surname)",
"businessPhones": [
"$($i.phoneNumber)"
]
}
"@
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$userprincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($Token.access_token)" }
Body = $Body
}
Invoke-RestMethod @request
}
In this example, I am getting all the list items, iterating through them and creating new contacts. Going back to Outlook I can see my newly created contacts.
Working with Duplicates
One problem we face is if we run the automation again it will create the contacts again, leaving us with duplicates. To get around this I am going to first check the phone number of the contact that is pending creation, if it is already in there, then make sure the firstname and lastname match what is in the SharePoint list.
By doing this, I am using the phone number as my source anchor. The reason I chose this as my anchor is because if an employee leaves and. anew employee takes that users phone number, I want the automation to just change the firstname and lastname value as the phone number will already be there.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
Function New-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter()]
[string]$contactFolderID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
If ($contactFolderID) {
Write-Output "Creating new contact in contact folder: $contactFolderID"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
Else {
Write-Output "Creating new contact outside of contact folder"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
}
End {
Invoke-RestMethod @request
}
}
Function Set-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$accessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter(Mandatory)]
[string]$matchcontactID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
$request = @{
Method = "PATCH"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts/$matchcontactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $accessToken" }
Body = $body
}
}
End {
Invoke-RestMethod @request
}
}
Function Get-ListItems {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$siteID,
[Parameter(Mandatory)]
[string]$listID,
[Parameter(Mandatory)]
[string]$accessToken
)
begin {
$allListItems = @()
Write-Output "Getting list items from $listID"
$headers = @{
Authorization = "Bearer $accessToken"
}
$apiUrl = "https://graph.microsoft.com/v1.0/sites/$siteID/lists/$listID/items?expand=fields"
}
process {
$listItems = Invoke-RestMethod -Uri $apiURL -Headers $headers -Method GET
$allListItems += $listItems.value.fields
if ($listItems.'@odata.nextLink') {
do {
$listItems = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allListItems += $listItems.value.fields
} Until (!$listItems.'@odata.nextLink')
}
}
end {
return $allListItems
}
}
Function Get-Contacts {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter()]
[string]$contactFolderID
)
Begin {
[system.array]$allContacts = @()
}
Process {
if ($contactfolderID) {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
Else {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
$contacts = Invoke-RestMethod @request
$allContacts += $contacts.value
if ($contacts.'@odata.nextLink') {
do {
$contacts = Invoke-RestMethod -Uri $contacts.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allContacts += $contacts.value
} Until (!$contacts.'@odata.nextLink')
}
}
End {
$allContacts
}
}
$clientID = ''
$tenantID = ''
$clientSecret = ''
$userPrincipalName = '[email protected]'
$SiteID = ''
$listID = ''
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
foreach ($Item in $listItems) {
#Check if the contact exists in the user's contacts
Write-Output "Getting all user contacts"
$userContacts = Get-Contacts -UserPrincipalName $userprincipalName -AccessToken $token.access_token
$Match = $userContacts | Where-Object { $_.businessPhones -contains $item.phoneNumber } | Select-Object -First 1
#If the contact phone number is present in the users contacts already, check if the first and last names match
If ($Match.givenName -eq $item.title -and $Match.surname -eq $item.surname) {
#If the first name and last name match, the contact does not need further updating
Write-Output "first and last names match for contact: $($item.phoneNumber)"
}
#If either the firstname or lastname don't match, update the contact
Elseif ($Match.givenName -ne $item.title -or $Match.surname -ne $item.surname -and $Null -ne $Match) {
Write-Output "The firstname or the lastname for the contact $($item.phoneNumber) do not match. Updating contact"
Set-Contact -UserPrincipalName $userprincipalName -accessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber -matchcontactID $Match.id
}
#If there is no matching contact, we must create a new contact
Else {
Write-Output "No matching contact found for $($item.phoneNumber). Creating new contact"
New-Contact -UserPrincipalName $userprincipalName -AccessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber
}
}
Before I run this, I am going to change the contact for Bill Nye in the SharePoint list to be William Nye. The automation will see the phone number is already in my contacts and update the contact.
Going back to Outlook, the contact is instantly update.
Work Contacts vs Personal
The next hurdle was separating work contacts (i.e. the contacts the automation is managing) vs contacts the end user may have or may in the future, create. If we do not separate the automation contacts and the other contacts, we will have no way of knowing which contacts might’ve been deleted.
For example, if a contact is removed from the SharePoint List the automation needs to look at the user’s contacts and compare against the list. If all the users have a contact named “Jerry” but the list does not have it anymore, it can safely assume that the contact was deleted out of the list and now it needs to be deleted for all of the users.
If we did not separate the contacts out, any contact the users create in their contacts list will be deleted.
To achieve this, we can create a new Contact Folder and save all contacts in there. In the ‘new’ Outlook folders are replaced by Categories but they remain the same functionality.
In the image below we can see the contact “Bradley Wyatt” now has the “Work Contacts” category assigned to it. When the automation runs it will manage only contacts with this category assigned to it. The contact “Billy Buttlicker” is a contact that the end user created themselves.
Note: The code below is only a snippet of the larger working script
The code below shows we introduce two new functions. New-ContactFolder and Get-ContactFolders. If the contact folder is not created, create it and manage contacts within. If it exists, then we need to see all the contacts in there.
Function New-ContactFolder {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$UserPrincipalName
)
Begin {
Write-Output "Creating new contact folder: $Name"
$body = @"
{
"displayName": "$Name"
}
"@
}
Process {
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$userprincipalName/contactFolders"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
Body = $Body
}
$Post = Invoke-RestMethod @request
}
End {
return $Post
}
}
Function Get-ContactFolders {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken
)
Begin {
Write-Output "Getting contact folders for $UserPrincipalName"
}
Process {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
$Data = Invoke-RestMethod @request
}
End {
return $Data.Value
}
}
$ContactFolders = Get-ContactFolders -UserPrincipalName $userprincipalName -AccessToken $token.access_token
if ($ContactFolders.displayName -notcontains $contactfolderName) {
$workContactsID = (New-ContactFolder -Name $contactfolderName -UserPrincipalName $userprincipalName -AccessToken $token.access_token).id
}
else {
$workcontactsID = ($ContactFolders | Where-Object { $_.displayName -eq "$contactfolderName" }).id
}
Iterating Through All Users
Up until this point, we have been supplying a single users UserPrincipalName and making changes to it. Next, I want to get all users and make the change to all of them. In the example below, I created a new function to get all users then I filter out. theusers with no mail attribute and also at least 1 assigned license to the account and then iterate through them all.
Note: The code below is only a snippet of the larger working script
Function Get-Users {
Param (
[Parameter(Mandatory)]
[system.string]$AccessToken
)
Begin {
[system.array]$AlluserItems = @()
Write-Output "Getting all users"
$APIendpoint = 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,assignedLicenses,assignedPlans,userprincipalname,mail'
}
Process {
$request = @{
Method = "Get"
Uri = $APIendpoint
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
$Users = Invoke-RestMethod @request
$AlluserItems += $Users.value
if ($Users.'@odata.nextLink') {
do {
$Users = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$AlluserItems += $AlluserItems += $Users.value
} Until (!$Users.'@odata.nextLink')
}
}
End {
$AlluserItems
}
}
$users = Get-Users -accessToken $token.access_token | Where-Object {($null -ne $_.mail) -and ($_.assignedLicenses -ne $null)}
foreach ($user in $users) {
}
Final Script (Non-Azure Automation)
Below is the final working script, you just need to edit the variables on lines 321-329.
Note: It’s not recommended to hard-code secret values into scripts. In. the next section I will be leveraging Azure Automation to not only run the script but also keep my secrets secret.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
Write-Output "Connecting to Graph API"
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
Function New-ContactFolder {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$UserPrincipalName
)
Begin {
Write-Output "Creating new contact folder: $Name"
$body = @"
{
"displayName": "$Name"
}
"@
}
Process {
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$userprincipalName/contactFolders"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
Body = $Body
}
$Post = Invoke-RestMethod @request
}
End {
return $Post
}
}
Function Get-ContactFolders {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken
)
Begin {
Write-Output "Getting contact folders for $UserPrincipalName"
}
Process {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
$Data = Invoke-RestMethod @request
}
End {
return $Data.Value
}
}
Function Get-ListItems {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$siteID,
[Parameter(Mandatory)]
[string]$listID,
[Parameter(Mandatory)]
[string]$accessToken
)
begin {
$allListItems = @()
Write-Output "Getting list items from $listID"
$headers = @{
Authorization = "Bearer $accessToken"
}
$apiUrl = "https://graph.microsoft.com/v1.0/sites/$siteID/lists/$listID/items?expand=fields"
}
process {
$listItems = Invoke-RestMethod -Uri $apiURL -Headers $headers -Method GET
$allListItems += $listItems.value.fields
if ($listItems.'@odata.nextLink') {
do {
$listItems = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allListItems += $listItems.value.fields
} Until (!$listItems.'@odata.nextLink')
}
}
end {
return $allListItems
}
}
Function New-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter()]
[string]$contactFolderID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
If ($contactFolderID) {
Write-Output "Creating new contact in contact folder: $contactFolderID"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
Else {
Write-Output "Creating new contact outside of contact folder"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
}
End {
Invoke-RestMethod @request
}
}
Function Set-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$accessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter(Mandatory)]
[string]$matchcontactID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
$request = @{
Method = "PATCH"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts/$matchcontactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $accessToken" }
Body = $body
}
}
End {
Invoke-RestMethod @request
}
}
Function Get-Contacts {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter()]
[string]$contactFolderID
)
Begin {
[system.array]$allContacts = @()
}
Process {
if ($contactfolderID) {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
Else {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
$contacts = Invoke-RestMethod @request
$allContacts += $contacts.value
if ($contacts.'@odata.nextLink') {
do {
$contacts = Invoke-RestMethod -Uri $contacts.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allContacts += $contacts.value
} Until (!$contacts.'@odata.nextLink')
}
}
End {
$allContacts
}
}
Function Remove-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$contactID,
[Parameter()]
[string]$contactFolderID
)
Begin {
Write-Output "Removing contact: $contactID for user $UserPrincipalName"
}
Process {
If ($contactFolderID) {
Write-Output "Removing contact in contact folder: $contactFolderID"
$request = @{
Method = "Delete"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts/$contactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
}
Else {
Write-Output "Removing contact outside of contact folder"
$request = @{
Method = "Delete"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts/$contactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
}
}
End {
Invoke-RestMethod @request
}
}
Function Get-Users {
Param (
[Parameter(Mandatory)]
[system.string]$AccessToken
)
Begin {
[system.array]$AlluserItems = @()
Write-Output "Getting all users"
$APIendpoint = 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,assignedLicenses,assignedPlans,userprincipalname,mail'
}
Process {
$request = @{
Method = "Get"
Uri = $APIendpoint
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
$Users = Invoke-RestMethod @request
$AlluserItems += $Users.value
if ($Users.'@odata.nextLink') {
do {
$Users = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$AlluserItems += $AlluserItems += $Users.value
} Until (!$Users.'@odata.nextLink')
}
}
End {
$AlluserItems
}
}
[system.string]$contactfolderName = 'Work Contacts'
#$clientId = Get-AutomationVariable -Name "clientID"
$clientid = ''
#$tenantID = Get-AutomationVariable -Name "tenantID"
$tenantid = ''
#$clientSecret = Get-AutomationVariable -Name "clientSecret"
$clientsecret = ''
#$siteID = Get-AutomationVariable -Name "siteID"
$siteid = ''
#$listID = Get-AutomationVariable -Name "listID"
$listid = ''
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
[system.int32]$countUsers = 0
#Get all users that have a mail attribute
$users = Get-Users -accessToken $token.access_token | Where-Object {($null -ne $_.mail) -and ($_.assignedLicenses -ne $null)}
foreach ($user in $users) {
$countUsers ++
[system.int32]$listcount = 1
Write-Output "---- Working on user $countUsers of $($users.count) ----"
$userprincipalName = $user.userprincipalName
Write-Output "Working on user: $userprincipalName"
$ContactFolders = Get-ContactFolders -UserPrincipalName $userprincipalName -AccessToken $token.access_token
if ($ContactFolders.displayName -notcontains $contactfolderName) {
$workContactsID = (New-ContactFolder -Name $contactfolderName -UserPrincipalName $userprincipalName -AccessToken $token.access_token).id
}
else {
$workcontactsID = ($ContactFolders | Where-Object { $_.displayName -eq "$contactfolderName" }).id
}
Write-Output "Work Contacts ID: $workContactsID"
#Get list items and iterate through them
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
foreach ($Item in $listItems) {
Write-Output "---- Working on list item $listcount of $($listitems.count) ----"
#Check if the contact exists in the user's contacts
Write-Output "Working on $($item.phoneNumber) from SharePoint list"
$userContacts = Get-Contacts -UserPrincipalName $userprincipalName -contactFolderID $workContactsID -AccessToken $token.access_token
Write-output "Checking if contact: $($item.phoneNumber) exists in user contacts"
$Match = $userContacts | Where-Object { $_.businessPhones -contains $item.phoneNumber } | Select-Object -First 1
#If the contact phone number is present in the users contacts already, check if the first and last names match
If ($Match.givenName -eq $item.title -and $Match.surname -eq $item.surname) {
#If the first name and last name match, the contact does not need further updating
Write-Output "first and last names match for contact: $($item.phoneNumber)"
}
#If either the firstname or lastname don't match, update the contact
Elseif ($Match.givenName -ne $item.title -or $Match.surname -ne $item.surname -and $Null -ne $Match) {
Write-Output "The firstname or the lastname for the contact $($item.phoneNumber) do not match. Updating contact"
Set-Contact -UserPrincipalName $userprincipalName -accessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber -matchcontactID $Match.id
}
#If there is no matching contact, we must create a new contact
Else {
Write-Output "No matching contact found for $($item.phoneNumber). Creating new contact"
New-Contact -UserPrincipalName $userprincipalName -AccessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber -contactFolderID $workContactsID
}
$listcount++
}
#Refresh the list of contacts and list items so we are working with the most current data
$userContacts = Get-Contacts -UserPrincipalName $userprincipalName -contactFolderID $workContactsID -AccessToken $token.access_token
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
#Get all contacts that are not in the SharePoint list
$removeContacts = $userContacts | Where-Object { ($_.givenName -notin $listItems.title ) -or ($_.surname -notin $listItems.surname) }
foreach ($i in $removeContacts) {
Write-Output "Removing contact: givenName: $($item.givenName) surname: $($item.surname)"
Remove-Contact -UserPrincipalName $userprincipalName -accessToken $token.access_token -contactID $i.id -contactFolderID $workContactsID
}
}
Automate Contact Syncing
As the script stands now, you can manually run it on a regular cadence, or you can leverage automation to have it automatically run and create contacts for your end users. In this example I will leverage Azure Automation to have it run on a schedule.
Once it has been created, navigate to the automation account and go to Variables and add new variables for ClientID, ClientSecret and TenantID.
Note: Make sure to check Yes on the encryption so they are encrypted.
I also chose to store the ListID and SiteID values as well.
Once you have finished creating your new variables, modify your script so it will grab the encrypted values during runtime.
$clientId = Get-AutomationVariable -Name "clientID"
$tenantID = Get-AutomationVariable -Name "tenantID"
$clientSecret = Get-AutomationVariable -Name "clientSecret"
$siteID = Get-AutomationVariable -Name "siteID"
$listID = Get-AutomationVariable -Name "listID"
Finally, save your runbook. You can also grab the runbook from here.
Function Connect-GraphAPI {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$clientID,
[Parameter(Mandatory)]
[string]$tenantID,
[Parameter(Mandatory)]
[string]$clientSecret
)
begin {
Write-Output "Connecting to Graph API"
$ReqTokenBody = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $clientID
Client_Secret = $clientSecret
}
}
process {
$tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody
}
end {
return $tokenResponse
}
}
Function New-ContactFolder {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$UserPrincipalName
)
Begin {
Write-Output "Creating new contact folder: $Name"
$body = @"
{
"displayName": "$Name"
}
"@
}
Process {
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$userprincipalName/contactFolders"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
Body = $Body
}
$Post = Invoke-RestMethod @request
}
End {
return $Post
}
}
Function Get-ContactFolders {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken
)
Begin {
Write-Output "Getting contact folders for $UserPrincipalName"
}
Process {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
$Data = Invoke-RestMethod @request
}
End {
return $Data.Value
}
}
Function Get-ListItems {
[CmdletBinding()]
Param (
[Parameter(Mandatory)]
[string]$siteID,
[Parameter(Mandatory)]
[string]$listID,
[Parameter(Mandatory)]
[string]$accessToken
)
begin {
$allListItems = @()
Write-Output "Getting list items from $listID"
$headers = @{
Authorization = "Bearer $accessToken"
}
$apiUrl = "https://graph.microsoft.com/v1.0/sites/$siteID/lists/$listID/items?expand=fields"
}
process {
$listItems = Invoke-RestMethod -Uri $apiURL -Headers $headers -Method GET
$allListItems += $listItems.value.fields
if ($listItems.'@odata.nextLink') {
do {
$listItems = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allListItems += $listItems.value.fields
} Until (!$listItems.'@odata.nextLink')
}
}
end {
return $allListItems
}
}
Function New-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter()]
[string]$contactFolderID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
If ($contactFolderID) {
Write-Output "Creating new contact in contact folder: $contactFolderID"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
Else {
Write-Output "Creating new contact outside of contact folder"
$request = @{
Method = "Post"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
Body = $Body
}
}
}
End {
Invoke-RestMethod @request
}
}
Function Set-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$accessToken,
[Parameter(Mandatory)]
[string]$givenName,
[Parameter(Mandatory)]
[string]$surname,
[Parameter(Mandatory)]
[string]$businessPhone,
[Parameter(Mandatory)]
[string]$matchcontactID
)
Begin {
$body = @"
{
"givenName": "$givenName",
"surname": "$surname",
"businessPhones": [
"$businessPhone"
]
}
"@
}
Process {
$request = @{
Method = "PATCH"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts/$matchcontactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $accessToken" }
Body = $body
}
}
End {
Invoke-RestMethod @request
}
}
Function Get-Contacts {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter()]
[string]$contactFolderID
)
Begin {
[system.array]$allContacts = @()
}
Process {
if ($contactfolderID) {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
Else {
$request = @{
Method = "Get"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($AccessToken)" }
}
}
$contacts = Invoke-RestMethod @request
$allContacts += $contacts.value
if ($contacts.'@odata.nextLink') {
do {
$contacts = Invoke-RestMethod -Uri $contacts.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$allContacts += $contacts.value
} Until (!$contacts.'@odata.nextLink')
}
}
End {
$allContacts
}
}
Function Remove-Contact {
Param (
[Parameter(Mandatory)]
[string]$UserPrincipalName,
[Parameter(Mandatory)]
[string]$AccessToken,
[Parameter(Mandatory)]
[string]$contactID,
[Parameter()]
[string]$contactFolderID
)
Begin {
Write-Output "Removing contact: $contactID for user $UserPrincipalName"
}
Process {
If ($contactFolderID) {
Write-Output "Removing contact in contact folder: $contactFolderID"
$request = @{
Method = "Delete"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contactFolders/$contactfolderID/contacts/$contactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
}
Else {
Write-Output "Removing contact outside of contact folder"
$request = @{
Method = "Delete"
Uri = "https://graph.microsoft.com/v1.0/users/$UserPrincipalName/contacts/$contactID"
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
}
}
End {
Invoke-RestMethod @request
}
}
Function Get-Users {
Param (
[Parameter(Mandatory)]
[system.string]$AccessToken
)
Begin {
[system.array]$AlluserItems = @()
Write-Output "Getting all users"
$APIendpoint = 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,assignedLicenses,assignedPlans,userprincipalname,mail'
}
Process {
$request = @{
Method = "Get"
Uri = $APIendpoint
ContentType = "application/json"
Headers = @{ Authorization = "Bearer $($accessToken)" }
}
$Users = Invoke-RestMethod @request
$AlluserItems += $Users.value
if ($Users.'@odata.nextLink') {
do {
$Users = Invoke-RestMethod -Uri $listItems.'@odata.nextLink' -Headers @{ Authorization = "Bearer $($AccessToken)" } -Method "Get" -ContentType "application/json"
$AlluserItems += $AlluserItems += $Users.value
} Until (!$Users.'@odata.nextLink')
}
}
End {
$AlluserItems
}
}
[system.string]$contactfolderName = 'Work Contacts'
$clientId = Get-AutomationVariable -Name "clientID"
$tenantID = Get-AutomationVariable -Name "tenantID"
$clientSecret = Get-AutomationVariable -Name "clientSecret"
$siteID = Get-AutomationVariable -Name "siteID"
$listID = Get-AutomationVariable -Name "listID"
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret $clientSecret
[system.int32]$countUsers = 0
#Get all users that have a mail attribute
$users = Get-Users -accessToken $token.access_token | Where-Object {($null -ne $_.mail) -and ($_.assignedLicenses -ne $null)}
foreach ($user in $users) {
$countUsers ++
[system.int32]$listcount = 1
Write-Output "---- Working on user $countUsers of $($users.count) ----"
$userprincipalName = $user.userprincipalName
Write-Output "Working on user: $userprincipalName"
$ContactFolders = Get-ContactFolders -UserPrincipalName $userprincipalName -AccessToken $token.access_token
if ($ContactFolders.displayName -notcontains $contactfolderName) {
$workContactsID = (New-ContactFolder -Name $contactfolderName -UserPrincipalName $userprincipalName -AccessToken $token.access_token).id
}
else {
$workcontactsID = ($ContactFolders | Where-Object { $_.displayName -eq "$contactfolderName" }).id
}
Write-Output "Work Contacts ID: $workContactsID"
#Get list items and iterate through them
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
foreach ($Item in $listItems) {
Write-Output "---- Working on list item $listcount of $($listitems.count) ----"
#Check if the contact exists in the user's contacts
Write-Output "Working on $($item.phoneNumber) from SharePoint list"
$userContacts = Get-Contacts -UserPrincipalName $userprincipalName -contactFolderID $workContactsID -AccessToken $token.access_token
Write-output "Checking if contact: $($item.phoneNumber) exists in user contacts"
$Match = $userContacts | Where-Object { $_.businessPhones -contains $item.phoneNumber } | Select-Object -First 1
#If the contact phone number is present in the users contacts already, check if the first and last names match
If ($Match.givenName -eq $item.title -and $Match.surname -eq $item.surname) {
#If the first name and last name match, the contact does not need further updating
Write-Output "first and last names match for contact: $($item.phoneNumber)"
}
#If either the firstname or lastname don't match, update the contact
Elseif ($Match.givenName -ne $item.title -or $Match.surname -ne $item.surname -and $Null -ne $Match) {
Write-Output "The firstname or the lastname for the contact $($item.phoneNumber) do not match. Updating contact"
Set-Contact -UserPrincipalName $userprincipalName -accessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber -matchcontactID $Match.id
}
#If there is no matching contact, we must create a new contact
Else {
Write-Output "No matching contact found for $($item.phoneNumber). Creating new contact"
New-Contact -UserPrincipalName $userprincipalName -AccessToken $token.access_token -givenName $Item.title -surname $Item.surname -businessPhone $Item.phoneNumber -contactFolderID $workContactsID
}
$listcount++
}
#Refresh the list of contacts and list items so we are working with the most current data
$userContacts = Get-Contacts -UserPrincipalName $userprincipalName -contactFolderID $workContactsID -AccessToken $token.access_token
$listItems = Get-ListItems -siteID $siteID -listID $listID -accessToken $Token.access_token
#Get all contacts that are not in the SharePoint list
$removeContacts = $userContacts | Where-Object { ($_.givenName -notin $listItems.title ) -or ($_.surname -notin $listItems.surname) }
foreach ($i in $removeContacts) {
Write-Output "Removing contact: givenName: $($item.givenName) surname: $($item.surname)"
Remove-Contact -UserPrincipalName $userprincipalName -accessToken $token.access_token -contactID $i.id -contactFolderID $workContactsID
}
}
Once it. has been saved, I would suggest adding a schedule so the automation runbook can run on a regular schedule.
Configure Outlook to Sync Contacts to Native Apps.
The last item we must do, is create an application configuration policy to force end users to sync Outlook contacts to their phones. This last step ensures that users can just open the native contacts app and they will see the shared contacts.
The benefit of App Configuration Policies is that it does not require the mobile device to be fully enrolled. Although, since we are allowing company contacts to be synced to the phone’s native app, it is recommended.
Go to Intune.microsoft.com > Apps > App Configuration Policies > + Add > Managed Apps
Give the new policy a proper name and target it to All Apps, then click Next
Next, in Save Contacts, click Yes and I prefer to not allow the end user from changing the setting.
Lastly, apply the policy to a group. In my example I am applying it to a test group.
Mobile Device Results
On my test phone I can now see that the setting to save contacts is enabled, I cannot modify it, and the contact is in the native contacts app.
My name is Bradley Wyatt; I am a 5x Microsoft Most Valuable Professional (MVP) in Microsoft Azure and Microsoft 365. I have given talks at many different conferences, user groups, and companies throughout the United States, ranging from PowerShell to DevOps Security best practices, and I am the 2022 North American Outstanding Contribution to the Microsoft Community winner.
14 thoughts on “Centrally Manage Company Contacts and Deploy to Built-In Contacts App Using Intune, SharePoint, PowerShell and Graph API.”
I am curious – how many contacts / end users have you tested this approach with – and how long does the script take to execute with your maximum? I am curious about scalability limits before you hit the 3-hour maximum in Azure Automations.
Our current solution is having a shared mailbox with the contacts in, and make our team add that to their Outlook iOS app – the contact syncing then pulls them down into the device’s phone.
great question, it took 1min 7seconds for approx 150 users and 15 contacts, although the creation of the contacts through the Graph API is very quick.
If I assume on the conservative side that it takes 1min per 100 users, if my math is correct, you may want to consider breaking this into multiple runbooks around 18,000 users.
Oh, that’s surprisingly fast. I shall have to give this a go – thanks for sharing.
Hi Brad,
Thank you for your detailed work on this – it’s something I’ve always hoped to implement.
I’ve noticed an error (at least with my limited knowledge) for the code under the heading ‘Create new contact’. The line:
$token = Connect-GraphAPI -clientID $clientID -tenantID $tenantID -clientSecret
is missing $clientSecret at the end?
I’m then trying the code under ‘Create Contacts from SharePoint List’ (after copying across the working tenant ID, secret etc), but getting the following error:
Invoke-RestMethod : The remote server returned an error: (404) Not Found.
At line:20 char:22
+ … nResponse = Invoke-RestMethod -Uri “https://login.microsoftonline.com …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-RestMethod], WebExc
eption
+ FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand
Get-ListItems : Cannot bind argument to parameter ‘accessToken’ because it is an empty string.
At line:63 char:73
+ … tems -siteID $siteID -listID $listID -accessToken $Token.access_token
+ ~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Get-ListItems], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationErrorEmptyStringNotAllowed,Get-ListItems
Any ideas please? Authentication has worked on all steps up to now, so confused as to why it doesn’t like this code. Thank you!
Yes you are 100% correct, good catch. I was missing $clientSecret. I have since resolved it
Thanks. I resolved the error by correcting the tenant ID, I incorrectly copied the wrong string. I’ve successfully populated Outlook with my list of contacts, but the next step to ensure amendments don’t generate duplicates doesn’t appear to work for me – it generates identical contacts. My list uses different field names so I’ve ensured they match the first script, and used replace all to ensure nothing was missed. Do you have any ideas?
Are you also using the phone number as a source anchor? If you can, can you contact me at bradwyatt.me; it’ll be quicker than going back and forth here
edit: This has been resolved, the issue was with pagination
Hi Brad!
Please inform me if, you can do this on free Azure.
Depends on how many users and contacts you have, but runtime costs for serverless are fractions of a cent.
The problem with this approach (i’ve tried something different, but the approach is the same) is that if you delete a contact on the sp list, the contact will be deleted in outlook mobile but not in the contacts folder of the phone 😐
Thank you for writing this article. If this works then you make me happy.
In the Final Script (Non-Azure Automation) i get the error below. Can you help me?
Invoke-RestMethod : Cannot validate argument on parameter ‘Uri’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At line:308 char:41
+ … $Users = Invoke-RestMethod -Uri $listItems.’@odata.nextLink’ -Header …
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Invoke-RestMethod], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.PowerShell.Commands.InvokeRestMethodCommand
You have to modify two lines in the Get-Users function:
Instead of:
#$Users = Invoke-RestMethod -Uri $listItems.’@odata.nextLink’ -Headers @{ Authorization = “Bearer $($AccessToken)” } -Method “Get” -ContentType “application/json”
$Users = Invoke-RestMethod -Uri $Users.’@odata.nextLink’ -Headers @{ Authorization = “Bearer $($AccessToken)” } -Method “Get” -ContentType “application/json”
and
#$AlluserItems += $AlluserItems += $Users.value
$AlluserItems += $Users.value
The solution is working for us. We use work profiles and the only complain we receive is that the contacts from personal profile are not listed just can be searched which is by design as I understood.
I am only writing to say we used this as a base to a more complete solution (we included several other fiels in the syncronization), a complete life saver!
Big Kudos to Brad, we were on the verge of paying a third party solution and not only saved us this money but also gave us the satisfaction to be able to resolve this need with our own tools.
Hi. Thanks for great job. I wonder if modifted script could add contacts from sp list to certain eploee or group of employee instead to all like it is now?