Sunday, April 15, 2007

Script: Importing Address Books into Outlook

Last year the University decided to use Exchange as the campus calendaring solution. As you well know, Exchange is a collaboration suite and it is difficult to separate email and calendaring functionality. Thus we also had a large mail migration on our hands.

Migrating mail was simple enough using a combination of drag-and-drop methods of IMAP folders and the Aid4Mail application to migrate local folders to PST files. Migrating address books proved to be a little more difficult. All the documentation on the internet detailed a process of importing a LDIF file into Outlook Express; then converting the Outlook Express address book into Outlook contacts. We found this solution rather cumbersome and it didn’t always import contacts in the desired format.

To solve this problem I wrote a VBScript that would parse a LDIF file and create the associated Outlook contacts.

How it works


First, export your address book as an LDIF file from your old IMAP client. If you have multiple address books you will need to export each into its own LDIF file.

Second, run the ImportLDIF.vbs script from a command prompt using CScript.exe. The script will parse the specified LDIF file and import any contacts/distribution lists included. The script will actually parse the LDIF file twice. The first time it will import any user contacts and the second run will import distribution lists. The LDIF file is parsed twice to ensure that all the contact objects have been created before creating the distribution lists and adding the members.

The script accepts 2 parameters: 1) the LDIF file name and 2) an optional category for all the users/groups in the specified LDIF file. The category option was added for those instances where users previously had multiple address books.

cscript.exe ‹path›\ImportLdif.vbs ‹path›\‹exported file name›.ldif /Category:"Category Name"


When the script enters the distribution list import phase you may be prompted in Outlook to allow the script to access your e-mail address stored in Outlook. Grant the script access for 1 minute to complete the import process.



The Script


'# Author: Nick Smith 
'# http://knicksmith.blogspot.com
'# Script Name: ImportLDIF.vbs
'# Purpose: This script will parse a LDIF file and import user
'# objects as contacts and groups as distribution lists.


'Create a shell object
Dim oShell
Set oShell = CreateObject("Wscript.Shell")

'Ensure the script is run with CScript
CheckForCScript

'Check arguments
If Wscript.Arguments.UnNamed.Count <> 1 then
Wscript.Echo "Usage: cscript ImportLdif.vbs LdifFileName [/Category:""My Category Name""] "
Wscript.Echo vbtab & "/Category" & vbtab & "Optional - Creates a category for the imported address book"
Wscript.Quit
End If

Dim olApp
Dim olNS
Dim contact
Dim contactFolder
Dim contactCategory
Dim bMoveToFolder

Dim mainFSO
Dim objFile
Dim strLine

Const olContactItem = 2
Const olDistributionListItem = 7
Const olFolderContacts = 10

'Create Outlook object
Set olApp = CreateObject ("Outlook.Application")
Set olNS = olApp.GetNamespace("MAPI")

on error resume next

'Open LDIF file
Set mainfso = CreateObject("Scripting.FileSystemObject")
Set objFile = mainFSO.OpenTextFile(Wscript.Arguments.Item(0), 1)
If Err.number <> 0 Then
'Error Opening File. Display Error info.
wscript.echo "Unable to open file: " & Wscript.Arguments.Item(0)
wscript.echo "Error Number: " & Err.number
wscript.echo "Error Description: " & Err.Description
Err.clear

'No reason to continue script so quit
Wscript.quit
End if

'Save the contacts in the default folder
Set contactFolder = olNS.GetDefaultFolder(olFolderContacts)

'Create category if needed
If (Wscript.Arguments.Named.Item("Category") <> "") Then
contactCategory = Wscript.Arguments.Named.Item("Category")
Else
contactCategory = ""
End if
on error goto 0
'*****************************************************************
' Add Contacts First
'*****************************************************************

Dim strSplit
Dim bContact
Dim bFullName

'Start parsing file
Do Until objFile.AtEndOfStream
strLine=objFile.ReadLine

'Create contact object
Set contact = olApp.CreateItem(olContactItem)
bContact = false
bFullName = false
Do Until ((Trim(strLine) = "") or (objFile.AtEndOfStream))
strSplit = Split(Trim(strLine),":")
select case Trim(Lcase(strSplit(0)))
case "objectclass"
if Trim(strSplit(1)) = "person" then
bContact = true
end if
case "givenname"
contact.FirstName = Trim(strSplit(1))
bFullName = true
case "sn"
contact.LastName = Trim(strSplit(1))
case "cn"
contact.FullName = Trim(strSplit(1))
case "mail"
contact.Email1Address = Trim(strSplit(1))
contact.Email1DisplayName = Trim(strSplit(1))
'Save contact as email address if no name
If bFullName <> True Then
contact.FullName = Trim(strSplit(1))
contact.FileAs = Trim (strSplit(1))
End If
case "telephonenumber"
contact.BusinessTelephoneNumber = Trim(strSplit(1))
case "homephone"
contact.HomeTelephoneNumber = Trim(strSplit(1))
case "facsimiletelephonenumber"
contact.BusinessFaxNumber = Trim(strSplit(1))
case "homepostaladdress"
contact.HomeAddressStreet = Trim(strSplit(1))
case "mozillahomelocalityname"
contact.HomeAddressCity = Trim(strSplit(1))
case "mozillahomestate"
contact.HomeAddressState = Trim(strSplit(1))
case "mozillahomepostalcode"
contact.HomeAddressPostalCode = Trim(strSplit(1))
case "mozillahomecountryname"
contact.HomeAddressCountry = Trim(strSplit(1))
case "postalAddress"
contact.BusinessAddressStreet = Trim(strSplit(1))
case "l"
contact.BusinessAddressCity = Trim(strSplit(1))
case "st"
contact.BusinessAddressState = Trim(strSplit(1))
case "postalcode"
contact.BusinessAddressPostalCode = Trim(strSplit(1))
case "c"
contact.BusinessAddressCountry = Trim(strSplit(1))
case "title"
contact.JobTitle = Trim(strSplit(1))
case "ou"
contact.Department = Trim(strSplit(1))
case "o"
contact.CompanyName = Trim(strSplit(1))
case "workurl"
contact.BusinessHomePage = Replace(Trim(strSplit(2)),"//","")
case "homeurl"
contact.PersonalHomePage = Replace(Trim(strSplit(2)),"//","")
case "description"
contact.Body = Trim(strSplit(1))
case "xmozillanickname"
contact.NickName = Trim(strSplit(1))
'Template fo addistional attributes
'case ""
' contact. = Trim(strSplit(1))
end select
strLine=objFile.ReadLine
Loop

'If this is a contact, save it
If bContact Then
contact.Categories = contactCategory
contact.Save
wscript.echo "Contact Created:" & contact.FullName
End If
Loop

objFile.Close


'*****************************************************************
' Now Add Distribution Lists
'*****************************************************************

'Reopen file
Set objFile = mainFSO.OpenTextFile(Wscript.Arguments.Item(0), 1)

Dim subSplit
Dim bList
Dim list
Dim objRcpnt

'Start parsing file
Do Until objFile.AtEndOfStream
strLine=objFile.ReadLine

'Create a distributil list
Set list = olApp.CreateItem(olDistributionListItem)
bList = false
bFullName = false
Do Until ((Trim(strLine) = "") or (objFile.AtEndOfStream))
strSplit = Split(Trim(strLine),":")
select case Trim(Lcase(strSplit(0)))
case "objectclass"
if Trim(strSplit(1)) = "groupOfNames" then
bList = true
end if
case "cn"
list.DLName = Trim(strSplit(1))
case "description"
'list.Description = Trim(strSplit(1))
case "member"
if (Ubound(strSplit) = 1 ) then
subSplit = Split(strSplit(1),"mail=")
if (Ubound(subSplit) = 1) then
'Create recipient with email address
set objRcpnt = olApp.Session.CreateRecipient(subSplit(1))
objRcpnt.Resolve
list.AddMember objRcpnt
end if
end if
end select
strLine=objFile.ReadLine
Loop
'If distribution list, save it
If bList Then
List.Categories = contactCategory
List.Save
wscript.echo "List Created:" & list.DLName
End If
Loop

objFile.Close



Sub CheckForCScript()
If Not Lcase(WScript.FullName) = Lcase(WScript.Path & "\cscript.exe") Then

UsageString = "Please launch script using cscript.exe" & vbcrlf
UsageString = UsageString & "Usage: cscript.exe ImportLdif.vbs LdifFileName [/Category:""My Category Name""] " & vbcrlf
UsageString = UsageString & vbtab & "/Category" & vbtab & "Optional - Creates a category for the imported address book"
oShell.Popup UsageString
WScript.Quit 0
End If
End Sub

Download the script here.

--Nick

Sunday, April 8, 2007

Script: Exchange 2007 backups with NTBackup

NTBackup is a great solution for Exchange disaster recovery without buying expensive additions to your current backup software. We use NTBackup to backup each Exchange database to its own backup file. Afterwards our primary backup software will send each of those files to tape. Here is a script we use to dynamically backup all the databases on our Exchange servers using NTBackup.

Optimizing NTBackup


Technet has a great article on how Microsoft IT uses NTBackup to backup their Exchange 2003 clusters. This article recommends some registry entries to help optimize performance as well as details about an enhanced NTBackup version that is included with Windows 2003 Service Pack 1. These performance enhancements cut our backup times by more than half. To start the script I set these registry values.

#Add registry keys to enhance NTBackup performance
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Logical Disk Buffer Size" -Value:"64" -PropertyType:"String" -Force
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Max Buffer Size" -Value:"1024" -PropertyType:"String" -Force
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Max Num Tape Buffers" -Value:"16" -PropertyType:"String" -Force


Detect Databases on Each Server


Next, the script will detect all the mailbox databases on the server. The same steps will be performed for public folder databases.

Get-MailboxDatabase -Server:$ServerName | foreach{
...


Creating the Backup Command File


Normally a ‘backup command’ or .bks file will be created by the NTBackup GUI. The script will create a .bks file for each database with the correct JET syntax and file encoding.

#Create the backup JET file for NTBackup
$JetBackupSyntax = "JET " + $_.ServerName + "\Microsoft Information Store\" + $_.StorageGroupName + "\" + $_.AdminDisplayName + "`n"
$BksFileName = $BackupScriptPath + $_.ServerName + "" + $_.StorageGroupName + "" + $_.AdminDisplayName + ".bks"
$JetBackupSyntax | out-file -filepath $BksFileName -encoding unicode


Invoking NTBackup


The ‘&’ character is an alias for the Invoke-Expression cmdlet. I use ‘cmd /c’ to ensure that the script waits for the completion of the database backup before continuing.

#Call NTBackup to backup the database
$BksDescriptiveName = $_.ServerName + "-" + $_.StorageGroupName.Replace(" ","_") + "-" + $_.AdminDisplayName.Replace(" ","_")
&cmd /c "C:\WINDOWS\system32\ntbackup.exe backup `"@$BksFileName`" /n $BksDescriptiveName /d $BksDescriptiveName /v:no /r:no /rs:no /hc:off /m normal /fu /j `"$BksDescriptiveName`" /l:s /f $BackupPath$BksDescriptiveName.bkf"


Compiling a Single Log File


Each backup process will create a log file in the format of backup##.log within the %userprofile%\Local Settings\Application Data\Microsoft\Windows NT\NTBackup\data\ directory. The script will add the contents of the latest backup file to the backup log for the server.

#Append database backup log to server backup log
&type (get-childitem "$Home\Local Settings\Application Data\Microsoft\Windows NT\NTBackup\data\" | Sort -Property:LastWriteTime -Descending)[0].FullName >> $BackupLog


At the end of the script, this file can be formatted and emailed to the system administrators.

#Add line breaks to format the backup log
$BackupLogFormatted = ""
Get-Content $BackupLog | foreach { $BackupLogFormatted += $_ + "`n" }

#Email the backup log
$smtp = New-Object Net.Mail.SmtpClient -arg $EmailSMTPServer
$smtp.Send($EmailReportFromAddress,$EmailReportTo,"Exchange Backup Results for " + $ServerName + ": " + $(Get-Date).ToString('MM/dd/yyyy'),$BackupLogFormatted)


Cluster Version


To backup our cluster we created separate cluster groups for our backup disk resource. This cluster group is moved to the corresponding active server to write the backup files. Afterwards it is moved to a passive server that sends the backup files to tape. We schedule this script to be run at the same time on all cluster nodes. It tests if a file path exists (that would be owned by an exchange virtual server) and if so perform the backup for the corresponding server node.

if (Test-Path G:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS1 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."

#Backup databases on EXEVS1 to the R:\ drive
perform_backup "EXEVS1" "R:\"

write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS1 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
elseif (Test-Path H:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS2 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."
perform_backup "EXEVS2" "S:\"
write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS2 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
elseif (Test-Path I:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS3 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."
perform_backup "EXEVS3" "T:\"
write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS3 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
else
{
write-host "--- NO BACKUP REQUIRED ---";
}


Scheduling the Task


Because this script uses PoweSshell with Exchange cmdlets you can’t just schedule the .ps1 file. You must run PowerShell, add in the Exchange PSConsoleFile, and invoke the script as a command.

C:\WINDOWS\system32\WindowsPowerShell\v1.0\powershell.exe -PSConsoleFile "C:\Program Files\Microsoft\Exchange Server\bin\exshell.psc1" -command "c:\util\Backup-Databases.ps1"

The Script



function perform_backup ([string] $ServerName, [string] $BackupRootDrive) {

### Start User Defined Variables ###
#Set the backup file paths
$BackupScriptPath = $BackupRootDrive + "BackupFiles\"
$BackupPath = $BackupRootDrive + "DailyBackups\"

#Set email options
$EmailReportTo = "user@domain.com"
$EmailReportFromAddress = "user@domain.com"
$EmailSMTPServer = "smtp.domain.com"

$BackupLog = $BackupScriptPath + "ExchangeBackupLogs.log"

### End User Defined Variables ###

#Create an empty backup log file
new-item $BackupLog -type file -force

#Add registry keys to enhance NTBackup performance
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Logical Disk Buffer Size" -Value:"64" -PropertyType:"String" -Force
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Max Buffer Size" -Value:"1024" -PropertyType:"String" -Force
New-ItemProperty -Path:"HKCU:Software\Microsoft\Ntbackup\Backup Engine" -Name:"Max Num Tape Buffers" -Value:"16" -PropertyType:"String" -Force

Get-MailboxDatabase -Server:$ServerName | foreach{

#Create the backup JET file for NTBackup
$JetBackupSyntax = "JET " + $_.ServerName + "\Microsoft Information Store\" + $_.StorageGroupName + "\" + $_.AdminDisplayName + "`n"
$BksFileName = $BackupScriptPath + $_.ServerName + "" + $_.StorageGroupName + "" + $_.AdminDisplayName + ".bks"
$JetBackupSyntax | out-file -filepath $BksFileName -encoding unicode

#Call NTBackup to backup the database
$BksDescriptiveName = $_.ServerName + "-" + $_.StorageGroupName.Replace(" ","_") + "-" + $_.AdminDisplayName.Replace(" ","_")
&cmd /c "C:\WINDOWS\system32\ntbackup.exe backup `"@$BksFileName`" /n $BksDescriptiveName /d $BksDescriptiveName /v:no /r:no /rs:no /hc:off /m normal /fu /j `"$BksDescriptiveName`" /l:s /f $BackupPath$BksDescriptiveName.bkf"

#Append database backup log to server backup log
&type (get-childitem "$Home\Local Settings\Application Data\Microsoft\Windows NT\NTBackup\data\" | Sort -Property:LastWriteTime -Descending)[0].FullName >> $BackupLog
}
Get-PublicFolderDatabase -Server:$ServerName | foreach{

#Create the backup JET file for NTBackup
$JetBackupSyntax = "JET " + $_.ServerName + "\Microsoft Information Store\" + $_.StorageGroupName + "\" + $_.AdminDisplayName + "`n"
$BksFileName = $BackupScriptPath + $_.ServerName + "" + $_.StorageGroupName + "" + $_.AdminDisplayName + ".bks"
$JetBackupSyntax | out-file -filepath $BksFileName -encoding unicode

#Call NTBackup to backup the database
$BksDescriptiveName = $_.ServerName + "-" + $_.StorageGroupName.Replace(" ","_") + "-" + $_.AdminDisplayName.Replace(" ","_")
&cmd /c "C:\WINDOWS\system32\ntbackup.exe backup `"@$BksFileName`" /n $BksDescriptiveName /d $BksDescriptiveName /v:no /r:no /rs:no /hc:off /m normal /fu /j `"$BksDescriptiveName`" /l:s /f $BackupPath$BksDescriptiveName.bkf"

#Append database backup log to server backup log
&type (get-childitem "$Home\Local Settings\Application Data\Microsoft\Windows NT\NTBackup\data\" | Sort -Property:LastWriteTime -Descending)[0].FullName >> $BackupLog
}

#Add line breaks to format the backup log
$BackupLogFormatted = ""
Get-Content $BackupLog | foreach { $BackupLogFormatted += $_ + "`n" }

#Email the backup log
$smtp = New-Object Net.Mail.SmtpClient -arg $EmailSMTPServer
$smtp.Send($EmailReportFromAddress,$EmailReportTo,"Exchange Backup Results for " + $ServerName + ": " + $(Get-Date).ToString('MM/dd/yyyy'),$BackupLogFormatted)

}

### Start of backup script ###

#Define backup server
#The cluster backup resource will be moved to this server upon completion
$BackupsServer = "BackupServerName"

if (Test-Path G:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS1 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."

#Backup databases on EXEVS1 to the R:\ drive
perform_backup "EXEVS1" "R:\"

write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS1 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
elseif (Test-Path H:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS2 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."
perform_backup "EXEVS2" "S:\"
write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS2 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
elseif (Test-Path I:\)
{
write-host ""
write-host "STARTING BACKUP (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
write-host "** Getting Backup Disk..."
CLUSTER GROUP "EXEVS3 Backups" /MOVETO:$env:COMPUTERNAME
write-host "** Performing Backup..."
perform_backup "EXEVS3" "T:\"
write-host "** Reassign Backup Disk to Server hit by TSM..."
CLUSTER GROUP "EXEVS3 Backups" /MOVETO:$BackupsServer
write-host ""
write-host "BACKUP COMPLETE (" $(get-date).Tostring("yyyy-MM-dd HH:mm:ss") ")"
write-host ""
}
else
{
write-host "--- NO BACKUP REQUIRED ---";
}


Download the clustered and standalone server scripts here.

--Nick

Friday, April 6, 2007

5 things you don't know about me

Evan tagged me so with a '5 things you don't know about me' post. So here it goes:

  1. I am a hockey fanatic. It started when I was a kid watching DU hockey and with the NHL playoffs about to start, this is my favorite time of the year. Too bad it looks like my team is going to finish just out of the playoffs this year despite a valiant effort.

  2. In grade school I told a teacher I did not need to write my term paper because I was going to play professional baseball. I was promptly suspended for the remainder of the season.

  3. To relax on the weekends and vacations I like to ride dirt bikes. My favorite place to ride is Ouray, CO but I am going to give Moab a try in June.

  4. I once wrote my own intrusion detection program that would compare the MD5 hashes of a file system and report any files that had been changed since the last scan.....in VBScript.

  5. Today I forwarded my desk phone line to Office Communications Server.


Now it's my turn: Matt Stehle and Josh Maher

Sunday, April 1, 2007

Delegating Distribution Group Management Via Outlook

Managing distribution lists is normally a duty of Exchange and/or Active Directory administrators. The process for adding or removing a member of a distribution list is likely to submit a request and wait until an administrator makes the necessary changes.

Using a combination of Active Directory permissions and the built-in tools of Outlook you can delegate the ability to manage distribution lists to the user. Here’s how to do it:

Adding Permissions


For a user to have the ability to manage distribution group membership they must be assigned the ‘Write Members’ active directory permission. This can be done in ADUC, but I find PowerShell much simpler.

Adding permission for a single user:

Add-ADPermission -Identity:'Group Display Name’ -User:domain\username -AccessRights ReadProperty, WriteProperty -Properties 'Member'

Adding permission for a group of users:

Add-ADPermission -Identity:'Group Display Name’ -User:'Display Name of Permissions Group’ -AccessRights ReadProperty, WriteProperty -Properties 'Member'

Modifying Group Membership within Outlook


After locating the group within the Global Address List, select ‘Modify Members…’ from the properties screen. To add new members select the ‘Add…’ option and located the desired users within the GAL. Members can be removed by highlighting the desired user and selecting the ‘Remove’ button.

If you receive a “Changes to the distribution list membership cannot be saved. You do not have sufficient permission to perform this operation on this object” error message either permissions are not assigned correctly or the user is not connecting a global catalog from the domain hosting the distribution group.

--Nick

Tuesday, March 27, 2007

Managing Exchange 2007 Recipients with C#

During our Exchange 2007 implementation I was faced with the task of integrating Exchange 2007 recipient management with our in-house identity management systems. Our active directory provisioning code was already written in .Net and implemented CDOEXM for recipient management. One of the things that concerned me the most about migrating to Exchange 2007 was the ability to manage recipients from within the existing code. CDOEXM is not supported for managing Exchange 2007 recipients and the idea of invoking the Exchange management shell from .Net code was a foreign concept to me. Information on how to do so was sparse. Many many thanks to Vivek Sharma for posting sample code before anything else was available. My early code was based directly off his examples. Since then I have created a few applications using these methods and I have learned quite a few lessons while doing so. Below are some of my experiences and customizations that made the process much simpler.

Web References


First, here are some helpful links to get started with invoking the management shell from C# code:

Creating Applications that Use the Default Host
http://msdn2.microsoft.com/en-us/library/ms714671.aspx

Mstehle's Introduction to Exchange Powershell Automation
Part 1: http://blogs.msdn.com/mstehle/archive/2007/01/25/fyi-introduction-to-exchange-powershell-automation-part-1.aspx
Part 2: http://blogs.msdn.com/mstehle/archive/2007/01/25/outbox-introduction-to-exchange-powershell-automation-part-2.aspx

Sample Code: Calling Exchange cmdlets from .Net code
http://www.viveksharma.com/techlog/2006/07/27/sample-code-calling-exchange-cmdlets-from-net-code

Caveats


  • Creating the necessary Runspace and RunspaceInvoke instances will take 2-3 seconds to get setup in a compiled application.
  • It can take 6-9 seconds to execute the first Exchange command on a computer with only the Exchange 2007 management tools installed. You may also receive the following errors in the event log:

    Event Type: Error
    Event Source: MSExchange ADAccess
    Event Category: General
    Event ID: 2152
    Date: 3/26/2007
    Time: 7:38:52 PM
    User: N/A
    Computer: HASTY
    Description:
    Process eIDAD.exe (PID=2984). A remote procedure call (RPC) request to the Microsoft Exchange Active Directory Topology service failed with error 1753 (Error 6d9 from HrGetServersForRole). Make sure that the Remote Procedure Call (RPC) service is running. In addition, make sure that the network ports that are used by RPC are not blocked by a firewall.


    Event Type: Error
    Event Source: MSExchange Common
    Event Category: Devices
    Event ID: 106
    Date: 3/26/2007
    Time: 7:38:57 PM
    User: N/A
    Computer: HASTY
    Description:
    Performance counter updating error. Counter name is Latest Exchange Topology Discovery Time in Seconds, category name is MSExchange Topology. Optional code: 2.

  • Invoking the Runspace and adding the Exchange management snap-in does not give you the same environment as opening the Exchange Management Shell. One of the most striking differences is that the $AdminSessionADSettings environment settings are not available. By default, all Exchange management cmdlets will search the entire forest. You can best replicate this environment by starting a normal PowerShell instance and add the Microsoft.Exchange.Management.Powershell.Admin snap-in. (Run “Add-PSSnapin -Name:’Microsoft.Exchange.Management.PowerShell.Admin’ ”).
  • To compile your code you must add a reference for Systems.Management.Automation. By default this is added to the global assembly cache when PowerShell is installed. The best way to add this DLL as a reference is to manually edit the .csproj file associated with the project and add the following line to the references itemgroup.
    <reference include="System.Management.Automation">

    Adding the reference in this manner will ensure that you are always using the appropriate DLL whether you are running on a 32 or 64 bit machine.


Birth of the wrapper class


My first attempt at integrating recipient management into our identity management provisioning code worked well but I paid quite a time penalty when invoking the shell and issuing commands to update/set attributes on each account. Updating a single account was not too bad (~1 second per account) after the Runspace was completely setup and all the Exchange DLLs were loaded. However, when updating all 60,000+ accounts the time penalty added up and became an unacceptable bottleneck. To prevent this, I implemented logic to check if each attribute needed to be updated before invoking the command to set the attribute. This logic removed the bottleneck and very soon the code was running just as quickly as before; except for the time needed to create the Runspace. These optimizations made the Runspace unnecessary for most instances when an update was run on a single account and no Exchange attributes were changed. Thus the wrapper class was setup to store a Runspace that would only be initialized if a command was invoked.

Notes About the Wrapper Class


  • The wrapper class is a singleton and must me instantiated as such. Because the class is a singleton, only one Runspace will be created no matter how many times the class is instantiated.
  • The Runspace is only created once the first command is invoked. The same is true with RunspaceInvoke.
  • The wrapper class combines methods from both the Runspace and RunspaceInvoke classes.


The Wrapper Class


using System;

using System.IO;

using System.Text;

using System.Reflection;

using System.Diagnostics;

using System.Collections;

using System.Collections.Generic;

using System.Collections.ObjectModel;

using System.Management.Automation;

using System.Management.Automation.Host;

using System.Management.Automation.Runspaces;

 

namespace EMSDemo.Utility

{

   public sealed class ExchangeManagementShellWrapper

   {

      #region Variable Declaration

      private RunspaceConfiguration rc;         //RunspaceConfiguration

      private Runspace r;                       //Runspace

      private RunspaceInvoke ri;                //RunspaceInvoke

      private static readonly ExchangeManagementShellWrapper instance = new ExchangeManagementShellWrapper(); //Singleton instance

      #endregion

 

      private ExchangeManagementShellWrapper(){}

 

      public static ExchangeManagementShellWrapper Instance

      {

         get

         {

            return instance;

         }

      }

 

      private void InitializeRunspace()

      {

         //Setup Runspace environment for Exchange Management Shell

         AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(AssemblyResolver);

        

         //Create RunspaceConfiguration

         rc = RunspaceConfiguration.Create();

 

         //Add PSSnapIn for Exchange 2007 and check for warnings

         PSSnapInException warning;

         PSSnapInInfo info = rc.AddPSSnapIn("Microsoft.Exchange.Management.PowerShell.Admin", out warning);

         if (warning != null)

         {

            // A warning is not expected, but if one is detected

            // write the warning and return.

            System.Console.Write(warning.Message);

            return;

         }

 

         //Create and open runspace

         r = RunspaceFactory.CreateRunspace(rc);

         r.Open();

 

      }

 

      private void InitializeRunspaceInvoke()

      {

         //Create runspace if it has not already been created

         if (r == null) { InitializeRunspace(); }

 

         //Create RunspaceInvoke

         ri = new RunspaceInvoke(r);

      }

 

      public ICollection<PSObject> RunspaceInvoke(string EMSCommand)

      {

         //Create RunspaceInvoke if it has not already been created

         if (ri == null) { InitializeRunspaceInvoke(); }

 

         //Invoke the commmand and return the results

         return ri.Invoke(EMSCommand);

      }

 

      public ICollection<PSObject> RunspaceInvoke(string EMSCommand, IEnumerable input)

      {

         //Create RunspaceInvoke if it has not already been created

         if (ri == null) { InitializeRunspaceInvoke(); }

 

         //Invoke the commmand and return the results

         return ri.Invoke(EMSCommand, input);

      }

 

      public ICollection<PSObject> RunspaceInvoke(string EMSCommand, out IList errors)

      {

         //Invoke the RunspaceInvoke method with a null input

         return RunspaceInvoke(EMSCommand, null, out errors);

      }

     

      public ICollection<PSObject> RunspaceInvoke(string EMSCommand, IEnumerable input, out IList errors)

      {

         //Create RunspaceInvoke if it has not already been created

         if (ri == null) { InitializeRunspaceInvoke(); }

 

         //Invoke the commmand and return the results and errors

         return ri.Invoke(EMSCommand, input, out errors);

      }

 

      public ICollection<PSObject> PipelineInvoke(Collection<Command> EMSCommands, out PipelineReader<object> Errors)

      {

         //Create Runspace if it has not already been created

         if (r == null) { InitializeRunspace(); }

 

         //Create Pipeline and add the commands

         Pipeline pipeline = r.CreatePipeline();

         foreach (Command item in EMSCommands)

         {

            pipeline.Commands.Add(item);

         }

 

         //Invoke the commands and return the results and errors

         ICollection<PSObject> results = pipeline.Invoke();

         Errors = pipeline.Error;

         pipeline = null;

         return results;

      }

 

      public ICollection<PSObject> PipelineInvoke(Collection<Command> EMSCommands)

      {

         //Create Runspace if it has not already been created

         if (r == null) { InitializeRunspace(); }

 

         //Create Pipeline and add the commands

         Pipeline pipeline = r.CreatePipeline();

         foreach (Command item in EMSCommands)

         {

            pipeline.Commands.Add(item);

         }

        

         //Invoke the commands and return the results

         return pipeline.Invoke();

      }

 

      public ICollection<PSObject> PipelineInvoke(Command EMSCommand, out PipelineReader<object> Errors)

      {

         //Create Runspace if it has not already been created

         if (r == null) { InitializeRunspace(); }

 

         //Create Pipeline and add the command

         Pipeline pipeline = r.CreatePipeline();

         pipeline.Commands.Add(EMSCommand);

 

         //Invoke the command and return the results and errors

         ICollection<PSObject> results = pipeline.Invoke();

         Errors = pipeline.Error;

         pipeline = null;

         return results;

      }

 

      public ICollection<PSObject> PipelineInvoke(Command EMSCommand)

      {

         //Create Runspace if it has not already been created

         if (r == null) { InitializeRunspace(); }

 

         //Create Pipeline and add teh command

         Pipeline pipeline = r.CreatePipeline();

         pipeline.Commands.Add(EMSCommand);

 

         //Invoke the command and return the results

         return pipeline.Invoke();

      }

 

      public void Dispose()

      {

         //Close the Runspace and cleanup

         r.Close();

         ri = null;

         r = null;

      }

 

      private static System.Reflection.Assembly AssemblyResolver(object p, ResolveEventArgs args)

      {

         //Add path for the Exchange 2007 DLLs

         if (args.Name.Contains("Microsoft.Exchange"))

         {

            return Assembly.LoadFrom(Path.Combine("C:\\Program Files\\Microsoft\\Exchange Server\\bin\\", args.Name.Split(',')[0] + ".dll"));

         }

         else

         {

            return null;

         }

      }

   }

}



Examples


Each example will perform the same task and produce the same results using both the RuspaceInvoke and PipelineInvoke methods.

Initializing the Exchange Management Shell Wrapper

Singletons do not have a constructor so you must create an ‘instance’.

static void Main(string[] args)

{

  //Initialize the ExchangeManagementShellWrapper

  ExchangeManagementShellWrapper ems = ExchangeManagementShellWrapper.Instance;

  ICollection<PSObject> results;

  ...



Invoking a Single Command

In this example both the RunspaceInvoke and PipelineInvoke methods are used to get information about the mailbox for ‘knsmith’. In each foreach loop are different methods for accessing the data within the PSObject.

//Use the RunspaceInvoke command with a command string

results = ems.RunspaceInvoke("Get-Mailbox knsmith");

foreach (PSObject item in results)

{

  Console.WriteLine(item.Members["Name"].Value.ToString());

}

 

//Use the PipelineInvoke command with a Command object

Command EMSCommand = new Command("Get-Mailbox");

EMSCommand.Parameters.Add("Identity", "knsmith");

results = ems.PipelineInvoke(EMSCommand);

foreach (PSObject item in results)

{

  //A different way of accessing property info from the results

  PSPropertyInfo prop = (PSPropertyInfo)item.Properties["Name"];

  if (prop != null)

  {

    Console.WriteLine(prop.Value.ToString());

  }

}



Invoking Commands With Errors Ouput

In this example each command will attempt to create a new file at c:\temp called test.ps1. A new file will be created it one does not already exist. If the file does exist, an error will be returned and the output will be: “Error: The file ‘C:\temp\test.ps1’ already exists.

//Using RunspaceInvoke with errors

IList IErrors;

results = ems.RunspaceInvoke("New-Item -Path:'c:\\temp\\test.ps1' -Type:File", out IErrors);

foreach (PSObject item in results)

{

  Console.WriteLine(item.Members["Name"].Value.ToString());

}

foreach (object item in IErrors)

{

  Console.WriteLine("Error: {0}", item.ToString());

}

 

//Using PipelineInvoke with errors

EMSCommand = new Command("New-Item");

EMSCommand.Parameters.Add("Path", "c:\\temp\\test.ps1");

EMSCommand.Parameters.Add("Type", "File");

PipelineReader<object> errors;

results = ems.PipelineInvoke(EMSCommand, out errors);

foreach (PSObject item in results)

{

  Console.WriteLine(item.Members["Name"].Value.ToString());

}

if (errors.Count > 0)

{

  foreach (object obj in errors.ReadToEnd())

  {

    Console.WriteLine("Error: {0}", obj.ToString());

  }

}



Invoking Multiple Commands

Invoking multiple commands using a pipe is one of my most common tasks in PowerShell. The RunspaceInvoke method is pretty straight forward as you can just pass in a command string the same as you use in the Exchange Management Shell. The PipelineInvoke method is a little different. You must create a Command object for each command you wish to issue. After the Command objects have been created, add them in the order they are to be executed to the collection.

//Using the RunspaceInvoke command with a command string

ems.RunspaceInvoke("Get-Mailbox knsmith | Set-Mailbox -EmailAddressPolicyEnabled:$True");

 

//Using PipelineInvoke with a Command Collection

Collection<Command> CommandsList = new Collection<Command>();

Command GetMailbox = new Command("Get-Mailbox");

GetMailbox.Parameters.Add("Identity", "knsmith");

CommandsList.Add(GetMailbox);

 

Command SetMailbox = new Command("Set-Mailbox");

SetMailbox.Parameters.Add("EmailAddressPolicyEnabled", true);

CommandsList.Add(SetMailbox);

 

ems.PipelineInvoke(CommandsList, out errors);



Passing an Input Object to the Runspace

The RunspaceInvoke method offers the added ability to pass in an input collection. The key to this is that you must start your command string with “$input | ” so that your input will be processed. This example will show how you can get the attributes for a mailbox, make a change and commit the changes by passing back the results and using the Set-Mailbox cmdlet.

//Use the RunspaceInvoke command with a command string

results = ems.RunspaceInvoke("Get-Mailbox knsmith");

foreach (PSObject item in results)

{

  //Set the property to false

  item.Members["UseDatabaseQuotaDefaults"].Value = false;

}

 

IList SetErrors;

 

//Save the changes we made to the results

//The $input variable will be equal to the 'results' variable we pass in

ems.RunspaceInvoke("$input | Set-Mailbox", results, out SetErrors);

 

foreach (object error in SetErrors)

{

  Console.WriteLine(error.ToString());

}



Download the wrapper class and example code. (Right-click and 'Save Target As')

Conclusion


Whether or not you decide to use this wrapper class I hope it at least helps you with examples of how to utilize the RunspaceInvoke and Pipeline methods for invoking the Exchange management shell.

--Nick