This is the automation to a post. I covered a couple of years ago, but can be read here.
Obviously that article is very manual and requires you to have quite an in-depth knowledge of Exchange - And by that I do not mean being able to create a mailbox and set options via the GUI - In fact, none of these commands use the GUI at all.
While the automation is there to make your life easier, I do not recommend you run these scripts/commands without understanding the whole process and how it actually works, that would usually be a case of jumping out of the frying pan into that searing fire, so lets get into the plan which will follow these steps:
Note: Step 3 and 4 have this delay as the script as the minimum synchronise internal is 30 minutes per synchronisation
- Check the Mailboxes for invalid attributes
- Create the mailboxes on Exchange On-Premises
- Move the account to an unsynchronised OU and wait 30 minutes
- Move the account back to the original synchronised OU and wait 30 minutes
- Perform the mailbox "magic" on the local account and prepare it to move to EXO
- Export the GUID from EXO
- Map the GUID of the local account to the EXO account
Check Mailboxes for invalid UPN and Mail Addresses
This script will go though all the UPN's listed in the file you need to create in the folder where the script is run from called upns.txt you can see that all these mailboxes have matched which is a good sign, so no output file will be created, that will look like this:
Script : 1-CheckMailbox.ps1if (!(Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
Install-Module -Name ExchangeOnlineManagement -Force -AllowClobber
}
Import-Module ExchangeOnlineManagement
if (!(Test-Path ".\upn.txt")) {
Write-Error "upn.txt not found"
exit
}
Connect-ExchangeOnline
$UPNs = Get-Content -Path "upn.txt"
$mismatchedUPNs = @()
foreach ($UPN in $UPNs) {
try {
$mailbox = Get-Mailbox -Identity $UPN -ErrorAction Stop
Write-Host "`nUPN: $UPN" -ForegroundColor Yellow
Write-Host "Exchange Mail: $($mailbox.PrimarySmtpAddress)"
if ($UPN -eq $mailbox.PrimarySmtpAddress) {
Write-Host "Status: Match ✓" -ForegroundColor Green
} else {
Write-Host "Status: Mismatch ✗" -ForegroundColor Red
$mismatchedUPNs += $UPN
}
}
catch {
Write-Host "`nUPN: $UPN" -ForegroundColor Yellow
Write-Host "Error: $_" -ForegroundColor Red
}
}
if ($mismatchedUPNs.Count -gt 0) {
$mismatchedUPNs | Out-File -FilePath ".\badupn.txt"
Write-Host "`nExported $($mismatchedUPNs.Count) mismatched UPNs to badupn.txt" -ForegroundColor Cyan
}
Disconnect-ExchangeOnline -Confirm:$false
If you get any mismatched UPNs to email addresses then you will get a "badupn.txt"output file as an example this is one of those files as highlighted below:
Create the local mailboxThis next step will create the local mailbox which is required for the later steps in this guide this will create a local mailbox and will need the Exchange management shell from your local Exchange to work correctly, this will read the badupn.txt file so it only applies to mailboxes that are mismatched.
Script : 2.CreateLocalMailbox.ps1
# Import Exchange Management tools
Add-PSSnapin Microsoft.Exchange.Management.PowerShell.SnapIn
if (!(Test-Path "badupn.txt")) {
Write-Error "badupn.txt not found"
exit
}
$UPNs = Get-Content -Path ".\badupn.txt"
foreach ($UPN in $UPNs) {
try {
# Get AD user
$user = Get-ADUser -Filter "UserPrincipalName -eq '$UPN'"
# Create mailbox
Enable-Mailbox -Identity $user.DistinguishedName `
-Database "<database-name>" `
-PrimarySmtpAddress $UPN
Write-Host "Created mailbox for $UPN" -ForegroundColor Green
}
catch {
Write-Host "Failed to create mailbox for $UPN : $_" -ForegroundColor Red
}
}
Perform Account Maintenance on listed accounts
This step of the script will move the accounts to an OU that is not synchronised then wait 30 minutes for Entra connect replication then move them back to their original location which does indeed fix the invalid e-mail address seen in EXO, this will again only read from the badupn.txt files for accounts that need the fix applied.
The 30 minutes timers, in this instance can be overridden with a word but unless you have a reason to override the timers please do not override them for no reason as further steps in this script will fail.
Script : 3-MoveUPNAccount.ps1
# Define variables for OUs
$targetOU = "<target_ou_DN>"
$originalOU = "<original_ou_DN>"
# Import list of UPNs from a file (one UPN per line)
$UPNs = Get-Content -Path "badupn.txt"
# Function to display 30-minute progress bar with override
function Show-ReplicationTimer {
param([string]$ActivityText)
$startTime = Get-Date
$endTime = $startTime.AddMinutes(30)
$totalSeconds = 1800
$overrideBuffer = ""
while ((Get-Date) -lt $endTime) {
if ($host.UI.RawUI.KeyAvailable) {
$key = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyUp")
$overrideBuffer += $key.Character
if ($overrideBuffer.Length -gt 8) {
$overrideBuffer = $overrideBuffer.Substring(1)
}
if ($overrideBuffer -eq "skeletor") {
Write-Host "`nNYAH! Timer override activated!"
return
}
}
$currentTime = Get-Date
$elapsedSeconds = ($currentTime - $startTime).TotalSeconds
$percentComplete = ($elapsedSeconds / $totalSeconds) * 100
Write-Progress -Activity $ActivityText `
-Status "Time Remaining: $([math]::Round(($totalSeconds - $elapsedSeconds) / 60, 1)) minutes" `
-PercentComplete $percentComplete
Start-Sleep -Seconds 1
}
Write-Progress -Activity $ActivityText -Completed
}
# Move accounts to target OU
foreach ($UPN in $UPNs) {
$user = Get-ADUser -Filter "UserPrincipalName -eq '$UPN'"
if ($user) {
Move-ADObject -Identity $user.DistinguishedName -TargetPath $targetOU
Write-Host "Moved $UPN to $targetOU"
}
}
# First replication timer
Show-ReplicationTimer -ActivityText "Waiting for initial AD/AAD Replication"
# Move accounts back to original OU
foreach ($UPN in $UPNs) {
$user = Get-ADUser -Filter "UserPrincipalName -eq '$UPN'"
if ($user) {
Move-ADObject -Identity $user.DistinguishedName -TargetPath $originalOU
Write-Host "Moved $UPN back to $originalOU"
}
}
# Second replication timer
Show-ReplicationTimer -ActivityText "Waiting for final AD/AAD Replication"
This is an example of the waiting timer where as you can see its 30 minutes for each timer and there are two of these:
Mailbox Magic and Attribute CopyThis is where the the script will guide you though all the steps required to complete this step as you can see below, it will tell you what will happen and you need to authorise each step which can be removed, but for this example it is included:
When this step is completed you will have a mailbox that will exist in both Exchange On-Premises and Exchange Online however we need need to match the EXO mailbox GUID with the local Exchange version by obtaining the EXO GUID first and then matching it to the on-prem version.
Export the Mailbox GUID from Exchange OnlineThis will export the Exchange GUID from Exchange online for each of the users in badupn.txt as you can see here when it connects to Exchange Online:
This script will end by exporting a CSV file with the UPN and the MailboxGUID which is required for the final step:
Script : 5-MailboxGUIDExport.ps1# Function to write verbose output
function Write-VerboseOutput {
param (
[string]$Message,
[string]$Type = "Info"
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
switch ($Type) {
"Info" { Write-Host "[$timestamp] INFO: $Message" -ForegroundColor Cyan }
"Warning" { Write-Host "[$timestamp] WARNING: $Message" -ForegroundColor Yellow }
"Error" { Write-Host "[$timestamp] ERROR: $Message" -ForegroundColor Red }
"Success" { Write-Host "[$timestamp] SUCCESS: $Message" -ForegroundColor Green }
}
}
# Function to prompt for confirmation
function Get-UserConfirmation {
param (
[string]$Action,
[string]$DetailedExplanation
)
Write-Host "`n========== PROPOSED ACTION ==========" -ForegroundColor Yellow
Write-Host "Action: $Action" -ForegroundColor Yellow
Write-Host "Details of what will happen:" -ForegroundColor Yellow
Write-Host $DetailedExplanation -ForegroundColor White
Write-Host "====================================`n" -ForegroundColor Yellow
$confirmation = Read-Host "Do you want to proceed with this action? (yes/no)"
return $confirmation -eq "yes"
}
try {
# Check if badupn.txt exists
if (!(Test-Path "badupn.txt")) {
throw "badupn.txt file not found in the current directory."
}
# Create output CSV filename with timestamp
$outputCsv = "ExchangeGUID_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$detailedExplanation = @"
This script will:
1. Connect to Exchange Online using proxy settings
2. Read UPNs from badupn.txt
3. Retrieve the Exchange Online GUID for each mailbox
4. Export results to: $outputCsv
The CSV will contain:
- UPN
- Exchange GUID
- Status (Success/Error)
- Error Message (if any)
"@
if (Get-UserConfirmation "Fetch Exchange Online GUIDs" $detailedExplanation) {
# Configure proxy settings
Write-VerboseOutput "Configuring proxy settings..." "Info"
$proxyOptions = New-PSSessionOption -ProxyAccessType IEConfig -ProxyAuthentication Basic
# Connect to Exchange Online
Write-VerboseOutput "Connecting to Exchange Online..." "Info"
try {
Connect-ExchangeOnline -PSSessionOption $proxyOptions
Write-VerboseOutput "Successfully connected to Exchange Online" "Success"
}
catch {
$errorMessage = $_.Exception.Message
throw "Failed to connect to Exchange Online: $errorMessage"
}
# Read UPNs from file
Write-VerboseOutput "Reading UPNs from badupn.txt" "Info"
$upnList = Get-Content "badupn.txt" | Where-Object { $_ -match '\S' } # Skip empty lines
Write-VerboseOutput "Found $($upnList.Count) UPNs to process" "Info"
# Create array to store results
$results = @()
# Process each UPN
foreach ($upn in $upnList) {
Write-VerboseOutput "Processing UPN: $upn" "Info"
try {
$mailbox = Get-EXOMailbox $upn
$guid = $mailbox.Guid
if ([string]::IsNullOrEmpty($guid)) {
Write-VerboseOutput "No GUID found for $upn" "Warning"
$result = [PSCustomObject]@{
UPN = $upn
ExchangeGUID = ""
Status = "Error"
ErrorMessage = "No GUID found"
}
} else {
Write-VerboseOutput "Successfully retrieved GUID for $upn" "Success"
$result = [PSCustomObject]@{
UPN = $upn
ExchangeGUID = $guid
Status = "Success"
ErrorMessage = ""
}
}
}
catch {
$errorMessage = $_.Exception.Message
Write-VerboseOutput ("Error retrieving GUID for " + $upn + ": " + $errorMessage) "Error"
$result = [PSCustomObject]@{
UPN = $upn
ExchangeGUID = ""
Status = "Error"
ErrorMessage = $errorMessage
}
}
$results += $result
}
# Export results to CSV
Write-VerboseOutput "Exporting results to $outputCsv" "Info"
$results | Export-Csv -Path $outputCsv -NoTypeInformation
Write-VerboseOutput "Results successfully exported to $outputCsv" "Success"
# Display summary
$successCount = ($results | Where-Object { $_.Status -eq "Success" }).Count
$errorCount = ($results | Where-Object { $_.Status -eq "Error" }).Count
Write-VerboseOutput "Processing complete!" "Success"
Write-VerboseOutput "Summary:" "Info"
Write-VerboseOutput "- Total processed: $($results.Count)" "Info"
Write-VerboseOutput "- Successful: $successCount" "Success"
Write-VerboseOutput "- Failed: $errorCount" "Warning"
}
}
catch {
$errorMessage = $_.Exception.Message
Write-VerboseOutput "Critical error occurred: $errorMessage" "Error"
Write-VerboseOutput "Stack Trace: $($_.ScriptStackTrace)" "Error"
}
finally {
if (Get-PSSession | Where-Object {$_.ConfigurationName -eq "Microsoft.Exchange"}) {
Write-VerboseOutput "Disconnecting from Exchange Online..." "Info"
Disconnect-ExchangeOnline -Confirm:$false
Write-VerboseOutput "Successfully disconnected from Exchange Online" "Success"
}
}
Right on the final straight and narrow now, with that CSV we can complete the last step to complete the process.
Update Local Exchange Remote Mailbox with correct GUID
We now need to finally update the remote mailbox attributes on your local exchange to then match the local exchange version with the one in EXO, this script will automatically look for the latest CSV file and then use the data in that to match the UPN column and the GUID column to issue the commands required.
Script : 6-MailboxGUIDUpdate.ps1# Function to write verbose output
function Write-VerboseOutput {
param (
[string]$Message,
[string]$Type = "Info"
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
switch ($Type) {
"Info" { Write-Host "[$timestamp] INFO: $Message" -ForegroundColor Cyan }
"Warning" { Write-Host "[$timestamp] WARNING: $Message" -ForegroundColor Yellow }
"Error" { Write-Host "[$timestamp] ERROR: $Message" -ForegroundColor Red }
"Success" { Write-Host "[$timestamp] SUCCESS: $Message" -ForegroundColor Green }
"Alert" {
# Flash between red and white background for important alerts
$originalBackground = $host.UI.RawUI.BackgroundColor
for ($i = 1; $i -le 3; $i++) {
$host.UI.RawUI.BackgroundColor = "Red"
Write-Host "[$timestamp] ALERT: $Message" -ForegroundColor White
Start-Sleep -Milliseconds 500
$host.UI.RawUI.BackgroundColor = $originalBackground
Write-Host "[$timestamp] ALERT: $Message" -ForegroundColor Red
Start-Sleep -Milliseconds 500
}
$host.UI.RawUI.BackgroundColor = $originalBackground
}
}
}
# Function to prompt for confirmation
function Get-UserConfirmation {
param (
[string]$upn,
[string]$guid,
[string]$command
)
Write-Host "`n========== CONFIRM COMMAND EXECUTION ==========" -ForegroundColor Yellow
Write-Host "User UPN: $upn" -ForegroundColor Cyan
Write-Host "Exchange GUID: $guid" -ForegroundColor Cyan
Write-Host "`nCommand to be executed:" -ForegroundColor Yellow
Write-Host $command -ForegroundColor Green
Write-Host "============================================`n" -ForegroundColor Yellow
$confirmation = Read-Host "Do you want to execute this command? (yes/no)"
return $confirmation -eq "yes"
}
# Main script
try {
# Display warning about running on local Exchange
Write-VerboseOutput "THIS SCRIPT MUST BE RUN ON LOCAL EXCHANGE SERVER!" "Alert"
Write-VerboseOutput "Running this script on Exchange Online will cause errors!" "Alert"
# Prompt to continue
$continue = Read-Host "`nAre you running this on Local Exchange Server? (yes/no)"
if ($continue -ne "yes") {
throw "Script terminated - must be run on Local Exchange Server"
}
# Find the most recent CSV file matching the pattern
$csvFiles = Get-Item "ExchangeGUID_*.csv" | Sort-Object LastWriteTime -Descending
if (-not $csvFiles) {
throw "No Exchange GUID CSV files found in the current directory."
}
$mostRecentCsv = $csvFiles[0]
# Display CSV details and get confirmation
Write-VerboseOutput "`nFound most recent CSV file:" "Info"
Write-VerboseOutput "Filename: $($mostRecentCsv.Name)" "Info"
Write-VerboseOutput "Last Modified: $($mostRecentCsv.LastWriteTime)" "Info"
Write-VerboseOutput "File Size: $([math]::Round($mostRecentCsv.Length/1KB, 2)) KB" "Info"
$confirmFile = Read-Host "`nDo you want to use this file? (yes/no)"
if ($confirmFile -ne "yes") {
# If user doesn't want to use the most recent file, show all available files
Write-VerboseOutput "`nAvailable CSV files:" "Info"
$csvFiles | ForEach-Object {
Write-Host ("$($_.LastWriteTime) - $($_.Name)") -ForegroundColor Cyan
}
$fileName = Read-Host "`nPlease enter the filename you want to use (or press Enter to exit)"
if ([string]::IsNullOrEmpty($fileName)) {
throw "Script terminated by user"
}
$selectedFile = Get-Item $fileName -ErrorAction SilentlyContinue
if (-not $selectedFile) {
throw "Selected file not found"
}
$mostRecentCsv = $selectedFile
}
Write-VerboseOutput "Loading CSV file: $($mostRecentCsv.Name)" "Info"
# Read CSV content
$users = Import-Csv $mostRecentCsv
$userCount = ($users | Measure-Object).Count
Write-VerboseOutput "Found $userCount users to process" "Info"
# Display sample of users to be processed
Write-VerboseOutput "`nFirst few users to be processed:" "Info"
$users | Select-Object -First 3 | ForEach-Object {
Write-Host "UPN: $($_.UPN), GUID: $($_.ExchangeGUID)" -ForegroundColor Cyan
}
$confirmProcess = Read-Host "`nDo you want to proceed with processing these users? (yes/no)"
if ($confirmProcess -ne "yes") {
throw "Script terminated by user"
}
# Create output log file
$logFile = "GUIDSet_Results_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$results = @()
foreach ($user in $users) {
Write-VerboseOutput "Processing user: $($user.UPN)" "Info"
# Skip if no GUID or error in CSV
if ([string]::IsNullOrEmpty($user.ExchangeGUID) -or $user.Status -ne "Success") {
Write-VerboseOutput "Skipping user $($user.UPN) - No valid GUID found" "Warning"
$results += [PSCustomObject]@{
UPN = $user.UPN
Status = "Skipped"
ErrorMessage = "No valid GUID in CSV"
}
continue
}
# Construct the command
$command = "Set-RemoteMailbox `"$($user.UPN)`" -ExchangeGuid `"$($user.ExchangeGUID)`""
# Get confirmation
if (Get-UserConfirmation -upn $user.UPN -guid $user.ExchangeGUID -command $command) {
try {
# Execute the command
Invoke-Expression $command
Write-VerboseOutput "Successfully set Exchange GUID for $($user.UPN)" "Success"
$results += [PSCustomObject]@{
UPN = $user.UPN
Status = "Success"
ErrorMessage = ""
}
}
catch {
$errorMessage = $_.Exception.Message
Write-VerboseOutput "Error setting Exchange GUID for $($user.UPN): $errorMessage" "Error"
$results += [PSCustomObject]@{
UPN = $user.UPN
Status = "Error"
ErrorMessage = $errorMessage
}
}
}
else {
Write-VerboseOutput "Skipped setting Exchange GUID for $($user.UPN) - User declined" "Warning"
$results += [PSCustomObject]@{
UPN = $user.UPN
Status = "Skipped"
ErrorMessage = "User declined execution"
}
}
}
# Export results
$results | Export-Csv -Path $logFile -NoTypeInformation
Write-VerboseOutput "Results exported to $logFile" "Success"
# Display summary
$successCount = ($results | Where-Object { $_.Status -eq "Success" }).Count
$errorCount = ($results | Where-Object { $_.Status -eq "Error" }).Count
$skippedCount = ($results | Where-Object { $_.Status -eq "Skipped" }).Count
Write-VerboseOutput "`nProcessing complete!" "Success"
Write-VerboseOutput "Summary:" "Info"
Write-VerboseOutput "- Total processed: $($results.Count)" "Info"
Write-VerboseOutput "- Successful: $successCount" "Success"
Write-VerboseOutput "- Failed: $errorCount" "Error"
Write-VerboseOutput "- Skipped: $skippedCount" "Warning"
}
catch {
$errorMessage = $_.Exception.Message
Write-VerboseOutput "Critical error occurred: $errorMessage" "Error"
Write-VerboseOutput "Stack Trace: $($_.ScriptStackTrace)" "Error"
}
You will first get the warnings that this needs to be run on local exchange, if not the script will fail like this:
When this is run on local exchange it will look like this:
xxxxx