How to get info about your ActiveSync, EWS and WebDAV clients before migrating to Exchange 2010
When you're moving from Exchange 2007 to Exchange 2010 there are a few gotchas that it's worth watching out for in the planning stages before you change over Client Access Namespaces or start migrating Mailboxes. If you don't you might find you have broken Exchange 2007 clients, due to the CAS changes, and find some clients can't connect after their mailboxes are migrated.
When it comes to the Windows version of Outlook, you can find out about currently logged-in clients using the Get-LogonStatistics command; however you also need to be aware of other clients using protocols like ActiveSync, Exchange Web Services and of course WebDAV.
ActiveSync clients will mostly be fine with CAS Namespace changes, as depending on version they should be automatically proxied from Exchange 2010 back to Exchange 2007 or should autodiscover; however some clients like the iPhone don't work properly and you need to consider a workaround.
Exchange Web Services clients, for the most part, shouldn't have issues thanks to AutoDiscover, however it's good to understand what clients you have out there already so you can test and plan for any issues. There's a number of iPhone apps that use EWS out there that your users may have bought and I've seen some funny issues myself with the EWS version Mac Mail on Snow Leopard that may require a client visit.
Finally, WebDAV. There's little to explain about WebDAV apart from it's not supported in Exchange 2010! You need to find these clients (think Entourage 2004 and 2008) and upgrade them.
Unfortunately there isn't anything built-in to Exchange 2007 or 2010 to examine this data, but the good news is it should all be available to you via the IIS log files. Whilst logparser is pretty good, personally I wanted the data collected and grouped all in one go ready to use. And that's where this script came from…
What information does the script output provide?
The output from the script is in CSV format, so it's easy to use in Excel for further data processing. The CSV file itself has the following fields:
Username: Logon name of the user
ActiveSyncUser: If the user uses an ActiveSync mobile device
ActiveSyncProxyUser: If the user is currently being proxied through to this client access server
ActiveSyncClients: A semi-colon separated list of the clients in use, eg. iPad;htchero;
ActiveSyncLastAccess: Last date found for ActiveSync use
EWSUser: If the user uses some sort of Exchange Web Services client
EWSPCOutlook: Version information if the user has the Windows version of Outlook 2007 or 2010
EWSMacMail: Version information if user has the EWS version of Mac Mail
EWSMacOutlook: Version information if the user has Mac Outlook 2011
EWSEntourage: Version information if the user has Entourage 2008, Web Services Edition
EWSOther: A semi-colon separated list of any other EWS clients the user has
EWSLastAccess: Last date found for EWS use
WebDavUser: If the user uses some sort of WebDAV Exchange client
WebDavClient: A semi-colon separated list of WebDAV client software and versions in use
WebDavLastAccess: Last date found for WebDAV use
In an actual export, the above looks a little bit like this (apart from the blurry usernames!)
How to use the script
Example one - Parses log files from the default log directory "C:\WINDOWS\system32\LogFiles\W3SVC1" to "C:\output.csv"
Example two - Parses the last 30 days of log files from the current directory to cas_results.csv in the current directory, and saves the state to state.xml in the current directory (could take a LONG time!)
Limitations
So far, only tested with Exchange 2007 IIS log files.. Any probs, let me know.
Script Code
.SYNOPSIS
Parses IIS Log files for records relating to ActiveSync, Exchange Web Services and WebDAV
Steve Goodman
.DESCRIPTION
Looks at logs files and produces a CSV file with summary data listing:
* Activesync clients, whether it is proxied by another CAS and ActiveSync device type
* EWS clients with columns for PC Outlook, Mac Outlook and Entourage 2008 EWS Edition and other clients
* WebDAV clients including client versions.
.PARAMETER LogFilePath
Path to base directory of IIS Log files, e.g. "C:\WINDOWS\system32\LogFiles\W3SVC1"
.PARAMETER Days
How many days log files to look back by
.PARAMETER OutputCSVFile
File to write CSV output to
.PARAMETER SaveStateFile
File to save or load internal state to. Useful when looking at multiple CAS servers or to update output later on based on only more recent logfiles
.EXAMPLE
Parses log files from the default log directory "C:\WINDOWS\system32\LogFiles\W3SVC1" to "C:\output.csv"
.\ExIISLogParser.ps1
.EXAMPLE
Parses the last 30 days of log files from the current directory to cas_results.csv in the current directory, and saves the state to state.xml in the current directory
.\ExIISLogParser.ps1 -LogFilePath ".\" -Days 30 -OutputCSVFile ".\cas_results.csv" -SaveStateFile ".\state.xml"
#>
param(
[parameter(Position=0,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Full path to log file directory")][string]$LogFilePath = "C:\WINDOWS\system32\LogFiles\W3SVC1",
[parameter(Position=1,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Last write date of logs to search back by")][int]$Days=0,
[parameter(Position=2,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="CSV file for output")][string]$OutputCSVFile="C:\output.csv",
[parameter(Position=3,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Script state XML file")][string]$SaveStateFile
)
if (!(Test-Path $LogFilePath))
{
throw "LogFilePath does not exist"
}
if ((Test-Path $OutputCSVFile))
{
throw "OutputCSVFile already exists"
}
[hashtable]$Users = @{}
if ($SaveStateFile)
{
if ((Test-Path $SaveStateFile))
{
$Users = Import-Clixml -Path $SaveStateFile
}
}
$EarliestLogDate = (Get-Date).Subtract([timespan]"$($Days).00:00").Date
[array]$Files = Get-Item -Path "$($LogFilePath)\*.log" | Where {$_.LastWriteTime -gt $EarliestLogDate}
for ($i = 0; $i -lt $Files.Count; $i++)
{
Write-Progress -id 1 -activity "Overall Progress" -status "File $($i) of $($Files.Count)" -percentComplete (($i/$Files.Count*100)*0.9);
Write-Progress -id 2 -activity "Log $($Files[$i].Name)" -status "Loading" -percentComplete 0
$Log = Get-Content $Files[$i] | Where {$_ -like "*/Microsoft-Server-ActiveSync/*" -or $_ -like "*/EWS/*" -or $_ -like "*/exchange/*"}
for ($j = 0; $j -lt $Log.Count; $j++)
{
Write-Progress -id 2 -activity "Current Log $($Files[$i].Name)" -status "Working" -percentComplete ($j/$Log.Count*100);
# Clear Username
$Username=$null
# Split up log line
$arrLog = $Log[$j].Split(" ");
# Extract username first
if ($arrLog[5] -eq "/Microsoft-Server-ActiveSync/Proxy")
{
foreach ($QSPart in $arrLog[6].Split("&"))
{
if ($QSPart -like "User=*")
{
$Username =($QSPart.Split("="))[1]
}
}
} else {
# Not proxied, just get the username provided direct
$Username = $arrLog[8]
}
# Only process if it's an authenticated user
if ($Username)
{
# Take off domain or UPN suffix
if ($Username -like "*@*")
{
$Username = ($Username.Split("@"))[0]
} elseif ($Username -like "*\*") {
$Username = ($Username.Split("\"))[1];
} elseif ($Username -like "*%40*") {
$Username = ($Username.Split("%40"))[0]
} elseif ($Username -like "*/*") {
$Username = ($Username.Split("/"))[1]
}
# Make username lower case
$Username = $Username.ToLower()
# Update data for user
switch -wildcard ($arrLog[5])
{
"/EWS/*"
{
# Get EWS Client
$EWSClient = $arrLog[10].Replace("+"," ")
# Check if already found or create new hashtable item
if (!$Users.Contains($Username))
{
[hashtable]$obj = @{ActiveSyncUser=0; ActiveSyncProxyUser=0; ActiveSyncClients=@{}; ActiveSyncLastAccess=""; EWSUser=0; EWSClients=@{}; EWSLastAccess=""; WebDavUser=0; WebDavClients=@{}; WebDavLastAccess=""}
$Users.Add($Username,$obj)
}
# Set variables
$Users[$Username]["EWSUser"]=1
if (!$Users[$Username]["EWSClients"].Contains($EWSClient))
{
$Users[$Username]["EWSClients"].Add($EWSClient,1)
}
$Users[$Username]["EWSLastAccess"]=$arrLog[0]
break
}
"/Microsoft-Server-ActiveSync/*"
{
# Check if already found or create new hashtable item
if (!$Users.Contains($Username))
{
[hashtable]$obj = @{ActiveSyncUser=0; ActiveSyncProxyUser=0; ActiveSyncClients=@{}; ActiveSyncLastAccess=""; EWSUser=0; EWSClients=@{}; EWSLastAccess=""; WebDavUser=0; WebDavClients=@{}; WebDavLastAccess=""}
$Users.Add($Username,$obj)
}
# Set variables
# Is a ActiveSync user?
$Users[$Username]["ActiveSyncUser"]=1
# Is a proxy user?
if ($arrLog[5] -eq "/Microsoft-Server-ActiveSync/Proxy")
{
$Users[$Username]["ActiveSyncProxyUser"]=1
}
# Client Info
foreach ($QSPart in $arrLog[6].Split("&"))
{
if ($QSPart -like "DeviceType=*")
{
$ASClient = ($QSPart.Split("="))[1]
if (!$Users[$Username]["ActiveSyncClients"].Contains($ASClient))
{
$Users[$Username]["ActiveSyncClients"].Add($ASClient,1)
}
}
}
# Last Access Date
$Users[$Username]["ActiveSyncLastAccess"]=$arrLog[0]
break
}
"/exchange/*"
{
# Check if already found or create new hashtable item
if (!$Users.Contains($Username))
{
[hashtable]$obj = @{ActiveSyncUser=0; ActiveSyncProxyUser=0; ActiveSyncClients=@{}; ActiveSyncLastAccess=""; EWSUser=0; EWSClients=@{}; EWSLastAccess=""; WebDavUser=0; WebDavClients=@{}; WebDavLastAccess=""}
$Users.Add($Username,$obj)
}
# Set variables
$Users[$Username]["WebDavUser"]=1
$WDClient = $arrLog[10].Replace("+"," ")
if (!$Users[$Username]["WebDavClients"].Contains($WDClient))
{
$Users[$Username]["WebDavClients"].Add($WDClient,1)
}
$Users[$Username]["WebDavLastAccess"]=$arrLog[0]
break
}
}
}
}
}
Write-Progress -id 1 -activity "Overall Progress" -status "Preparing Output" -percentComplete 95;
[array]$Output=$null
$Users.GetEnumerator() | Foreach {
$OutputItem = New-Object Object
$OutputItem | Add-Member NoteProperty Username $_.Key
$OutputItem | Add-Member NoteProperty ActiveSyncUser $_.Value["ActiveSyncUser"]
$OutputItem | Add-Member NoteProperty ActiveSyncProxyUser $_.Value["ActiveSyncProxyUser"]
$ActiveSyncClients = $null
$_.Value["ActiveSyncClients"].GetEnumerator() | % { $ActiveSyncClients += "$($_.Key); "}
$OutputItem | Add-Member NoteProperty ActiveSyncClients $ActiveSyncClients
$OutputItem | Add-Member NoteProperty ActiveSyncLastAccess $_.Value["ActiveSyncLastAccess"]
$OutputItem | Add-Member NoteProperty EWSUser $_.Value["EWSUser"]
$EWSPCOutlook = ""
$EWSMacMail = ""
$EWSMacOutlook = ""
$EWSEntourage = ""
$EWSOther = ""
$_.Value["EWSClients"].GetEnumerator() | foreach {
$EWSClient = $_.Key
switch -wildcard ($EWSClient)
{
"Microsoft Office*"
{
$EWSPCOutlook=$EWSClient
break
}
"Mac*ExchangeWebServices*"
{
$EWSMacMail=$EWSClient
break
}
"MacOutlook*"
{
$EWSMacOutlook=$EWSClient
break
}
"Entourage*"
{
$EWSEntourage=$EWSClient
break
}
default
{
$EWSOther+="$($EWSClient); "
}
}
}
$OutputItem | Add-Member NoteProperty EWSPCOutlook $EWSPCOutlook
$OutputItem | Add-Member NoteProperty EWSMacMail $EWSMacMail
$OutputItem | Add-Member NoteProperty EWSMacOutlook $EWSMacOutlook
$OutputItem | Add-Member NoteProperty EWSEntourage $EWSEntourage
$OutputItem | Add-Member NoteProperty EWSOther $EWSOther
$OutputItem | Add-Member NoteProperty EWSLastAccess $_.Value["EWSLastAccess"]
$OutputItem | Add-Member NoteProperty WebDavUser $_.Value["WebDavUser"]
$WebDavClients=$null
$_.Value["WebDavClients"].GetEnumerator() | % { $WebDavClients += "$($_.Key); "}
$OutputItem | Add-Member NoteProperty WebDavClients $WebDavClients
$OutputItem | Add-Member NoteProperty WebDavLastAccess $_.Value["WebDavLastAccess"]
$Output += $OutputItem
}
if ($SaveStateFile)
{
$Users = Export-Clixml -Path $SaveStateFile
}
$Output[1..($Output.Count)] | Select Username,ActiveSyncUser,ActiveSyncProxyUser,ActiveSyncClients,ActiveSyncLastAccess,EWSUser,EWSPCOutlook,EWSMacMail,EWSMacOutlook,EWSEntourage,EWSOther,EWSLastAccess,WebDavUser,WebDavClients,WebDavLastAccess | Export-Csv -Path $OutputCSVFile -NoClobber -NoTypeInformation
Script Download
Creating Shared Calendars on Exchange 2010
With Public Folders slowly, painfully making their way out from Exchange, you might find a need to replace shared calendars that traditionally you would have used a public folder for. While Sharepoint is a great option, using Shared Mailboxes isn't a bad idea either.
One of the issues with creating a Shared Mailbox for a Calendar is that as well as creating the mailbox, often you'll need to add folder permissions to the Calendar for Editors, Reviewers and perhaps publish iCal and Web URLs for subscriptions. And the chances are, you probably don't want the Shared Mailbox auto-mapped to the owner's Outlook client.
To make the process a little more simple, I've written a short script that wraps up all this into a single script, allowing you to specify the initial owners (who get full access to the mailbox) and optionally an initial set of people who can edit the calendar and an initial list of calendar viewers. You can choose to publish the iCal and Web URLs (which are provided in the script output), and the manager and department fields are auto-populated from the first owner specified.
Finally, you'll also find a basic set of documentation (in word format, so you can alter to your own needs) that you can provide to the users to help them get started managing their new shared calendar.
Here's a quick example of the script's usage, in it's simplest form:
And using the full set of options:
The full script follows, along with a download link for a zip file containing the script and user documentation:
.SYNOPSIS
Creates a mailbox for use as a shared calendar
Steve Goodman
.DESCRIPTION
Creates a shared mailbox, assigns owner(s), with full access, along with editors and viewers. Restricts mail to authenticated users.
Additionally generates a HTML/iCal published URL if specified.
.PARAMETER Name
Name of the Shared Calendar. Used for the Display Name
.PARAMETER SamAccountName
Optional - Pre-Windows 2000 Login name to use when creating the mailbox. If not specified, uses defaults
.PARAMETER UserPrincipalName
Optional - User Principal Name (UPN) to use when creating the mailbox. If not specified, uses defaults
.PARAMETER Database
Optional - Mailbox Database to use
.PARAMETER OrganizationalUnit
Optional - AD Organizational Unit to use when creatng the mailbox. If not specified, uses defaults.
.PARAMETER Owners
List of the people who should be the owners of the mailbox. The first will be specified as the "manager" field, their department used, and all owners will be given "Full Access" mailbox permissions. The Auto-Mapping will be removed.
.PARAMETER Editors
Optional - List of the people who should have editor access on the Calendar
.PARAMETER Reviewers
Optional - List of the people who should have viewer access on the Calendar
.PARAMETER WebPublish
Optional - Publish the Calendar via private iCal and HTML URLs. Defaults to False. Requires Default Sharing Policy to allow Web Publishing.
.EXAMPLE
Creates a new calendar, allowing defaults:
New-SharedCalendar.ps1 -Name "Test Calendar" -Owners steve,lisa -Editors isabelle,peter -Reviewers liz,drew
.EXAMPLE
Creates a new calendar, many options set
New-SharedCalendar.ps1 -Name "Shared Calendar" -SamAccountName sharedcalendar -UserprincipalName sharedcalendar@sgdev.stevieg.org -Database DB01 -OrganizationalUnit sgdev.stevieg.org/People -Owners sgoodman,fstone -Editors epitts,csutton -Reviewers fcarson -WebPublish:$True
#>
param(
[parameter(Position=0,Mandatory=$true,ValueFromPipeline=$false,HelpMessage="Calendar Name")][string]$Name,
[parameter(Position=1,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Logon Name (SAM Account Name)")][string]$SamAccountName,
[parameter(Position=2,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Logon Name (User Principal Name)")][string]$UserPrincipalName,
[parameter(Position=3,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Mailbox Database")]$Database,
[parameter(Position=4,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Organizational Unit")][string]$OrganizationalUnit,
[parameter(Position=5,Mandatory=$true,ValueFromPipeline=$false,HelpMessage="Mailbox Owners")][array]$Owners,
[parameter(Position=6,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Calendar Editors")][array]$Editors,
[parameter(Position=7,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Calendar Reviewers")][array]$Reviewers,
[parameter(Position=8,Mandatory=$false,ValueFromPipeline=$false,HelpMessage="Publish (privately) via the web?")][bool]$WebPublish=$false
)
# Check all pre-reqs
if ((Get-PSSnapin Microsoft.Exchange.Management.PowerShell.Admin -ErrorAction SilentlyContinue))
{
throw "Exchange 2007 Management Console Not Supported"
}
if (!(Get-Command New-Mailbox -ErrorAction SilentlyContinue))
{
throw "Please launch the Exchange 2010 Management Shell"
}
# Setup splatting hashtable
$NewSharedMailbox = @{
"Shared" = $True
"Name" = $Name
}
# Check parameters OK and add relevant parameters to splatting hashtable
$Recipient=Get-Recipient $Name -ErrorAction SilentlyContinue
if ($Recipient)
{
$Recipient
throw "Recipient $($Name) Exists (See above)"
}
if ($SamAccountName)
{
$Recipient=Get-Mailbox $SamAccountName -ErrorAction SilentlyContinue
if ($Recipient)
{
$Recipient
throw "Recipient $($SamAccountName) Exists (See above)"
}
$NewSharedMailbox.Add("SamAccountName",$SamAccountName)
}
if ($UserPrincipalName)
{
$Recipient=Get-Mailbox $UserPrincipalName -ErrorAction SilentlyContinue
if ($Recipient)
{
$Recipient
throw "Recipient $($UserPrincipalName) Exists (See above)"
}
$NewSharedMailbox.Add("UserPrincipalName",$UserPrincipalName)
}
if ($Database)
{
$MailboxDatabase=Get-MailboxDatabase $Database -ErrorAction SilentlyContinue
if (!$MailboxDatabase)
{
throw "Mailbox Database $($Database) not found"
}
$NewSharedMailbox.Add("Database",$Database)
}
if ($OrganizationalUnit)
{
$objOrganizationalUnit = Get-OrganizationalUnit $OrganizationalUnit -ErrorAction SilentlyContinue
if (!$objOrganizationalUnit)
{
throw "Organizational Unit $($OrganizationalUnit) not found"
}
$NewSharedMailbox.Add("OrganizationalUnit",$OrganizationalUnit)
}
if ($Owners.Count -eq 0)
{
throw "You must specify at least one owner"
}
foreach ($Owner in $Owners)
{
if (!(Get-Mailbox $Owner -ErrorAction SilentlyContinue))
{
throw "Owner mailbox $($Owner) not found"
}
}
if ($Editors)
{
foreach ($Editor in $Editors)
{
if (!(Get-Mailbox $Editor -ErrorAction SilentlyContinue))
{
throw "Editor mailbox $($Editor) not found"
}
}
}
if ($Reviewers)
{
foreach ($Reviewer in $Reviewers)
{
if (!(Get-Mailbox $Reviewer -ErrorAction SilentlyContinue))
{
throw "Reviewer mailbox $($Reviewer) not found"
}
}
}
# Create mailbox
Write-Host -ForegroundColor Green "Creating Shared Calendar Mailbox"
$Mailbox = New-Mailbox @NewSharedMailbox
if (!$Mailbox)
{
throw "An error occurred creating the shared calendar mailbox"
}
$DomainController = $Mailbox.OriginatingServer
$Mailbox
# Set Owner Details including Department to match the first specified Owner
Write-Host -ForegroundColor Green "Setting Shared Calendar Mailbox Owner, Department and Description"
$Mailbox | Set-User -Manager (Get-User $Owners[0]) -Department ((Get-User $Owners[0]).Department) -DomainController $DomainController
# Set Description
$LDAPUser = [ADSI]"LDAP://$($DomainController)/$($Mailbox.DistinguishedName)"
$LDAPUser.description = "Shared Calendar"
$LDAPUser.SetInfo()
$LDAPUser = [ADSI]"LDAP://$($DomainController)/$($Mailbox.DistinguishedName)"
Write-Host "Manager: $($LDAPUser.manager)"
Write-Host "Department: $($LDAPUser.department)"
Write-Host "Description: $($LDAPUser.description)"
# Set authenticated mail only
$Mailbox | Set-Mailbox -RequireSenderAuthenticationEnabled:$true
# Set Owner Permissions
Write-Host -ForegroundColor Green "Adding Shared Calendar Mailbox Owner Permissions and removing Outlook auto-mapping"
foreach ($Owner in $Owners)
{
# Add Permission
$Mailbox | Add-MailboxPermission -User $Owner -AccessRights FullAccess -DomainController $DomainController
# Remove Auto-Mailbox mapping
$LDAPUser=[ADSI]"LDAP://$($DomainController)/$($Mailbox.DistinguishedName)"
$LDAPUser.msExchDelegateListLink.Remove(((Get-Mailbox $Owner).DistinguishedName))
$LDAPUser.SetInfo()
}
if ($Editors -or $Reviewers)
{
# Wait until Mailbox is ready before adding folder permissions
Write-Host -ForegroundColor Green "Waiting until mailbox folder structure is available before adding folder permissions"
$MailboxReady=$False
while ($MailboxReady -eq $False)
{
Write-Host -NoNewline "."
$Result = Get-MailboxFolderStatistics $Mailbox -ErrorAction SilentlyContinue
if ($Result)
{
$MailboxReady = $True
}
sleep 5
}
Write-Host
}
# Set Editor Permissions
if ($Editors)
{
Write-Host -ForegroundColor Green "Adding Editor permissions to Calendar"
foreach ($Editor in $Editors)
{
Add-MailboxFolderPermission "$($Mailbox.SamAccountName):\Calendar" -User $Editor -AccessRights Editor -DomainController $DomainController
}
}
# Set Reiewer Permissions
if ($Reviewers)
{
Write-Host -ForegroundColor Green "Adding Reviewer permissions to Calendar"
foreach ($Reviewer in $Reviewers)
{
Add-MailboxFolderPermission "$($Mailbox.SamAccountName):\Calendar" -User $Reviewer -AccessRights Reviewer -DomainController $DomainController
}
}
# Publish Calendar
if ($WebPublish)
{
if (((Get-SharingPolicy | Where {$_.Default -eq $True}).Domains|Where {$_.Domain -eq "anonymous"})) {
Write-Host -ForegroundColor Green "Publishing Calendar using private URL"
Set-MailboxCalendarFolder -Identity "$($Mailbox.SamAccountName):\Calendar" -DetailLevel FullDetails -PublishDateRangeFrom OneYear -PublishDateRangeTo OneYear -PublishEnabled:$true -SearchableUrlEnabled:$false -DomainController $DomainController
$MailboxCalendarFolder = Get-MailboxCalendarFolder -Identity "$($Mailbox.SamAccountName):\Calendar" -DomainController $DomainController
Write-Host -ForegroundColor Yellow -NoNewline "Web URL: "
Write-Host $MailboxCalendarFolder.PublishedCalendarUrl
Write-Host -ForegroundColor Yellow -NoNewline "iCal URL: "
Write-Host $MailboxCalendarFolder.PublishedICalUrl
} else {
Write-Host -ForegroundColor Yellow "Skipping Web/iCal Publishing because Default Sharing Policy does not allow web publishing (to anonymous domains)"
}
}
Download the Script and User Documentation
How to batch optimize your Exchange GAL Photos before importing to Active Directory
As I've covered in previous articles, it's possible to import contact photos into Active Directory and display them via the Exchange Global Address List in Outlook clients. This is fully supported against Exchange 2010 and Outlook 2010, however I've covered in previous articles how to import GAL photos into Exchange 2007 and how to enable your Outlook 2003 and Outlook 2007 clients to display the photos via the use of the Outlook Social Connector.
When you import pictures into the Active Directory it's important to make sure you optimise the pictures first. The optimal dimensions for the GAL photos is 96x96 pixels, and the maximum file size for the photos is 10K. For 10,000 users, that adds at least 100MB to the database size, more so if the picture needs replicating to the Global Catalog. By properly optimizing your photos before you import, not only will the pictures look better by virtue of being the correct dimensions, but you can also squish them down to a much smaller size.
Whilst you can use Photoshop, Microsoft Paint or other tools to accomplish this, it's actually fairly easy to batch optimize large numbers of photos at the command line, using an open source product called ImageMagick. It's a tool you can use to create, edit and convert photos. To convert our images we only need to use a small subset of it's conversion functionality.
Optimising a single photo
Later on in the article I've packaged up the script and redistributable binaries for ImageMagick, but before we get there, let's have a quick look at the process required to convert a single image file.
First of all, we need the actual ImageMagick software. I've used the portable version from the ImageMagick website and from that, just copied out the convert.exe and vcomp100.dll files, because that's all we need:

Then, with a larger file we want to convert I've used the following options with convert.exe to produce an optimized 96x96 ~2K jpeg file:
The options are fairly straightforward. Firstly, we set the jpeg quality. The best compromise between size and quality I've found is 50, with 100 producing a 10K+ file, and much lower producing blocky output. The colour depth is reduced and any colour profiles or file comments are stripped out. We resize using the "thumbnail" resize option, which is a great alternative to the "resize" option that's optimised for speed and also removes the image profile. The resize is set to a minimum of 96x96, then we do a centred crop to remove any excess pixels.
Batch optimising photos using GALBatchConvert.ps1
So now we've seen how to convert a single photo, what about if you've got a folder of 10, 100 or even 10,000? That's (naturally) where Powershell comes in. I've put together a little script that you can use to perform the batch conversion.
GALBatchConvert.ps1 takes 2 mandatory parameters -InputFolder, which is the folder with the original photos and -OutputFolder, which is an empty folder to write the optimised files. As a third parameter, you can specify -Quality to override the output jpeg quality with a value between 1 and 100.
Here's a quick example of it in use:
Converting C:\Users\steve\Documents\Input\isabelle.jpg
Converting C:\Users\steve\Documents\Input\lisa.jpg
Converting C:\Users\steve\Documents\Input\steve.jpg
Converting C:\Users\steve\Documents\Input\steve2.jpg
After the conversion, the output folder contains optimised 96x96, 2KB jpg files and the original files are retained in the input folder, unmodified:

The full script and redistributable binaries required for ImageMagick can be downloaded below:
Download Script and ImageMagick
As usual, if you have any problems, questions or suggestions, let me know in the comments section below.
Setup and use the GAL Photos feature using Exchange 2007 [Updated]
If you're not using Exchange Server 2010 yet, then you might wonder if you can take advantage of some of the newer features available in Outlook 2010. While mailtips, personal archives and automatic mailbox mappings aren't possible, you can make use of the GAL photo feature.
It's fairly painless to enable this and the setup steps for your organisation are the same as Exchange 2010. Once you've sorted the org pre-requisites, then the only piece missing is the cmdlets to import the photos. Although you can use Sharepoint 2010 to do this (and delegate it to end-users), I thought it would be useful to have a simple drop-in Powershell script that can accomplish this in the same way as you can in Exchange 2010.
Getting Active Directory Ready
The AD pre-requisites are fairly simple. You need to ensure the attribute the photo is stored in is replicated to the Global Catalog, and ensure you have either a Windows Server 2008 or later Domain Controller in your domain, or simply prepare your Windows 2003 forest schema for 2008:
- If you are in a multi domain environment, follow "A minor schema change" in the Exchange Team blog post GAL Photos in Exchange 2010 and Outlook 2010. If it's a single-domain environment you're all set.
- If you don't have at least one Windows 2008 / 2008 R2 Domain Controller, you need to run adprep /forestprep from the 2008/2008 R2 setup CD as described in the article Prepare a Windows 2000 or Windows Server 2003 Forest Schema for a Domain Controller That Runs Windows Server 2008 or Windows Server 2008 R2. You don't need to install a Windows 2008 / 2008 R2 domain controller, just update the schema. More details on why this is required here.
Importing Photos
In Exchange 2010, you use the Import-RecipientDataProperty cmdlet to import photos. This command isn't provided in Exchange 2007 so I've written a short Powershell script that can do this for you. It's usage is almost identical to the Import-RecipientDataProperty cmdlets, except that you don't need to specify which property to import (obviously) and you don't need to do submit the file as a byte-encoded array - you just give the filename.
Usage is simple:
Then, once AD has replicated, the Offline Address Book is updated (for example, using Update-OfflineAddressBook "Default Offline Address Book"), and clients have downloaded the new OAB, photos should now show in Outlook 2010 (or 2003 and 2007 - check my previous article!)
Removing Photos [New]
If you want to remove a photo from a contact, you need to clear the relevant attribute in Active Directory. As above, I've provided a script that can do this - for example, to clear the GAL picture:
The Import-Picture.ps1 and Clear-Picture.ps1 Scripts
#
# Import a JPEG file into Active Directory for use as the Exchange / Outlook GAL Photo
# Best results - Under 10K, 96 x 96
#
# Steve Goodman
if (!$Identity)
{
throw "Identity Missing";
}
if (!$Path)
{
throw "Path Missing";
}
if (!(Get-Command Get-User))
{
throw "Exchange Management Shell not loaded";
}
$User = Get-User $Identity -ErrorAction SilentlyContinue
if (!$User)
{
throw "User $($Identity) not found";
}
if (!(Test-Path -Path $Path))
{
throw "File $($Path) not found";
}
$FileData = [Byte[]]$(Get-Content -Path $Path -Encoding Byte -ReadCount 0);
if($FileData.Count -gt 10240)
{
throw "File size must be less than 10K";
}
$adsiUser = [ADSI]"LDAP://$($User.OriginatingServer)/$($User.DistinguishedName)";
$adsiUser.Put("thumbnailPhoto",$FileData);
$adsiUser.SetInfo()
#
# Clear the GAL Picture attribute
#
# Steve Goodman
if (!$Identity)
{
throw "Identity Missing";
}
if (!(Get-Command Get-User))
{
throw "Exchange Management Shell not loaded";
}
$User = Get-User $Identity -ErrorAction SilentlyContinue
if (!$User)
{
throw "User $($Identity) not found";
}
$adsiUser = [ADSI]"LDAP://$($User.OriginatingServer)/$($User.DistinguishedName)";
$adsiUser.PutEx(1,"thumbnailPhoto",$null)
$adsiUser.SetInfo()
Finally, if you have any problems or questions, let me know as usual in the comments…


