Powershell : Automating and recovering an exchange online account that exists in EXO and Exchange On-Prem

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

  1. Check the Mailboxes for invalid attributes
  2. Create the mailboxes on Exchange On-Premises
  3. Move the account to an unsynchronised OU and wait 30 minutes
  4. Move the account back to the original synchronised OU and wait 30 minutes
  5. Perform the mailbox "magic" on the local account and prepare it to move to EXO
  6. Export the GUID from EXO
  7. 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.ps1

if (!(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 mailbox

This 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 Copy

This 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 Online

This 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

Previous Post Next Post

نموذج الاتصال