Distribution Group Maintenance/Update Script


I found myself with a need to get (and keep) numerous Exchange Online distribution groups cleaned out and updated. Old (disabled) employees, new employees, etc., etc. and these lists were sometimes very out of date. So, i put this script together that will will keep a list of Exchange Online Distribution Groups cleaned out and updated. The idea was that I need a script that would run on a schedule (say, daily) and do the following (approximately in order)...

  1. any mailboxes associated with disabled user accounts
  2. Add any enabled user account mailboxes associated with certain locations (State) or Department, excluding any users that were sync'd from specific on-prem Active Directory oraganizatonal units (OU's).

Powershell

[CmdletBinding(SupportsShouldProcess = $true)]
param()

# PARAMETER CONFIGURATION

$GroupIdentities       = @("IT@somedomain.com","help@somedomain.com") # The distribution group(s) to clean/modify
$StateId               = "NY"                                         # Users with this State ID will be candidates for the distribution group
$TargetDomain          = "somedomain.com"                             # Domain name must exist in the candidates email address
$ExcludeOnPremStrings  = @("Executive","Marketing")                   # The presence of these strings in the on-prem common name will exclude candidates
$UserDepartment        = "IT"                                         # Only users in this department will added to the distribution group. Leave empty to ignore department checking.

Import-Module ExchangeOnlineManagement -ErrorAction Stop
Import-Module Microsoft.Graph -ErrorAction Stop

# MAKE GRAPH AND EOL CONNECTIONS

# App registration details needed to connect to mgGraph
$appId    = Get-Secret -Name MyAppID -AsPlainText
$tenantId = Get-Secret -Name MyTenantID -AsPlainText
$clientSecretPlain = Get-Secret -Name MyClientSecret -AsPlainText
$clientSecretSecure = ConvertTo-SecureString $clientSecretPlain -AsPlainText -Force
$clientSecretCred = [System.Management.Automation.PSCredential]::new($appId, $clientSecretSecure)

# Connect to Graph using app-only auth (uses app permissions assigned to the app)
Connect-MgGraph -TenantId $tenantId -ClientSecretCredential $clientSecretCred | Out-Null

# Connect to Exchange Online using credentials stored in your KeyVault
$azureCredential = Get-Secret -Name "AzureCredential"
Connect-ExchangeOnline -Credential $azureCredential -ShowBanner:$false

# Where to drop logs (per distribution group)
$LogRoot = Join-Path $PSScriptRoot "logs"   # ./logs next to the script

if (-not (Test-Path $LogRoot)) {
    New-Item -Path $LogRoot -ItemType Directory | Out-Null
}

foreach ($GroupIdentity in $GroupIdentities) {
    # Log file name per distribution group
    $safeGroupName = ($GroupIdentity -replace '[^\w\-]', '_')
    $logFile       = Join-Path $LogRoot "DG-$safeGroupName.log"

    Start-Transcript -Path $logFile -Force | Out-Null

    try {

        Write-Host "Processing distribution group '$GroupIdentity'..." -ForegroundColor Cyan
        Write-Host "  State filter:     '$StateId'" -ForegroundColor Cyan
        Write-Host "  Email domain:     '$TargetDomain'" -ForegroundColor Cyan
        Write-Host "  Exclude on-prem:  $($ExcludeOnPremStrings -join ', ')" -ForegroundColor Cyan
        Write-Host ""

        # GET DISTRIBUTION GROUP

        try {
            $group = Get-DistributionGroup -Identity $GroupIdentity -ErrorAction Stop
        }
        catch {
            Write-Error "Could not find distribution group '$GroupIdentity'. $($_.Exception.Message)"
            return
        }

        Write-Host "Found group: $($group.DisplayName) <$($group.PrimarySmtpAddress)>" -ForegroundColor Green
        Write-Host ""

        # LOAD ALL GRAPH USERS ONCE

        Write-Host "Loading Entra ID (Graph) users..." -ForegroundColor Cyan

        $allUsers = Get-MgUser -All `
            -Property "id,displayName,mail,accountEnabled,state,employeeId,onPremisesDistinguishedName,department"

        # Build quick lookup by Graph Id
        $graphUsersById = @{}
        foreach ($u in $allUsers) {
            if ($u.Id) { $graphUsersById[$u.Id] = $u }
        }

        Write-Host "Total Entra users loaded: $($allUsers.Count)" -ForegroundColor Cyan
        Write-Host ""

        # STEP 1: REMOVE DISABLED DISTRIBUTION GROUP MEMBERS

        Write-Host "Step 1: Removing disabled accounts from '$($group.DisplayName)'..." -ForegroundColor Cyan

        # Any DG member that is backed by an Entra user (has ExternalDirectoryObjectId)
        $dgMembers = Get-DistributionGroupMember -Identity $group.Identity -ResultSize Unlimited |
                    Where-Object { $_.ExternalDirectoryObjectId }

        Write-Host "Members in DG with ExternalDirectoryObjectId: $($dgMembers.Count)" -ForegroundColor Cyan

        foreach ($member in $dgMembers) {
            $graphId = $member.ExternalDirectoryObjectId

            if (-not $graphId -or -not $graphUsersById.ContainsKey($graphId)) {
                Write-Verbose "No matching Graph user for DG member '$($member.Identity)'. Skipping."
                continue
            }

            $mgUser = $graphUsersById[$graphId]

            if (-not $mgUser.AccountEnabled) {
                $target = "$($mgUser.DisplayName) <$($mgUser.Mail)>"

                # Pick a unique identifier for the -Member parameter
                $memberIdForRemoval = if ($member.PrimarySmtpAddress) {
                    $member.PrimarySmtpAddress
                }
                elseif ($member.DistinguishedName) {
                    $member.DistinguishedName
                }
                elseif ($member.Guid) {
                    $member.Guid
                }
                else {
                    # Fallback – may still be ambiguous, but better than nothing
                    $member.Identity
                }

                $action = "Remove disabled member '$memberIdForRemoval' from DG '$($group.DisplayName)'"

                if ($PSCmdlet.ShouldProcess($target, $action)) {
                    try {
                        Remove-DistributionGroupMember -Identity $group.Identity `
                                                    -Member $memberIdForRemoval `
                                                    -Confirm:$false `
                                                    -ErrorAction Stop
                        Write-Host "  -> Removed disabled member: ${target} (MemberId: $memberIdForRemoval)" -ForegroundColor Green
                    }
                    catch {
                        Write-Warning "  -> Failed to remove disabled member ${target} (MemberId: $memberIdForRemoval): $($_.Exception.Message)"
                    }
                }
                else {
                    Write-Host "  -> [WhatIf] Would remove disabled member: ${target} (MemberId: $memberIdForRemoval)" -ForegroundColor Yellow
                }
            }
            else {
                Write-Host "Keeping enabled member: $($mgUser.DisplayName) <$($mgUser.Mail)>" -ForegroundColor DarkGray
            }
        }

        Write-Host ""

        # Refresh DG membership after removals
        $dgMembers = Get-DistributionGroupMember -Identity $group.Identity -ResultSize Unlimited |
                    Where-Object { $_.ExternalDirectoryObjectId }

        # Build lookup of current member emails (lowercase) so we don't add duplicates
        $currentMemberEmail = @{}
        foreach ($m in $dgMembers) {
            if ($m.PrimarySmtpAddress) {
                $email = $m.PrimarySmtpAddress.ToString().ToLower()
                $currentMemberEmail[$email] = $true
            }
        }

        Write-Host "DG membership after disabled cleanup: $($dgMembers.Count) user-type members." -ForegroundColor Cyan
        Write-Host ""

        # STEP 2: BUILD DG MEMBER CANDIDATES LIST

        Write-Host "Step 2: Building candidate list using dynamic-group logic..." -ForegroundColor Cyan

        $candidates = $allUsers | Where-Object {
            # Basic filters
            $hasEmployeeId = -not [string]::IsNullOrWhiteSpace($_.EmployeeId)
            $stateOk       = $_.State -eq $StateId
            $enabled       = $_.AccountEnabled -eq $true
            $mailOk        = $_.Mail -and ($_.Mail -like "*$TargetDomain*")

            # Optional department filter
            if ([string]::IsNullOrWhiteSpace($UserDepartment)) {
                # No department configured for this script → don't filter on it
                $departmentOk = $true
            }
            else {
                # Only include users whose Department matches the configured value
                $departmentOk = ($_.Department -eq $UserDepartment)
            }

            # DN / OU exclusion logic (uses $ExcludeOnPremStrings)
            $dn = $_.OnPremisesDistinguishedName
            if ([string]::IsNullOrWhiteSpace($dn)) {
                # No DN → treat as OK
                $dnOk = $true
            }
            else {
                $dnOk = $true
                foreach ($ex in $ExcludeOnPremStrings) {
                    if ($dn -like "*$ex*") {
                        $dnOk = $false
                        break
                    }
                }
            }

            # Final decision for this user
            $hasEmployeeId -and $stateOk -and $enabled -and $mailOk -and $departmentOk -and $dnOk
        }

        Write-Host "Candidates matching logic: $($candidates.Count)" -ForegroundColor Cyan
        Write-Host ""

        # STEP 3: ADD MISSING CANDIDATES TO THE DISTRIBUTION GROUP

        Write-Host "Step 3: Adding missing candidates to '$($group.DisplayName)'..." -ForegroundColor Cyan

        foreach ($user in $candidates) {
            if (-not $user.Mail) {
                continue
            }

            $email = $user.Mail.ToLower()

            if ($currentMemberEmail.ContainsKey($email)) {
                Write-Host "Already in DG: $($user.DisplayName) <$($user.Mail)>" -ForegroundColor DarkGray
                continue
            }

            $target = "$($user.DisplayName) <$($user.Mail)>"
            $action = "Add member to DG '$($group.DisplayName)'"

            if ($PSCmdlet.ShouldProcess($target, $action)) {
                try {
                    # Use the mail address to resolve the member in EXO
                    Add-DistributionGroupMember -Identity $group.Identity `
                                                -Member $user.Mail `
                                                -ErrorAction Stop
                    Write-Host "  -> Added to DG: ${target}" -ForegroundColor Green
                }
                catch {
                    Write-Warning "  -> Failed to add ${target}: $($_.Exception.Message)"
                }
            }
            else {
                Write-Host "  -> [WhatIf] Would add to DG: ${target}" -ForegroundColor Yellow
            }
        }

        # Refresh DG membership after additions
        $dgMembers = Get-DistributionGroupMember -Identity $group.Identity -ResultSize Unlimited |
                    Where-Object { $_.ExternalDirectoryObjectId }

        Write-Host ""
        Write-Host "Members in DG with ExternalDirectoryObjectId: $($dgMembers.Count)" -ForegroundColor Cyan
        Write-Host "Completed processing for group '$($group.DisplayName)'." -ForegroundColor Cyan
    }
    finally {
        Stop-Transcript | Out-Null
    }
}