Summary / Problem Statement
When an organization completes an Exchange Online migration, any distribution groups that remain DirSynced from Active Directory become unmanageable in the cloud.
Common symptoms include:
-
Users can no longer edit group membership in Outlook.
-
Azure AD–synchronized groups can’t be modified in Exchange Online.
-
Adding external contacts requires on-prem directory objects.
Rather than rebuild these manually, the following PowerShell script automates a full, safe conversion of on-premises distribution groups into native cloud objects.
Overview of the Script
The script performs the following workflow:
-
Connects to Exchange Online PowerShell (MFA-aware).
-
Creates working and export directories for backups.
-
Backs up all synchronized distribution-group attributes and memberships to CSV.
-
Builds cloud copies of each group, prefixed with
Cloud_. -
Removes the original on-prem groups.
-
Triggers an Azure AD Connect delta sync.
-
Renames the new cloud groups back to their original names.
-
Re-enables the sync scheduler.
Average run time: ≈ 20 minutes for typical tenants.
Note:
The script is designed for execution on the domain controller running Azure AD Connect.
It excludes mail-enabled security groups.
Usage
Run in an elevated PowerShell session.
RequiresExchangeOnlineManagementandActiveDirectorymodules.
Always review backups before deleting on-prem groups.
<#
#########################################################################################
##
## Name: DG_Cloud.PS1
##
## Version: 1.0
##
## Description: $ Installs required components for Exchange Online Powershell Management
## $ Creates a "Working" folder (C:\PoSH-Working) for backups.
## $ Creates an "Exports" folder for the temp files needed to migrate the
## Distribution Lists.
## $ Backs up the Distribution List Names and Attributes to DG_Details_Backup.csv
## $ Backs up the Distribution List Members to DG_Members_Backup.csv
## $ Capable of running mulitple times and retaining existing backups - creates
## new backups each time it's run if any new groups are detected
## $ Selectively Creates a copy of each Distribution Group called Cloud_$Group
## that are specifically Distribution Groups and not Mail-Enabled Security
## groups.
## $ Deletes the selected Distribtuion Groups from Active Directory
## $ Initiates an Azure AD Connect to remove the AD objects from Cloud Environment
## $ Forces wait period of 5 minutes to allow Azure AD to synchronize with Exchange
## $ Completes process by renaming Cloud_$Group back to original name
##
## Usage: Execute script in PowerShell with elevated privileges
##
## Author: Jason Zondag
##
## Disclaimer: Has not been tried in every conceivable environment - always check the results
## and fall back on the backups created to recreate the Distribution Groups if
## necessary
##
#########################################################################################
###### ALTERNATIVE CODE FOR MFA LOGIN TO OFFICE 365 ####################################
#Connect & Login to ExchangeOnline (MFA)
$getsessions = Get-PSSession | Select-Object -Property State, Name
$isconnected = (@($getsessions) -like '@{State=Opened; Name=ExchangeOnlineInternalSession*').Count -gt 0
If ($isconnected -ne "True") {
Connect-ExchangeOnline
}
#########################################################################################
#>
clear
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host "!!!!!IMPORTANT!!!!!!" -ForeGroundColor Red
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host "!!!!!IMPORTANT!!!!!!" -ForeGroundColor Red
Write-Host "YOU MUST RUN THIS SCRIPT FROM THE DOMAIN CONTROLLER THAT IS RUNNING AZURE AD CONNECT" -ForeGroundColor Red
sleep 5
Write-Host "IF YOU ARE NOT PLEASE USE CTRL + C TO ESCAPE AND RUN FROM THE APPROPRIATE DOMAIN CONTROLLER" -ForeGroundColor Red
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host "It's also important to note that this only affects Distribution Lists and not Mail-Enabled" -ForeGroundColor Green
Write-Host "Security Groups. Mail-Enabled Security Groups must be handled differently." -ForeGroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
sleep 15
Pause
Write-Host "Connecting to Exchange Online - installing all required PowerShell Modules and initiaing a connection" -ForegroundColor Green
# --------------------------------------------------------------------------
# Load PowerShell Modules
# --------------------------------------------------------------------------
Set-ExecutionPolicy RemoteSigned -Force
Import-Module ActiveDirectory
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Install-Module -Name ExchangeOnlineManagement -Force
Import-Module ExchangeOnlineManagement
#Connect & Login to ExchangeOnline (MFA)
$getsession = get-pssession | select-object -Property State | select -expandproperty state
If ($getsession -ne "Opened") {
Connect-ExchangeOnline
}
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host
Write-host
Write-Host "______________________________________________________________________________________________" -ForegroundColor Cyan
Write-Host "Synchronized Distribution Groups with no ManagedBy settings will be defaulted to Organization" -ForeGroundColor Yellow
Write-Host "Management. This value cannot be translated." -ForeGroundColor Yellow
Write-host
Write-Host "You must set a default account value to replace Organization Management." -ForeGroundColor Green
Write-Host "The default account must be a valid licensed address for this tenant. IE. user@domain.com " -ForeGroundColor Green
$ManagedByDefault = Read-host "Enter the email address of a valid licensed account for this tenant:"
Write-Host "______________________________________________________________________________________________" -ForegroundColor Cyan
# Disable Azure AD Connect from initiating a sync while this process is underway
Set-ADSyncScheduler -SyncCycleEnabled $false
Write-host "Azure AD Connect Schedule Sync has been disabled temporarily."
# --------------------------------------------------------------------------
# Create Working and Export folders
# --------------------------------------------------------------------------
Write-Host "Creating a Working Directory C:\DG-Migrate and an Exports Directory within the Working Directory" -ForegroundColor Green
# Create a working directory
$orginfo = Get-OrganizationConfig | select -expandproperty Name
$WorkingDirectory = "C:\Posh-Working\" + $orginfo + "\"
$ExportDirectory = $WorkingDirectory + "ExportedAddresses\"
If(!(Test-Path -Path $WorkingDirectory )){
# if WorkingDirectory doesn't exist neither does ExportDirectory - create them both
Write-Host " Creating Directory: $WorkingDirectory"
New-Item -ItemType directory -Path $WorkingDirectory | Out-Null
Write-Host " Creating Directory: $ExportDirectory"
New-Item -ItemType directory -Path $ExportDirectory | Out-Null
} else {
# WorkingDirectory may exist but that doesn't mean ExportDirectory does - create if it doesn't exist
If(!(Test-Path -Path $ExportDirectory )){
Write-Host " Creating Directory: $ExportDirectory"
New-Item -ItemType directory -Path $ExportDirectory | Out-Null
}
}
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host "Creating a backup of all AD Synchronized Distribution Lists and placing into the Working Directory" -ForegroundColor Green
# --------------------------------------------------------------------------
# Export all the Distribution Group Information to a separate file
# --------------------------------------------------------------------------
$check = (get-distributiongroup | Where {($_.IsDirSynced -eq $true) -AND ($_.RecipientType -eq "MailUniversalDistributionGroup")})
if ((($check | Measure-Object).count) -ne 0) {
# Not 0 so we found some Distribution Groups to migrate
# We don't want to overwrite an existing backup set - rename any existing files with a time stamp
if (Test-Path ($WorkingDirectory + "DG_Details_Backup.csv")) {
$filename = ($WorkingDirectory + "DG_Details_Backup.csv")
$fileObj = get-item $filename
$DateStamp = get-date -uformat "%Y-%m-%d@%H-%M-%S"
$extOnly = $fileObj.extension
if ($extOnly.length -eq 0) {
$nameOnly = $fileObj.Name
rename-item "$fileObj" "$nameOnly-$DateStamp"
}
else {
$nameOnly = $fileObj.Name.Replace( $fileObj.Extension,'')
rename-item "$fileName" "$nameOnly-$DateStamp$extOnly"
} }
$check | select `
GroupType, `
SamAccountName, `
IsDirSynced, `
@{label="ManagedBy";expression={
($_.managedby `
| % { get-mailbox -identity $_ | select-object -ExpandProperty PrimarySMTPAddress } `
| Where-Object {$_ -like "*@*"}) -join ';'}
}, `
MemberJoinRestriction, `
MemberDepartRestriction, `
ReportToOriginatorEnabled, `
Description, `
AddressListMembership, `
Alias, `
DisplayName, `
PrimarySMTPAddress, `
@{label="EmailAddressess";expression={
($_.EmailAddresses | Where-Object {$_ -like "*smtp:*" }) -join ';'}
},`
ExternalDirectoryObjectId, `
HiddenFromAddressListsEnabled, `
LegacyExchangeDN, `
MaxSendSize, `
MaxReceiveSize, `
ModeratedBy, `
ModerationEnabled, `
PoliciesIncluded, `
PoliciesExcluded, `
EmailAddressPolicyEnabled, `
RecipientType, `
RecipientTypeDetials, `
RequireSenderAuthenticationEnabled, `
WindowsEmailAddress, `
Identity, `
Id, `
Name, `
DistinguishedName, `
ExchangeObjectId, `
Guid `
| Export-CSV ($WorkingDirectory + "DG_Details_Backup.csv") -NoTypeInformation
sleep 20
}
else {
Write-Host "There are no appropriate Distribution Lists to migrate. Cancelling migration."
Break
}
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-Host "Creating a backup of Distribution List Membership and placing in the Working Directory" -ForegroundColor Green
# --------------------------------------------------------------------------
# Export all the Distribution Group Members to a separate file
# --------------------------------------------------------------------------
$output = @()
$Identities = import-csv ($WorkingDirectory + "DG_Details_Backup.csv") | select Name,PrimarySmtpAddress,Managedby,GroupType,RecipientType
If ($Identities) {
Foreach($group in $Identities) {
$Members = Get-DistributionGroupMember $group.PrimarySmtpAddress -resultsize unlimited
if (@($Members.count) -eq 0) {
#$managers = ($group | Select @{Name='DistributionGroupManagers';Expression={[string]::join(";", ($_.Managedby))}})
$userObj = New-Object PSObject
$userObj | Add-Member NoteProperty -Name "DisplayName" -Value EmptyGroup
$userObj | Add-Member NoteProperty -Name "Alias" -Value EmptyGroup
$userObj | Add-Member NoteProperty -Name "RecipientType" -Value EmptyGroup
$userObj | Add-Member NoteProperty -Name "Recipient OU" -Value EmptyGroup
$userObj | Add-Member NoteProperty -Name "Primary SMTP address" -Value EmptyGroup
$userObj | Add-Member NoteProperty -Name "Distribution Group" -Value $group.Name
$userObj | Add-Member NoteProperty -Name "Distribution Group Primary SMTP address" -Value $group.PrimarySmtpAddress
$userObj | Add-Member NoteProperty -Name "Distribution Group Managers" -Value $managers.DistributionGroupManagers
$userObj | Add-Member NoteProperty -Name "Distribution Group Type" -Value $group.GroupType
$userObj | Add-Member NoteProperty -Name "Distribution Group Recipient Type" -Value $group.RecipientType
$output+=$UserObj
}
else {
Foreach($Member in $members) {
#$managers = $group | Select @{Name='DistributionGroupManagers';Expression={[string]::join(";", ($_.Managedby))}}
$userObj = New-Object PSObject
$userObj | Add-Member NoteProperty -Name "DisplayName" -Value $Member.Name
$userObj | Add-Member NoteProperty -Name "Alias" -Value $Member.Alias
$userObj | Add-Member NoteProperty -Name "RecipientType" -Value $Member.RecipientType
$userObj | Add-Member NoteProperty -Name "Recipient OU" -Value $Member.OrganizationalUnit
$userObj | Add-Member NoteProperty -Name "Primary SMTP address" -Value $Member.PrimarySmtpAddress
$userObj | Add-Member NoteProperty -Name "Distribution Group" -Value $group.Name
$userObj | Add-Member NoteProperty -Name "Distribution Group Primary SMTP address" -Value $group.PrimarySmtpAddress
$userObj | Add-Member NoteProperty -Name "Distribution Group Managers" -Value $managers.DistributionGroupManagers
$userObj | Add-Member NoteProperty -Name "Distribution Group Type" -Value $group.GroupType
$userObj | Add-Member NoteProperty -Name "Distribution Group Recipient Type" -Value $group.RecipientType
$output+=$UserObj
}
}
}
# We don't want to overwrite an existing backup set - rename any existing files with a time stamp
if (Test-Path ($WorkingDirectory + "DG_Members_Backup.csv")) {
$filename = ($WorkingDirectory + "DG_Members_Backup.csv")
$fileObj = get-item $filename
$DateStamp = get-date -uformat "%Y-%m-%d@%H-%M-%S"
$extOnly = $fileObj.extension
if ($extOnly.length -eq 0) {
$nameOnly = $fileObj.Name
rename-item "$fileObj" "$nameOnly-$DateStamp"
}
else {
$nameOnly = $fileObj.Name.Replace( $fileObj.Extension,'')
rename-item "$fileName" "$nameOnly-$DateStamp$extOnly"
} }
$output | Export-CSV ($WorkingDirectory + "DG_Members_Backup.csv") -NoTypeInformation
}
# ----------------------------------------------------------------------------------------------
sleep 15
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
# --------------------------------------------------------------------------
# Create the Cloud copies of the Distribution Lists
# --------------------------------------------------------------------------
Write-Host "Creating Cloud copies of each AD Synced Distribution List" -ForegroundColor Green
$Identities = import-csv ($WorkingDirectory + "DG_Details_Backup.csv") | select -expandproperty PrimarySmtpAddress
# Create the cloud versions
If ($Identities) {
foreach ($group in $identities) {
If (((Get-DistributionGroup $group -Resultsize Unlimited -ErrorAction 'SilentlyContinue').IsValid) -eq $true) {
$OldDG = Get-DistributionGroup $group
[System.IO.Path]::GetInvalidFileNameChars() | ForEach {$Group = $Group.Replace($_,'_')}
$OldName = [string]$OldDG.Name
$OldDisplayName = [string]$OldDG.DisplayName
$OldPrimarySmtpAddress = [string]$OldDG.PrimarySmtpAddress
$OldAlias = [string]$OldDG.Alias
if ((![string]$OldDG.managedby) -or ([string]$OldDG.managedby -eq "Organization Management")) {
[string]$OldDG.managedby=$ManagedByDefault
}
$OldMembers = (Get-DistributionGroupMember $OldDG.PrimarySmtpAddress).primarysmtpaddress "EmailAddress" > "$ExportDirectory\$OldName.csv"
$OldDG.EmailAddresses >> "$ExportDirectory\$OldName.csv"
"x500:"+$OldDG.LegacyExchangeDN >> "$ExportDirectory\$OldName.csv"
Write-Host " Creating Group: Cloud-$OldDisplayName" -ForegroundColor Green
New-DistributionGroup `
-Name "Cloud-$OldName" `
-Alias "Cloud-$OldAlias" `
-DisplayName "Cloud-$OldDisplayName" `
-ManagedBy $OldDG.ManagedBy `
-Members $OldMembers `
-PrimarySmtpAddress "Cloud-$OldPrimarySmtpAddress" | Out-Null
Sleep -Seconds 3
Write-Host " Setting Values For: Cloud-$OldDisplayName" -ForegroundColor Green
Set-DistributionGroup `
-Identity "Cloud-$OldPrimarySmtpAddress" `
-AcceptMessagesOnlyFromSendersOrMembers $OldDG.AcceptMessagesOnlyFromSendersOrMembers `
-RejectMessagesFromSendersOrMembers $OldDG.RejectMessagesFromSendersOrMembers `
Set-DistributionGroup `
-Identity "Cloud-$OldPrimarySmtpAddress" `
-AcceptMessagesOnlyFrom $OldDG.AcceptMessagesOnlyFrom `
-AcceptMessagesOnlyFromDLMembers $OldDG.AcceptMessagesOnlyFromDLMembers `
-BypassModerationFromSendersOrMembers $OldDG.BypassModerationFromSendersOrMembers `
-BypassNestedModerationEnabled $OldDG.BypassNestedModerationEnabled `
-CustomAttribute1 $OldDG.CustomAttribute1 `
-CustomAttribute2 $OldDG.CustomAttribute2 `
-CustomAttribute3 $OldDG.CustomAttribute3 `
-CustomAttribute4 $OldDG.CustomAttribute4 `
-CustomAttribute5 $OldDG.CustomAttribute5 `
-CustomAttribute6 $OldDG.CustomAttribute6 `
-CustomAttribute7 $OldDG.CustomAttribute7 `
-CustomAttribute8 $OldDG.CustomAttribute8 `
-CustomAttribute9 $OldDG.CustomAttribute9 `
-CustomAttribute10 $OldDG.CustomAttribute10 `
-CustomAttribute11 $OldDG.CustomAttribute11 `
-CustomAttribute12 $OldDG.CustomAttribute12 `
-CustomAttribute13 $OldDG.CustomAttribute13 `
-CustomAttribute14 $OldDG.CustomAttribute14 `
-CustomAttribute15 $OldDG.CustomAttribute15 `
-ExtensionCustomAttribute1 $OldDG.ExtensionCustomAttribute1 `
-ExtensionCustomAttribute2 $OldDG.ExtensionCustomAttribute2 `
-ExtensionCustomAttribute3 $OldDG.ExtensionCustomAttribute3 `
-ExtensionCustomAttribute4 $OldDG.ExtensionCustomAttribute4 `
-ExtensionCustomAttribute5 $OldDG.ExtensionCustomAttribute5 `
-GrantSendOnBehalfTo $OldDG.GrantSendOnBehalfTo `
-HiddenFromAddressListsEnabled $True `
-MailTip $OldDG.MailTip `
-MailTipTranslations $OldDG.MailTipTranslations `
-MemberDepartRestriction $OldDG.MemberDepartRestriction `
-MemberJoinRestriction $OldDG.MemberJoinRestriction `
-ModeratedBy $OldDG.ModeratedBy `
-ModerationEnabled $OldDG.ModerationEnabled `
-RejectMessagesFrom $OldDG.RejectMessagesFrom `
-RejectMessagesFromDLMembers $OldDG.RejectMessagesFromDLMembers `
-ReportToManagerEnabled $OldDG.ReportToManagerEnabled `
-ReportToOriginatorEnabled $OldDG.ReportToOriginatorEnabled `
-RequireSenderAuthenticationEnabled $OldDG.RequireSenderAuthenticationEnabled `
-SendModerationNotifications $OldDG.SendModerationNotifications `
-SendOofMessageToOriginatorEnabled $OldDG.SendOofMessageToOriginatorEnabled `
-BypassSecurityGroupManagerCheck
sleep 3
}
Else {
Write-Host " ERROR: The distribution group '$Group' was not found" -ForegroundColor Red
Write-Host
}
}
}
# --------------------------------------------------------------------------
# Delete all the Distribution Groups in Active Directory
# --------------------------------------------------------------------------
Write-Host "All Distribution Lists have been replicated in the Cloud with Cloud_ as a prefix" -ForegroundColor Green
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-host "If you encountered any errors during the creation of the Cloud-Group process you may hit CTRL + C now to kill the process." -ForegroundColor Red -BackgroundColor Black
Write-host "If you kill the process now to fix any issues you should remove the Cloud-Group objects from Azure AD and start fresh." -ForegroundColor Red -BackgroundColor Black
Write-host "WARNING - The Azure AZ Connect Sync Schedule is currently Suspended. You must complete the script or manually restart the Schedule." -ForegroundColor Black -BackgroundColor Red
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Write-host "Press Enter to delete the migrated Distribution Lists from Active Directory" -ForegroundColor Cyan
pause
If (test-path ($WorkingDirectory + "DG_Details_Backup.csv")) {
$Identities = import-csv ($WorkingDirectory + "DG_Details_Backup.csv") | select -expandproperty Identity
foreach ($group in $identities) {
Remove-ADGroup -identity "$group" -confirm:$false
sleep 2
}
}
Write-Host "All Distribution Lists have been removed from Active Directory" -ForegroundColor Green
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
sleep 15
Pause
# --------------------------------------------------------------------------
# Initiate a Delta Sync with Azure AD Connect and set a timer of 5 minutes
# --------------------------------------------------------------------------
Write-Host "Synchronizing Changes with Azure AD Connect. Please allow 5 minutes for process to complete. You will be prompted when to continue." -ForegroundColor Green
Start-AdSyncSyncCycle -PolicyType Delta
Write-Host "PLEASE BE PATIENT - Confirm the Distribution Lists have been removed from Office 365 Azure AD before continuing" -ForegroundColor Green
sleep 300
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
Pause
# --------------------------------------------------------------------------
# Complete the process by renaming the Cloud copies to the original names
# --------------------------------------------------------------------------
Write-Host "Updating the placeholder Distribution Lists to replace the original AD synchronized Distribution Lists" -ForegroundColor Green
If (test-path $ExportDirectory) {
$Identities = import-csv ($WorkingDirectory + "DG_Details_Backup.csv") | select -expandproperty Identity
foreach ($group in $identities) {
$TempDG = Get-DistributionGroup "Cloud-$Group"
$TempPrimarySmtpAddress = $TempDG.PrimarySmtpAddress
[System.IO.Path]::GetInvalidFileNameChars() | ForEach {$Group = $Group.Replace($_,'_')}
$OldAddresses = @(Import-Csv "$ExportDirectory\$Group.csv")
$NewAddresses = $OldAddresses | ForEach {$_.EmailAddress.Replace("X500","x500")}
$NewDGName = $TempDG.Name.Replace("Cloud-","")
$NewDGDisplayName = $TempDG.DisplayName.Replace("Cloud-","")
$NewDGAlias = $TempDG.Alias.Replace("Cloud-","")
$NewPrimarySmtpAddress = ($NewAddresses | Where {$_ -clike "SMTP:*"}).Replace("SMTP:","")
Write-Host "Converting Cloud-$Group to $Group"
Set-DistributionGroup `
-Identity $TempDG.Name `
-Name $NewDGName `
-Alias $NewDGAlias `
-DisplayName $NewDGDisplayName `
-PrimarySmtpAddress $NewPrimarySmtpAddress `
-HiddenFromAddressListsEnabled $False `
-BypassSecurityGroupManagerCheck
Set-DistributionGroup `
-Identity $NewDGName `
-EmailAddresses @{Add=$NewAddresses} `
-BypassSecurityGroupManagerCheck
Set-DistributionGroup `
-Identity $NewDGName `
-EmailAddresses @{Remove=$TempPrimarySmtpAddress} `
-BypassSecurityGroupManagerCheck
sleep 3
}
}
Write-Host "Completed" -ForegroundColor Green
Write-Host "----------------------------------------------------------------------------------------------" -ForegroundColor Cyan
# Re-Enable AD Sync Schedule
Set-ADSyncScheduler -SyncCycleEnabled $true
Write-Host "The conversion process happens in Exchange and can take a while to reflect in Azure AD"
Write-Host "Check to make sure that Azure AD is updated and now showing all of the Distribution Lists are converted to Cloud objects"
Pause




