I have been working on a mail migration within an environment that has a Hybrid Exchange configuration with a single 365 tenant but which synchronises Active Directory from multiple forests. As part of the migration there is a need to migrate on-prem user accounts from a legacy forest into a new forest, but the accounts need to continue to be synchronised with O365 using AD Sync for password changes. As the mailboxes have already been synchronised with an existing on-prem account, it wasn’t possible to do SMTP matching, so it was necessary to use hard matching with ImmutableID. I wrote the following script to help me as I needed to carry out the migration in small batches rather than big bang, and still allow clients to work on the system in the meantime and avoid any disruption to day to day activities.

The following script has allowed me to do that without disrupting mail flow or restricting the activities of the users other than within the 15-20 period it takes to migrate each batch. The script is broken down here but is also available at the end of the post as a ps1 file.
The first section contains all the variables

This section contains all the functions we will use later on

This section imports the pre-prepared CSV file which we have named migratelist.csv. This list is what is used to match the newly created accounts in the destination domain with the accounts in the old domain.  Before importing the file it will show a preview of the file contents so that there is an opportunity for the admin to trap any mistakes such as referencing the wrong file.  When creating this file you need to ensure that it contains the following row headers:


  • SrcName – contains the SAMAccountName of the existing user account eg. j.doe
  • DstName – contains the SAMAccountName of the destination account however it will appear in the new active directory domain ie john.doe
  • License – contains the “AccountSKUId” (this can be obtained by running Get-MsolAccountSku)
  • Password – Include the password you wish to be set on the mailbox once the mogration is complete.  Read this article for a guide on how to generate random passwords suitable for O365
  • GUID – Leave blank
  • ImmutableID – Leave blank
  • UPN – Leave blank

Delete any blank rows in the csv file (ie rows that would be imported as  ,,,,,,,,,,,,,,,,,,,,).

This section forces an AD synchronisation in the legacy environment.  It’s run as a job so that we can see that the script is still running and isn’t just hanging.  I don’t like it when my scripts go away and do things without reporting progress to me!

I found that very large mailboxes can take longer to appear in the list of soft deleted mailboxes in O365.  For that reason I’ve added a quick check that allows the administrator to see the size of all the mailboxes in the migration.  If there are any that are very large the administrator is then aware that more patience may be require for these to appear in the softdeleted mailbox list.

In order to fool O365 into deprovisioning the existing user account and softdeleting the mailbox, we need to make O365 think that the associated user account has been deleted.  There are two ways to do this… one is to use this undocumented filter and populate the “adminDescription” attribute for the user account with the value “User_NoO365Sync”.  The trouble with this is that it isn’t then clear exactly which accounts will sync and which won’t.  I prefer the second option which is to create an OU called “_Migrated Users” and to move the users to that. The reason is that it helps to organize the environment and make it clear which users are migrated and which aren’t.  It’s also preferable to deleting the accounts as it allows the migrated users to continue to log on in the legacy domain if that’s required.

Sync legacy AD again to propagate changes

Initiates a delta synchronisation cycle through AADsync.  This is the point at which O365 will think the users have been deleted in the local AD.  O365 will also remove the ImmutableID value at this stage so that we can re-populate it later.

This uses the migratelist object we imported earlier, and populates the it with the GUIDs from the new AD.  This will match the accounts from the spreadsheet with the new accounts and pull in the GUID data.  The GUID is then converted to a base 64 string that will match the required format for the ImmutableID in O365.  The revised list is then exported as “revisedMigrateList.csv” so that we have a backup.

Pulls the UPN value over from the Legacy AD and populates the file with the information. The revised list is then exported once again, this time as “revisedMasterListwithUPN.csv”.

This section is not always necessary, but it makes sure we have a backup of the exisiting distribution groups for peace of mind.  A lookup is done to populate a variable with all distribution groups for each user.  This is then exported to a csv file as a backup (in case there are issues with the migration – better safe than sorry).  It will create a backup of the existing lists, change the current list to a backup copy and archive any exisiting lists so that we never over-write any of this data.  Later on there is logic that will import these backups if the distribution list variable is empty – something that can happen if the script is stopped halfway through.  We have to rely on the backup because once the user has been deprovisioned, we can’t go back and import the data again.

The next step asks the user to check that the mailboxes are visible amongst the softdeleted mailboxes in O365 (as this can take a little time), then they are undeleted, which will set them to a cloud mailbox rather than “synced with AD”.  You could change  sort-object -Property Displayname to sort-object -Property WhenSoftDeleted to change the view to latest first if you prefer.  I did think about automating this part of the script by checking for the existence of the softdeleted mailboxes within the script rather than relying on the administrator but I think it’s better to have eyes on what’s going on at this point.  It’s a powerful script to just leave running without any human interaction. Continuing at this point will undelete the mailbox and assign the new password.

At this point I added the pause because we are at a point of no return (that’s not actually true as I do have a reverse migration script as well, but it’s a lot of hassle if we can avoid it. The immutableID is then updated with the value which was converted from the GUID and a license is assigned. The viability check just makes sure that the destination users are in the _Inbound Users OU in the target domain.  This is also filtered so that it is not synchronised by AD Sync.  It’s our ‘staging area’ for the incoming users.

One more chance to back out of the changes that have jsut been made here before the accounts are moved to an OU that will be synchronised.  I added this extra step because if the ImmutableID was not set earlier for any reason (such as the script errored), I didn’t want this to run on as it would create duplicate accounts in Azure.

The new AD is then synchronised to ensure that all domain controllers are up to date with the changes

AADSync is the forced to run another synchronisation.  At this point it will spot the matching ImmutableIDs on the cloud mailbox and the local AD account and associate the cloud mailbox with the on-prem account.  The status of the mailbox in O365 will then change back to “synced with AD”.

The distribution groups should have remained with the mailbox, but I have know some get missed.  This next step just tries to add the users to the distribution groups we gathered earlier.  If the variable is empty it will warn and then try to import the backup.  This may still be empty (if there weren’t any groups to add) but the script can just be allowed to run on.  If the user is already a member of the group then the error checking will display the message that the use is already a member of the group.