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)...
- any mailboxes associated with disabled user accounts
- 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
}
}