Viewing nested groups in Exchange Online

5/5 - (4 votes)

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)"

 

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.