Office 365

Download profile pictures from Microsoft Office 365 and Upload to Interact

The following is an example script that will download profile pictures from Microsoft Office 365 and upload those images to Interact Profiles.

Azure Application Registration

It requires the creation of an Azure Application Registration, with the following permission, this is so that all users can be found via MSGRAPH and the profile image, if available can be downloaded.

When you create the application you will need to record the following:

  • Client ID
  • Client Secret
  • Tenant ID

Interact Profile Source

You will also need to create a General Profile Source in Interact and obtain the Profile Source ID and Profile Source API Key

Script Flow

The script undertakes the following steps:

  1. Get list of users
  2. Download Profile Images (where available) for each user to a directory
  3. Check if the file is the same as the one in the lastUploaded directory, if it is, it removes it from the download directory.
  4. Uploads all remaining files to Interact using the UMIID
  5. Moves the uploaded files to the Last Uploaded Folder
  6. Moves failed files to the Failed Upload Folder
📘

NOTE: This script depends on the UMIUID (External ID) within a users Interact Profile as being the ObjectID of the users profile in EntraID.

Complete Script

# Input Parameters
$downloadFolder = "c:\O365\DownloadedPictures"
$lastUploadFolder = "c:\O365\LastUploadPictures"
$failedUploadFolder = "c:\O365\FailedUploadPictures"

$logpath = "c:\O365\log.txt"

# Interact Upload Parameters
$interactUrl = <Interact Instance URL>"
$profileSourceApiKey = "<Profile Source API Key>"
$profileSourceId = "<Profile Source ID>"
$enableUpload = $true  # Set to $false to disable upload

Add-Type -AssemblyName System.Net.Http

# Create directories if they don't exist
if (-not (Test-Path -Path $downloadFolder)) {
    New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $lastUploadFolder)) {
    New-Item -ItemType Directory -Path $lastUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $failedUploadFolder)) {
    New-Item -ItemType Directory -Path $failedUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path (Split-Path -Path $logpath -Parent))) {
    New-Item -ItemType Directory -Path (Split-Path -Path $logpath -Parent) -Force | Out-Null
}

# Azure AD App Registration Details
$ClientId = "<Client ID>"
$ClientSecret = "<Client Secret>"
$TenantId = "<Tenant ID>"

# Step 1: Get the access token
$TokenBody = @{
    client_id     = $ClientId
    client_secret = $ClientSecret
    scope         = "https://graph.microsoft.com/.default"
    grant_type    = "client_credentials"
}

$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method Post -Body $TokenBody
$AccessToken = $TokenResponse.access_token

# Check if the token was retrieved successfully
if ($null -eq $AccessToken) {
    Write-Error "Failed to retrieve access token."
    exit
}

# Step 2: Create the authorization header with the access token
$headers = @{
    "Authorization" = "Bearer $AccessToken"
    "Content-Type"  = "application/json"
}

# Step 3: Get list of all users (pagination support for large tenant)
$usersUri = "https://graph.microsoft.com/v1.0/users"
$allUsers = @()

do {
    $response = Invoke-RestMethod -Uri $usersUri -Method Get -Headers $headers
    $allUsers += $response.value

    # Get next page of results if present
    $usersUri = $response.'@odata.nextLink'
} while ($usersUri)

# Initialize statistics counters
$stats = @{
    TotalUsers = $allUsers.Count
    Downloaded = 0
    NoProfilePic = 0
    DownloadErrors = 0
    DuplicatesRemoved = 0
    PicturesChanged = 0
    Uploaded = 0
    UploadFailed = 0
}

# Step 4: Download profile pictures
foreach ($user in $allUsers) {
    $userId = $user.id
    $path = Join-Path $downloadFolder "$userId.jpg"

    try {
        # Fetch the user photo (use headers without Content-Type for binary data)
        $photoHeaders = @{
            "Authorization" = "Bearer $AccessToken"
        }
        $photoUri = "https://graph.microsoft.com/v1.0/users/$userId/photos/648x648/`$value"
        
        # Use -OutFile to directly save binary data to disk
        # ContentType parameter forces treating response as binary
        Invoke-RestMethod -Uri $photoUri -Method Get -Headers $photoHeaders -OutFile $path -ErrorAction Stop -ContentType "image/jpeg"
        
        $stats.Downloaded++
        $message = "$($user.userPrincipalName) profile picture downloaded"
        Write-Output $message
        Add-Content -Path $logpath -Value $message
    }
    catch {
        if ($_.Exception.Response.StatusCode.Value__ -eq 404) {
            $stats.NoProfilePic++
            $message = "$($user.userPrincipalName) has no profile picture"
            Write-Output $message
            Add-Content -Path $logpath -Value $message
        }
        else {
            $stats.DownloadErrors++
            $errorMessage = "Error downloading profile picture for $($user.userPrincipalName): $($_.Exception.Message)"
            Write-Error $errorMessage
            Add-Content -Path $logpath -Value $errorMessage
        }
    }
}

# Step 4.5: Remove already uploaded files from download folder (comparing file hashes)
Write-Output "`nChecking for already uploaded files..."
Add-Content -Path $logpath -Value "`n=== Checking for Duplicates ==="

$removedCount = 0
$changedCount = 0
$downloadedFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"

foreach ($downloadedFile in $downloadedFiles) {
    $lastUploadedFilePath = Join-Path $lastUploadFolder $downloadedFile.Name
    
    if (Test-Path $lastUploadedFilePath) {
        try {
            # Compare file hashes to detect if content has changed
            $downloadedHash = (Get-FileHash -Path $downloadedFile.FullName -Algorithm SHA256).Hash
            $uploadedHash = (Get-FileHash -Path $lastUploadedFilePath -Algorithm SHA256).Hash
            
            if ($downloadedHash -eq $uploadedHash) {
                # Files are identical, remove from download folder
                Remove-Item -Path $downloadedFile.FullName -Force
                $message = "Removed duplicate file (unchanged): $($downloadedFile.Name)"
                Write-Output $message
                Add-Content -Path $logpath -Value $message
                $removedCount++
            }
            else {
                # File content has changed, keep for upload
                $message = "Picture changed for: $($downloadedFile.Name) - will be uploaded"
                Write-Output $message
                Add-Content -Path $logpath -Value $message
                $changedCount++
            }
        }
        catch {
            $errorMessage = "Failed to compare file $($downloadedFile.Name): $($_.Exception.Message)"
            Write-Warning $errorMessage
            Add-Content -Path $logpath -Value $errorMessage
        }
    }
}

$stats.DuplicatesRemoved = $removedCount
$stats.PicturesChanged = $changedCount
$duplicateMessage = "Removed $removedCount duplicate files (unchanged), Found $changedCount pictures that changed"
Write-Output $duplicateMessage
Add-Content -Path $logpath -Value $duplicateMessage

# Upload Functions
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

function ProcessFiles() {
    Write-Output "`nStarting upload process..."
    Add-Content -Path $logpath -Value "`n=== Upload Process Started ==="
    
    $filesPerMinute = 6
    $uploadCounter = 0
    $allFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
    $totalFiles = $allFiles.Count
    
    $allFiles | Foreach-Object {
        $umiid = $_.BaseName
        $baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
        Write-Output "Uploading $($_.FullName) to $baseUri"
        $success = SendFile $baseUri $_.FullName
        if ($success) {
            $stats.Uploaded++
        } else {
            $stats.UploadFailed++
        }
        
        $uploadCounter++
        
        # Rate limiting: Wait after every 6 uploads to avoid Error 429
        if ($uploadCounter -eq $filesPerMinute -and ($stats.Uploaded + $stats.UploadFailed) -lt $totalFiles) {
            $rateLimitMessage = "Rate limit: Uploaded $uploadCounter files. Waiting 60 seconds before continuing..."
            Write-Output $rateLimitMessage
            Add-Content -Path $logpath -Value $rateLimitMessage
            Start-Sleep -Seconds 60
            $uploadCounter = 0
        }
      }
    
    $uploadMessage = "Upload complete: $($stats.Uploaded) successful, $($stats.UploadFailed) failed"
    Write-Output $uploadMessage
    Add-Content -Path $logpath -Value $uploadMessage
}

function SendFile([string]$uri, [string]$path) {
    $httpClientHandler = New-Object System.Net.Http.HttpClientHandler
    $httpClient = New-Object System.Net.Http.Httpclient $httpClientHandler
    $response = $null
    $packageFileStream = $null
    $success = $false

    try {
        $packageFileStream = New-Object System.IO.FileStream @($path, [System.IO.FileMode]::Open)
        
        $contentDispositionHeaderValue = New-Object System.Net.Http.Headers.ContentDispositionHeaderValue "form-data"
        $contentDispositionHeaderValue.Name = "fileData"
        $contentDispositionHeaderValue.FileName = (Split-Path $path -leaf)
 
        $streamContent = New-Object System.Net.Http.StreamContent $packageFileStream
        $streamContent.Headers.ContentDisposition = $contentDispositionHeaderValue
        $streamContent.Headers.ContentType = New-Object System.Net.Http.Headers.MediaTypeHeaderValue "image/jpeg"
        
        $content = New-Object System.Net.Http.MultipartFormDataContent
        $content.Add($streamContent)
 
        $httpClient.DefaultRequestHeaders.Add("X-ApiKey", $profileSourceApiKey)

        $response = $httpClient.PostAsync($uri, $content).Result
        
        if ($response.IsSuccessStatusCode) {
            $message = "Successfully uploaded: $(Split-Path $path -leaf)"
            Write-Output $message
            Add-Content -Path $logpath -Value $message
            $success = $true
        } else {
            $errorMessage = "Failed to upload $(Split-Path $path -leaf): $($response.StatusCode)"
            Write-Warning $errorMessage
            Add-Content -Path $logpath -Value $errorMessage
        }
    }
    catch [Exception] {
        $errorMessage = "Error uploading $(Split-Path $path -leaf): $($_.Exception.Message)"
        Write-Error $errorMessage
        Add-Content -Path $logpath -Value $errorMessage
    }
    finally {
        if ($null -ne $packageFileStream) {
            $packageFileStream.Dispose()
        }
        
        if ($null -ne $httpClient) {
            $httpClient.Dispose()
        }
 
        if ($null -ne $response) {
            $response.Dispose()
        }
    }
    
    # Move file to lastUploadFolder after successful upload, or to failedUploadFolder if failed
    if ($success) {
        try {
            $fileName = Split-Path $path -Leaf
            $destinationPath = Join-Path $lastUploadFolder $fileName
            Move-Item -Path $path -Destination $destinationPath -Force
            $moveMessage = "Moved $(Split-Path $path -leaf) to $lastUploadFolder"
            Write-Output $moveMessage
            Add-Content -Path $logpath -Value $moveMessage
        }
        catch {
            $moveErrorMessage = "Failed to move $(Split-Path $path -leaf): $($_.Exception.Message)"
            Write-Warning $moveErrorMessage
            Add-Content -Path $logpath -Value $moveErrorMessage
        }
    } else {
        try {
            $fileName = Split-Path $path -Leaf
            $destinationPath = Join-Path $failedUploadFolder $fileName
            Move-Item -Path $path -Destination $destinationPath -Force
            $moveMessage = "Moved failed upload $(Split-Path $path -leaf) to $failedUploadFolder"
            Write-Output $moveMessage
            Add-Content -Path $logpath -Value $moveMessage
        }
        catch {
            $moveErrorMessage = "Failed to move $(Split-Path $path -leaf) to failed folder: $($_.Exception.Message)"
            Write-Warning $moveErrorMessage
            Add-Content -Path $logpath -Value $moveErrorMessage
        }
    }
    
    return $success
}

# Step 5: Upload profile pictures to Interact
if ($enableUpload) {
    ProcessFiles
} else {
    Write-Output "`nUpload disabled. Set `$enableUpload = `$true to enable."
}

# Display Final Statistics
Write-Output "`n==========================================="
Write-Output "           FINAL STATISTICS"
Write-Output "==========================================="
Write-Output "Total Users Found:        $($stats.TotalUsers)"
Write-Output "Images Downloaded:        $($stats.Downloaded)"
Write-Output "No Profile Picture:       $($stats.NoProfilePic)"
Write-Output "Download Errors:          $($stats.DownloadErrors)"
Write-Output "Duplicates Removed:       $($stats.DuplicatesRemoved)"
Write-Output "Pictures Changed:         $($stats.PicturesChanged)"
Write-Output "Images Uploaded:          $($stats.Uploaded)"
Write-Output "Upload Failed:            $($stats.UploadFailed)"
Write-Output "==========================================="

# Write statistics to log
Add-Content -Path $logpath -Value "`n==========================================="
Add-Content -Path $logpath -Value "           FINAL STATISTICS"
Add-Content -Path $logpath -Value "==========================================="
Add-Content -Path $logpath -Value "Total Users Found:        $($stats.TotalUsers)"
Add-Content -Path $logpath -Value "Images Downloaded:        $($stats.Downloaded)"
Add-Content -Path $logpath -Value "No Profile Picture:       $($stats.NoProfilePic)"
Add-Content -Path $logpath -Value "Download Errors:          $($stats.DownloadErrors)"
Add-Content -Path $logpath -Value "Duplicates Removed:       $($stats.DuplicatesRemoved)"
Add-Content -Path $logpath -Value "Pictures Changed:         $($stats.PicturesChanged)"
Add-Content -Path $logpath -Value "Images Uploaded:          $($stats.Uploaded)"
Add-Content -Path $logpath -Value "Upload Failed:            $($stats.UploadFailed)"
Add-Content -Path $logpath -Value "==========================================="

The script can be broken down into sections as detailed below:

Check Directories

# Create directories if they don't exist
if (-not (Test-Path -Path $downloadFolder)) {
    New-Item -ItemType Directory -Path $downloadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $lastUploadFolder)) {
    New-Item -ItemType Directory -Path $lastUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path $failedUploadFolder)) {
    New-Item -ItemType Directory -Path $failedUploadFolder -Force | Out-Null
}
if (-not (Test-Path -Path (Split-Path -Path $logpath -Parent))) {
    New-Item -ItemType Directory -Path (Split-Path -Path $logpath -Parent) -Force | Out-Null
}

Checks that the directories exist, if not they are created

MSGraph Function & Steps

Step 1: Get the access token

$TokenBody = @{
    client_id     = $ClientId
    client_secret = $ClientSecret
    scope         = "https://graph.microsoft.com/.default"
    grant_type    = "client_credentials"
}

$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -Method Post -Body $TokenBody
$AccessToken = $TokenResponse.access_token

# Check if the token was retrieved successfully
if ($null -eq $AccessToken) {
    Write-Error "Failed to retrieve access token."
    exit
}

Step 2: Create the authorization header with the access token

$headers = @{
    "Authorization" = "Bearer $AccessToken"
    "Content-Type"  = "application/json"
}

Step 3: Get list of all users (pagination support for large tenant)

$usersUri = "https://graph.microsoft.com/v1.0/users"
$allUsers = @()

do {
    $response = Invoke-RestMethod -Uri $usersUri -Method Get -Headers $headers
    $allUsers += $response.value

    # Get next page of results if present
    $usersUri = $response.'@odata.nextLink'
} while ($usersUri)

# Initialize statistics counters
$stats = @{
    TotalUsers = $allUsers.Count
    Downloaded = 0
    NoProfilePic = 0
    DownloadErrors = 0
    DuplicatesRemoved = 0
    PicturesChanged = 0
    Uploaded = 0
    UploadFailed = 0
}

Step 4: Download profile pictures

This section downloads the profile pictures from Office365, it downloads the image as 648x648 to keep this in line with Interact Profile image size.

foreach ($user in $allUsers) {
    $userId = $user.id
    $path = Join-Path $downloadFolder "$userId.jpg"

    try {
        # Fetch the user photo (use headers without Content-Type for binary data)
        $photoHeaders = @{
            "Authorization" = "Bearer $AccessToken"
        }
        $photoUri = "https://graph.microsoft.com/v1.0/users/$userId/photos/648x648/`$value"
        
        # Use -OutFile to directly save binary data to disk
        # ContentType parameter forces treating response as binary
        Invoke-RestMethod -Uri $photoUri -Method Get -Headers $photoHeaders -OutFile $path -ErrorAction Stop -ContentType "image/jpeg"
        
        $stats.Downloaded++
        $message = "$($user.userPrincipalName) profile picture downloaded"
        Write-Output $message
        Add-Content -Path $logpath -Value $message
    }
    catch {
        if ($_.Exception.Response.StatusCode.Value__ -eq 404) {
            $stats.NoProfilePic++
            $message = "$($user.userPrincipalName) has no profile picture"
            Write-Output $message
            Add-Content -Path $logpath -Value $message
        }
        else {
            $stats.DownloadErrors++
            $errorMessage = "Error downloading profile picture for $($user.userPrincipalName): $($_.Exception.Message)"
            Write-Error $errorMessage
            Add-Content -Path $logpath -Value $errorMessage
        }
    }
}

Step 4.5: Remove already uploaded files from download folder (comparing file hashes)

This checks if the file in the Last Uploaded Folder (if it exists) is the same as the file downloaded, if it is, it is removed to avoid uploading again.

Write-Output "`nChecking for already uploaded files..."
Add-Content -Path $logpath -Value "`n=== Checking for Duplicates ==="

$removedCount = 0
$changedCount = 0
$downloadedFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"

foreach ($downloadedFile in $downloadedFiles) {
    $lastUploadedFilePath = Join-Path $lastUploadFolder $downloadedFile.Name
    
    if (Test-Path $lastUploadedFilePath) {
        try {
            # Compare file hashes to detect if content has changed
            $downloadedHash = (Get-FileHash -Path $downloadedFile.FullName -Algorithm SHA256).Hash
            $uploadedHash = (Get-FileHash -Path $lastUploadedFilePath -Algorithm SHA256).Hash
            
            if ($downloadedHash -eq $uploadedHash) {
                # Files are identical, remove from download folder
                Remove-Item -Path $downloadedFile.FullName -Force
                $message = "Removed duplicate file (unchanged): $($downloadedFile.Name)"
                Write-Output $message
                Add-Content -Path $logpath -Value $message
                $removedCount++
            }
            else {
                # File content has changed, keep for upload
                $message = "Picture changed for: $($downloadedFile.Name) - will be uploaded"
                Write-Output $message
                Add-Content -Path $logpath -Value $message
                $changedCount++
            }
        }
        catch {
            $errorMessage = "Failed to compare file $($downloadedFile.Name): $($_.Exception.Message)"
            Write-Warning $errorMessage
            Add-Content -Path $logpath -Value $errorMessage
        }
    }
}

Interact Upload Steps

Step 5: Upload Profile Pictures to Interact Instance

This section is the bulk of the work, it process the files left in the Download Directory and uploads them to Interact.

if ($enableUpload) {
    ProcessFiles
} else {
    Write-Output "`nUpload disabled. Set `$enableUpload = `$true to enable."
}

# Display Final Statistics
Write-Output "`n==========================================="
Write-Output "           FINAL STATISTICS"
Write-Output "==========================================="
Write-Output "Total Users Found:        $($stats.TotalUsers)"
Write-Output "Images Downloaded:        $($stats.Downloaded)"
Write-Output "No Profile Picture:       $($stats.NoProfilePic)"
Write-Output "Download Errors:          $($stats.DownloadErrors)"
Write-Output "Duplicates Removed:       $($stats.DuplicatesRemoved)"
Write-Output "Pictures Changed:         $($stats.PicturesChanged)"
Write-Output "Images Uploaded:          $($stats.Uploaded)"
Write-Output "Upload Failed:            $($stats.UploadFailed)"
Write-Output "==========================================="

# Write statistics to log
Add-Content -Path $logpath -Value "`n==========================================="
Add-Content -Path $logpath -Value "           FINAL STATISTICS"
Add-Content -Path $logpath -Value "==========================================="
Add-Content -Path $logpath -Value "Total Users Found:        $($stats.TotalUsers)"
Add-Content -Path $logpath -Value "Images Downloaded:        $($stats.Downloaded)"
Add-Content -Path $logpath -Value "No Profile Picture:       $($stats.NoProfilePic)"
Add-Content -Path $logpath -Value "Download Errors:          $($stats.DownloadErrors)"
Add-Content -Path $logpath -Value "Duplicates Removed:       $($stats.DuplicatesRemoved)"
Add-Content -Path $logpath -Value "Pictures Changed:         $($stats.PicturesChanged)"
Add-Content -Path $logpath -Value "Images Uploaded:          $($stats.Uploaded)"
Add-Content -Path $logpath -Value "Upload Failed:            $($stats.UploadFailed)"
Add-Content -Path $logpath -Value "==========================================="

This section calls, the function

Process Files to Interact Profile Source

This section of the code, will upload the downloaded file to the relevant profile in Interact, by matching the user using the UMI ID (External ID). This is the routine that will check the directory for files and send the file to SendFile function.

function ProcessFiles() {
    Write-Output "`nStarting upload process..."
    Add-Content -Path $logpath -Value "`n=== Upload Process Started ==="
    
    $filesPerMinute = 6
    $uploadCounter = 0
    $allFiles = Get-ChildItem $downloadFolder -Filter "*.jpg"
    $totalFiles = $allFiles.Count
    
    $allFiles | Foreach-Object {
        $umiid = $_.BaseName
        $baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"
        Write-Output "Uploading $($_.FullName) to $baseUri"
        $success = SendFile $baseUri $_.FullName
        if ($success) {
            $stats.Uploaded++
        } else {
            $stats.UploadFailed++
        }
        
        $uploadCounter++
        
        # Rate limiting: Wait after every 6 uploads to avoid Error 429
        if ($uploadCounter -eq $filesPerMinute -and ($stats.Uploaded + $stats.UploadFailed) -lt $totalFiles) {
            $rateLimitMessage = "Rate limit: Uploaded $uploadCounter files. Waiting 60 seconds before continuing..."
            Write-Output $rateLimitMessage
            Add-Content -Path $logpath -Value $rateLimitMessage
            Start-Sleep -Seconds 60
            $uploadCounter = 0
        }
      }
    
    $uploadMessage = "Upload complete: $($stats.Uploaded) successful, $($stats.UploadFailed) failed"
    Write-Output $uploadMessage
    Add-Content -Path $logpath -Value $uploadMessage
}

Which then calls the SendFile function to do the upload.

Send File to Interact Profile Source

This routine is the actual function that does the uploading of the files. It has control in the function to only allow 6 uploads per minute as per the threshold requirements of the Profile Picture process.

Depending on success or failure, the file is moved to the relevant folder.

Using Username

As mentioned in the script above, this is based on using the UMI ID of a users profile in Interact, which is expected to match the Entra ID Object ID of the profile in Entra.

The script can be changed to download the files using the User Principal Name (UPN) when that matches the Username in interact.

To do this the following lines need to change

$userId = $user.id
$path = Join-Path $downloadFolder "$userId.jpg"

In the above the $userId is being set to the Object ID, to change it to the UPN, change the lines to

$userId = $user.userPrincipalName
$path = Join-Path $downloadFolder "$userId.jpg"

You will also need to change the Upload URL from

$umiid = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/umiid/" + $umiid + "/picture"

To

$username = $_.BaseName
$baseUri = $interactUrl + "/api/umi/" + $profileSourceId + "/upload/username/" + $username + "/picture"

This is an example script only.