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