O365 and Exchange 2016/Exchange 2013: Understanding the UserPhoto API

We recently had an issue for an Enterprise Cloud customer, in which the photo was not rendering for the user - which was uploaded to AD (and synced over via MMSSPP to the managed environment). It was sussed that the issue was customer-caused, as the customer was modifying the photo via the PowerShell commandlets and had deleted the photo.

Despite the fact that the customer had disabled the OWA functionality to change the user photo in OWA, the customer was using the PowerShell commandlets to modify this object, which is the same interface/commands that OWA utilises behind the scenes.

To explain this behaviour in further detail: When you delete a photo from a mailbox in Exchange (or Exchange Online), it changes the UserPhotoCacheId MAPI property value to '0' and this signifies to the UserPhoto API that the photo has been deleted. When this occurs, we will not fall-back to the ADPhotoHandler to call the photo from AD because the user explicitly deleted the photo. This scenario is by-design of the UserPhoto API.

To rectify this behaviour, clear the MAPI property by running 'Remove-UserPhoto -ClearMailboxPhotoRecord'.

Keep in mind, as well, that Exchange uses a CachingPhotoHandler and that the photos stored on disk have a TTL of 7 days.

I have written a script to help ascertain the MAPI properties on a given mailbox, obtain the location of the cached photos on disk, and obtain the IPM.UserPhoto item from the user's mailbox (on-premises):

function Get-UserPhotoDataOnPremises { param([string]$User) Write-Warning -Message "The source of our powers comes from a Cracker Jack™ box, so this will take a minute. Please be patient..." if([System.String]::IsNullOrEmpty($User)) { throw [System.NullReferenceException]::new("The user value cannot be null.") } else { $mbx = Get-Mailbox $User } [string]$scriptPath = $env:ExchangeInstallPath + "Scripts\ManagedStoreDiagnosticFunctions.ps1" # Dot-load script into function . $scriptPath [GUID]$guid = $mbx.ExchangeGuid.Guid [int]$mbxNumber = Get-StoreQuery -Database $mbx.Database -Query "SELECT MailboxNumber FROM Mailbox WHERE MailboxGuid='$guid'" | Select -ExpandProperty MailboxNumber [string]$folderId = Get-StoreQuery -Database $mbx.Database -Query "SELECT FolderId FROM Folder WHERE MailboxNumber='$mbxNumber' AND DisplayName='$([System.String]::Empty)'" | Select -ExpandProperty FolderId $global:item = Get-StoreQuery -Database $mbx.Database -Query "SELECT * FROM Message WHERE MailboxNumber='$mbxNumber' AND FolderId='$folderId' AND MessageClass='IPM.UserPhoto'" -Unlimited [string]$previewPhotoCachedId = (Get-StoreQuery -Database $mbx.Database -Query "SELECT UserPhotoPreviewCacheId FROM Mailbox WHERE MailboxNumber='$mbxNumber'" -Unlimited).p7C1B0003 [string]$photoCacheId = (Get-StoreQuery -Database $mbx.Database -Query "SELECT UserPhotoCacheId FROM Mailbox WHERE MailboxNumber='$mbxNumber'" -Unlimited).p7C1A0003 # Obtain files on disk (if any) $smtp = $mbx.WindowsEmailAddress.Address $smtpAtIndex = $smtp.IndexOf("@") $smtpAtIndexPlusOne = $smtpAtIndex + 1 $smtpDotIndex = $smtp.LastIndexOf(".") $smtpNewLength = $smtpDotIndex - $smtpAtIndexPlusOne $subString = $smtp.Substring($smtpAtIndexPlusOne, $smtpNewLength) $queryString = "_$subString" $preSubString = $smtp.Substring(0, $smtpAtIndex) $srvr = Get-MailboxDatabaseCopyStatus $mbx.Database.Name | Where{$_.Status -contains 'Mounted'} | Select -ExpandProperty MailboxServer $folderPathUnc = "\\$($srvr)\" + $env:ExchangeInstallPath.Replace(":", "$") + "\ClientAccess\photos" $obj = Get-ChildItem -Path $folderPathUnc -Filter "*$($queryString)*" $folderPath = $obj.FullName $obj2 = Get-ChildItem -Path $folderPath $fullPaths = @() foreach($o in $obj2) { $picObj = Get-ChildItem -Path $o.FullName -Filter "*$($preSubString)*" $fullPaths += New-Object PSobject -Property @{ Name=$picObj.Name Size=$picObj.Length Location=$picObj.FullName } } if([System.String]::IsNullOrEmpty($item.MessageId) -eq $FALSE) { $string = "IPM.UserPhoto item found in user's mailbox. The object can be found in " + "$" + "item" Write-Host $string } if($previewPhotoCachedId) { Write-Host "UserPhotoPreviewCacheId found: $previewPhotoCachedId" } if($photoCacheId) { Write-Host "UserPhotoCacheId found: $photoCacheId" } Write-Host -ForegroundColor Green "Photo files found on disk on $($srvr) for $($smtp):" $fullPaths | FL }

Here's an example as run from my lab:

[PS] E:\>Get-UserPhotoDataOnPremises -User Administrator WARNING: The source of our powers comes from a Cracker Jack™ box, so this will take a minute. Please be patient... IPM.UserPhoto item found in user's mailbox. The object can be found in $item UserPhotoPreviewCacheId found: -88512737 UserPhotoCacheId found: -88512737 Photo files found on disk on [REDACTED] for Administrator@contoso.se: Name : _Administrator-8EEDD78A2D804372C17E9FABD151BCD5.jpg Location : \\[REDACTED]\E$\exchsrvr\ClientAccess\photos\_contoso.se-BBED6250E228D4F38F5F19BF4F1A6823\HR648x648\_Administrator-8EEDD78A2D804372C17E9FABD151BCD5.jpg Size : 79838 Name : _Administrator-8EEDD78A2D804372C17E9FABD151BCD5.jpg Location : \\[REDACTED]\E$\exchsrvr\ClientAccess\photos\_contoso.se-BBED6250E228D4F38F5F19BF4F1A6823\HR96x96\_Administrator-8EEDD78A2D804372C17E9FABD151BCD5.jpg Size : 3224

I've also written a method in C# to test obtain the photo via the EWS GetUserPhoto REST method:

/// /// Creates an EWS request for a user's photo at each standard size./// /// Vanity name of your endpoint. /// Smtp Address of the user you're targeting. private static void GetPhotos(string vanityName, string username) { int[] sizeInts = new[] { 48, 64, 96, 120, 240, 360, 432, 504, 648 }; Parallel.ForEach(sizeInts, delegate (int i) { Uri targetUri = new Uri($"https://{vanityName}/EWS/Exchange.asmx/s/GetUserPhoto?email={username}&size=HR{i}x{i}"); Console.WriteLine($"Targeting: {targetUri}"); try { HttpWebRequest newWebRequest = (HttpWebRequest)WebRequest.Create(targetUri); newWebRequest.UserAgent = "Enterprise Cloud UserPhoto EWS Client"; newWebRequest.Credentials = new NetworkCredential("[UserName]", "[PassWord]"); using (HttpWebResponse newWebResponse = (HttpWebResponse)newWebRequest.GetResponse()) { if (newWebResponse.StatusCode == HttpStatusCode.OK) { Console.WriteLine($"Size {i} photo found."); } else if(newWebResponse.StatusCode == HttpStatusCode.NotFound) { Console.WriteLine($"Photo API states that a photo cannot be found for the size {i}x{i}"); } else { Console.WriteLine($"Unexpected http response received: {newWebResponse.StatusCode}"); } } } catch (Exception e) { Console.WriteLine(e.Message); } }); }

If you run into any problems with the script or have any questions or concerns around the UserPhoto API, feel free to let know! :)