Sometimes, you need to see the group within the group. And, a lot of times, it’s nice to see it laid out in a handy tree-type view.
Not a whole lot to say about this, except that it provides a new way to look at Exchange Online groups from a nested perspective.
<#
.SYNOPSIS
Recursively maps Distribution Group nesting and reports alias, email
address, and direct/indirect member counts for each top-level group.
.DESCRIPTION
For every distribution group in the tenant, this script:
- Prints an indented tree of nested distribution groups (like the
original script), now annotated with each nested group's alias
and primary SMTP address.
- Guards against infinite loops caused by circular nesting
(Group A -> Group B -> Group A).
- Builds a summary per top-level group showing:
Alias, PrimarySmtpAddress, DirectMemberCount,
IndirectMemberCount, TotalUniqueMemberCount
"Direct" members are whatever Get-DistributionGroupMember returns for
the top-level group itself (users, contacts, and any nested groups).
"Indirect" members are the unique members pulled in by expanding those
nested groups (recursively), not already counted as direct.
.NOTES
Requires a connected Exchange Online PowerShell / Exchange Management
Shell session (e.g. Connect-ExchangeOnline) with rights to read
distribution group membership.
#>
Write-Host "Start: $(Get-Date)"
# Accumulates the summary for every top-level group processed.
$script:GroupSummaries = New-Object System.Collections.Generic.List[Object]
function Get-GroupMembers {
param(
[Parameter(Mandatory)]
[string]$GroupName,
[int]$Iteration = 0,
# Guards against circular nesting within THIS top-level group's tree.
[System.Collections.Generic.HashSet[string]]$VisitedGroups = $null,
# Accumulates unique member identities across the whole recursion,
# used to compute indirect member counts at the root.
[System.Collections.Generic.HashSet[string]]$CollectedMembers = $null
)
$group = Get-DistributionGroup $GroupName -ErrorAction SilentlyContinue
if (-not $group) {
Write-Warning "Could not find group '$GroupName'"
return
}
$isRoot = ($null -eq $VisitedGroups)
if ($isRoot) {
$VisitedGroups = New-Object System.Collections.Generic.HashSet[string]
$CollectedMembers = New-Object System.Collections.Generic.HashSet[string]
}
$groupKey = $group.Identity.ToString()
if ($VisitedGroups.Contains($groupKey)) {
for ($i = $Iteration; $i -gt 0; $i--) { Write-Host " " -NoNewline }
Write-Host "+--- (already expanded above — circular nesting, skipping)" -ForegroundColor DarkGray
return
}
[void]$VisitedGroups.Add($groupKey)
if ($Iteration -eq 0) {
Write-Host ""
Write-Host "$($group.DisplayName) [alias: $($group.Alias)] <$($group.PrimarySmtpAddress)>" -ForegroundColor Yellow
}
$allMembers = Get-DistributionGroupMember $group.Identity -ResultSize Unlimited
$nestedGroups = $allMembers | Where-Object { $_.RecipientType -like "*Group*" }
foreach ($member in $nestedGroups) {
$nextIteration = $Iteration + 1
$nestedDetail = Get-DistributionGroup $member.Name -ErrorAction SilentlyContinue
$aliasText = if ($nestedDetail) { $nestedDetail.Alias } else { "n/a" }
$emailText = if ($nestedDetail) { $nestedDetail.PrimarySmtpAddress } else { "n/a" }
for ($i = $nextIteration; $i -gt 0; $i--) { Write-Host " " -NoNewline }
Write-Host "|"
for ($i = $nextIteration; $i -gt 0; $i--) { Write-Host " " -NoNewline }
Write-Host "+--- $($member.Name) [alias: $aliasText] <$emailText>"
Get-GroupMembers -GroupName $member.Name -Iteration $nextIteration `
-VisitedGroups $VisitedGroups -CollectedMembers $CollectedMembers
}
# Fold this group's members into the running unique-member set.
foreach ($m in $allMembers) {
[void]$CollectedMembers.Add($m.Identity.ToString())
}
if ($isRoot) {
$directCount = $allMembers.Count
$indirectCount = [Math]::Max(0, $CollectedMembers.Count - $directCount)
$summary = [PSCustomObject]@{
DisplayName = $group.DisplayName
Alias = $group.Alias
PrimarySmtpAddress = $group.PrimarySmtpAddress
DirectMemberCount = $directCount
IndirectMemberCount = $indirectCount
TotalUniqueMembers = $CollectedMembers.Count
}
$script:GroupSummaries.Add($summary)
Write-Host ""
Write-Host ("Direct: {0} Indirect (via nesting): {1} Total unique: {2}" -f `
$directCount, $indirectCount, $CollectedMembers.Count) -ForegroundColor Cyan
}
}
# ---- Main ----
$groups = Get-DistributionGroup -ResultSize Unlimited
foreach ($g in $groups) {
Get-GroupMembers -GroupName $g.Name -Iteration 0
}
Write-Host ""
Write-Host "=== Summary ===" -ForegroundColor Green
$script:GroupSummaries |
Format-Table DisplayName, Alias, PrimarySmtpAddress, DirectMemberCount, IndirectMemberCount, TotalUniqueMembers -AutoSize
# Optional: export the summary to CSV as well
# $script:GroupSummaries | Export-Csv -Path ".\DistributionGroupSummary.csv" -NoTypeInformation
Write-Host "End: $(Get-Date)"

