Private/Expand-METGroupMembership.ps1

function Expand-METGroupMembership {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]    $Identity,
        [Parameter(Mandatory)] [hashtable] $Cache,
        # Shared visited set passed through recursive EXO calls to prevent infinite loops
        # on circular group membership. Callers should omit this - it is initialised
        # automatically on the first call and threaded through recursion internally.
        [System.Collections.Generic.HashSet[string]] $Visited = $null,
        [System.Collections.Generic.List[string]] $RetrievalErrors
    )

    if ($Cache.ContainsKey($Identity)) { return $Cache[$Identity] }

    if ($null -eq $Visited) {
        $Visited = [System.Collections.Generic.HashSet[string]]::new(
            [System.StringComparer]::OrdinalIgnoreCase)
    }
    # Cycle guard - if this identity is already being expanded in the current call
    # stack, return empty to break the loop.
    if (-not $Visited.Add($Identity)) { return @() }

    $addresses = [System.Collections.Generic.HashSet[string]]::new(
        [System.StringComparer]::OrdinalIgnoreCase)
    $graphSucceeded = $false
    $graphError = $null

    # Try Graph first - handles M365 Unified Groups, Azure AD Security Groups,
    # Distribution Lists, and nested memberships via transitive expansion.
    try {
        $escaped = $Identity -replace "'", "''"
        $mgGroup = Get-MgGroup -Filter "mail eq '$escaped'" -Top 1 -ErrorAction Stop |
            Select-Object -First 1

        if (-not $mgGroup) {
            $mgGroup = Get-MgGroup -Filter "displayName eq '$escaped'" -Top 1 -ErrorAction Stop |
                Select-Object -First 1
        }

        if ($mgGroup) {
            $members = Get-MgGroupTransitiveMember -GroupId $mgGroup.Id -All -ErrorAction Stop
            foreach ($m in $members) {
                $mail = $m.AdditionalProperties['mail']
                if ($mail) {
                    $null = $addresses.Add($mail)
                    continue
                }
                $upn = $m.AdditionalProperties['userPrincipalName']
                if ($upn -and $upn -like '*@*') { $null = $addresses.Add($upn) }
            }
            $graphSucceeded = $true
        }
    }
    catch {
        $graphError = $_.ToString()
        Write-Verbose "Graph group expansion failed for '$Identity': $_"
    }

    # Fallback: Exchange DL expansion with recursive nested-group handling.
    # Get-MgGroupTransitiveMember already handles nesting; this fallback covers
    # mail-enabled security groups and DLs when Graph is unavailable or the group
    # was not found via the Graph filter. The shared $Visited set prevents cycles
    # when groups nest circularly.
    if (-not $graphSucceeded) {
        $exoError = $null
        try {
            $members = Get-DistributionGroupMember -Identity $Identity -ResultSize Unlimited -ErrorAction Stop
            foreach ($m in $members) {
                $rtype = [string]$m.RecipientType
                if ($rtype -match 'Group') {
                    $nestedId = if ($m.PrimarySmtpAddress) { $m.PrimarySmtpAddress } else { $m.Identity }
                    $nested   = @(Expand-METGroupMembership -Identity $nestedId -Cache $Cache -Visited $Visited -RetrievalErrors $RetrievalErrors)
                    foreach ($n in $nested) { $null = $addresses.Add($n) }
                } elseif ($m.PrimarySmtpAddress) {
                    $null = $addresses.Add($m.PrimarySmtpAddress)
                }
            }
        }
        catch {
            $exoError = $_.ToString()
            Write-Verbose "Exchange distribution group expansion failed for '$Identity': $_"

            # Get-DistributionGroupMember only resolves distribution groups and
            # mail-enabled security groups - Microsoft 365 Groups need the separate
            # Get-UnifiedGroupLinks cmdlet, per Microsoft's own documented example.
            try {
                $links = Get-UnifiedGroupLinks -Identity $Identity -LinkType Members -ResultSize Unlimited -ErrorAction Stop
                foreach ($link in $links) {
                    if ($link.PrimarySmtpAddress) { $null = $addresses.Add($link.PrimarySmtpAddress) }
                }
            }
            catch {
                Write-Verbose "Microsoft 365 Group expansion failed for '$Identity': $_"
                if ($null -ne $RetrievalErrors) {
                    $details = if ($graphError) { "Graph: $graphError; Exchange: $exoError; Microsoft 365 Group: $($_.ToString())" } else { "Exchange: $exoError; Microsoft 365 Group: $($_.ToString())" }
                    $RetrievalErrors.Add("Unable to expand group '$Identity'. $details")
                }
                return @()
            }
        }
    }

    $result = [string[]]$addresses
    $Cache[$Identity] = $result
    return $result
}