Skip to content
The Lazy Administrator
  • Home
  • Disclaimer
  • Contact
  • About Me
  • Search Icon

The Lazy Administrator

Finding ways to do the most work with the least effort possible

Centrally Manage Company Contacts and Deploy to Built-In Contacts App Using Intune, SharePoint, PowerShell and Graph API.

Centrally Manage Company Contacts and Deploy to Built-In Contacts App Using Intune, SharePoint, PowerShell and Graph API.

September 17, 2023 Brad Wyatt Comments 14 comments

Table of Contents

  • Create the SharePoint Site
  • Get SharePoint List Property Names
  • Create Azure AD App for Graph API Access
  • View List Items
  • Create Contacts Based on List Items
    • Create New Contact
    • Create Contacts from SharePoint List
    • Working with Duplicates
    • Work Contacts vs Personal
    • Iterating Through All Users
    • Final Script (Non-Azure Automation)
  • Automate Contact Syncing
  • Configure Outlook to Sync Contacts to Native Apps.
  • Mobile Device Results

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.

Brad Wyatt
Brad Wyatt

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.


DevOps, Graph, PowerShell
API, Automation, Azure, Contacts, Endpoint, Graph, Intune, PowerShell, REST

Post navigation

PREVIOUS
Windows LAPS Management, Configuration and Troubleshooting Using Microsoft Intune
NEXT
Getting Started with the IntuneCLI, an Automated Intune Management Solution

14 thoughts on “Centrally Manage Company Contacts and Deploy to Built-In Contacts App Using Intune, SharePoint, PowerShell and Graph API.”

  1. JohnMSP says:
    September 18, 2023 at 6:03 am

    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.

    Reply
    1. Brad Wyatt says:
      September 18, 2023 at 1:33 pm

      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.

      Reply
      1. JohnMSP says:
        September 19, 2023 at 1:53 am

        Oh, that’s surprisingly fast. I shall have to give this a go – thanks for sharing.

        Reply
  2. Darren says:
    September 19, 2023 at 6:32 am

    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!

    Reply
    1. Brad Wyatt says:
      September 19, 2023 at 1:09 pm

      Yes you are 100% correct, good catch. I was missing $clientSecret. I have since resolved it

      Reply
      1. Darren says:
        September 19, 2023 at 1:28 pm

        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?

        Reply
        1. Brad Wyatt says:
          September 19, 2023 at 3:05 pm

          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

          Reply
  3. Robert says:
    November 25, 2023 at 2:39 am

    Hi Brad!
    Please inform me if, you can do this on free Azure.

    Reply
    1. Brad Wyatt says:
      December 8, 2023 at 10:42 pm

      Depends on how many users and contacts you have, but runtime costs for serverless are fractions of a cent.

      Reply
  4. Nicola says:
    May 11, 2024 at 10:40 am

    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 😐

    Reply
  5. Stephan says:
    May 24, 2024 at 7:53 am

    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

    Reply
    1. Balage says:
      June 7, 2024 at 6:47 am

      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.

      Reply
  6. Albert says:
    August 14, 2024 at 4:16 am

    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.

    Reply
  7. Dominik says:
    October 17, 2024 at 12:07 pm

    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?

    Reply

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Subscribe

Email


Categories

  • Active Directory (8)
  • AI (3)
  • API (1)
  • AutoPilot (2)
  • Azure (15)
  • Bicep (4)
  • Connectwise (1)
  • Defender for Cloud Apps (1)
  • Delegated Admin (1)
  • DevOps (6)
  • Graph (6)
  • Intune (15)
  • LabTech (1)
  • Microsoft Teams (6)
  • Office 365 (19)
  • Permissions (2)
  • PowerShell (50)
  • Security (1)
  • SharePoint (3)
  • Skype for Business (1)
  • Terraform (1)
  • Uncategorized (2)
  • Yammer (1)

Recent Comments

  • Kristopher Gates on Getting Started with GitHub Copilot in the CLI
  • MD SHARIQUE AKHTAR on Modern Active Directory – An update to PSHTML-AD-Report
  • TommyBoich on How The ConnectWise Manage API Handles Pagination with PowerShell
  • LOTTERY 365 LOGIN on Windows LAPS Management, Configuration and Troubleshooting Using Microsoft Intune
  • SPRUNKI PHASE 6 on Get a New Computer’s Auto Pilot Hash Without Going Through the Out of Box Experience (OOBE)

1,739,405 People Reached

© 2025   All Rights Reserved.