Src/Private/Get-AbrExoDistributionGroups.ps1

function Get-AbrExoDistributionGroups {
    <#
    .SYNOPSIS
    Documents Exchange Online distribution groups, mail-enabled security groups,
    dynamic distribution groups, and Microsoft 365 Groups (mail-enabled).
    .NOTES
        Version: 0.1.1
        Author: Pai Wei Sing
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [string]$TenantId
    )

    begin {
        Write-PScriboMessage -Message "Collecting Exchange Online Distribution Group information for $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'DistributionGroups'
    }

    process {
        Section -Style Heading2 'Group Inventory' {
            Paragraph "The following section documents distribution groups, mail-enabled security groups, dynamic distribution groups, and Microsoft 365 Groups for tenant $TenantId."
            BlankLine

            # Collect each group type separately using the correct cmdlet per type
            $DistGroups    = [System.Collections.ArrayList]::new()
            $MailSecGroups = [System.Collections.ArrayList]::new()
            $DynGroups     = [System.Collections.ArrayList]::new()
            $M365Groups    = [System.Collections.ArrayList]::new()
            $OtherGroups   = [System.Collections.ArrayList]::new()

            #region Fetch from Get-DistributionGroup (DLs + Mail-Sec groups)
            try {
                Write-Host " - Retrieving distribution and mail-enabled security groups..."
                $RawGroups = Get-DistributionGroup -ResultSize Unlimited -ErrorAction Stop

                foreach ($Grp in $RawGroups) {
                    switch ($Grp.RecipientTypeDetails) {
                        'MailUniversalDistributionGroup' { $null = $DistGroups.Add($Grp) }
                        'MailUniversalSecurityGroup'     { $null = $MailSecGroups.Add($Grp) }
                        'RoomList'                       { $null = $DistGroups.Add($Grp) }   # Room lists are a type of DL
                        default                          { $null = $OtherGroups.Add($Grp) }
                    }
                }
            } catch {
                Write-ExoError 'DistributionGroups' "Unable to retrieve distribution groups: $($_.Exception.Message)"
            }
            #endregion

            #region Fetch Dynamic Distribution Groups
            try {
                Write-Host " - Retrieving dynamic distribution groups..."
                $DynRaw = Get-DynamicDistributionGroup -ResultSize Unlimited -ErrorAction Stop
                foreach ($Grp in $DynRaw) { $null = $DynGroups.Add($Grp) }
            } catch {
                Write-AbrDebugLog "Dynamic distribution groups unavailable: $($_.Exception.Message)" 'DEBUG' 'DistributionGroups'
            }
            #endregion

            #region Fetch Microsoft 365 Groups (Unified Groups)
            try {
                Write-Host " - Retrieving Microsoft 365 Groups..."
                $M365Raw = Get-UnifiedGroup -ResultSize Unlimited -ErrorAction Stop
                foreach ($Grp in $M365Raw) { $null = $M365Groups.Add($Grp) }
            } catch {
                Write-AbrDebugLog "Unified Groups (M365 Groups) unavailable: $($_.Exception.Message)" 'DEBUG' 'DistributionGroups'
            }
            #endregion

            $GrandTotal = $DistGroups.Count + $MailSecGroups.Count + $DynGroups.Count + $M365Groups.Count + $OtherGroups.Count

            # Summary table
            $SumObj = [System.Collections.ArrayList]::new()
            $sumInObj = [ordered] @{
                'Distribution Groups (incl. Room Lists)' = $DistGroups.Count
                'Mail-Enabled Security Groups'           = $MailSecGroups.Count
                'Dynamic Distribution Groups'            = $DynGroups.Count
                'Microsoft 365 Groups'                   = $M365Groups.Count
                'Other'                                  = $OtherGroups.Count
                'Total'                                  = $GrandTotal
            }
            $SumObj.Add([pscustomobject]$sumInObj) | Out-Null
            $SumTableParams = @{ Name = "Group Summary - $TenantId"; List = $true; ColumnWidths = 55, 45 }
            if ($Report.ShowTableCaptions) { $SumTableParams['Caption'] = "- $($SumTableParams.Name)" }
            $SumObj | Table @SumTableParams

            #region Distribution Groups Detail
            if ($DistGroups.Count -gt 0) {
                Section -Style Heading3 'Distribution Groups' {
                    Paragraph "The following $($DistGroups.Count) distribution group(s) are configured in tenant $TenantId."
                    BlankLine

                    $DgObj = [System.Collections.ArrayList]::new()
                    foreach ($Grp in ($DistGroups | Sort-Object DisplayName)) {
                        $MemberCount = 'N/A (IL1)'
                        if ($InfoLevel.DistributionGroups -ge 2) {
                            try { $MemberCount = @(Get-DistributionGroupMember -Identity $Grp.Identity -ResultSize Unlimited -ErrorAction SilentlyContinue).Count } catch {}
                        }
                        $dgInObj = [ordered] @{
                            'Display Name'        = $Grp.DisplayName
                            'Primary SMTP'        = $Grp.PrimarySmtpAddress
                            'Type'                = $Grp.RecipientTypeDetails
                            'Managed By'          = if ($Grp.ManagedBy) { ($Grp.ManagedBy | Select-Object -First 2) -join ', ' } else { 'Not Set' }
                            'Member Count'        = $MemberCount
                            'Accept From'         = if ($Grp.AcceptMessagesOnlyFromSendersOrMembers) { 'Restricted' } else { 'Anyone' }
                            'Require Sender Auth' = $Grp.RequireSenderAuthenticationEnabled
                            'Moderated'           = $Grp.ModerationEnabled
                            'Hidden from GAL'     = $Grp.HiddenFromAddressListsEnabled
                        }
                        $DgObj.Add([pscustomobject](ConvertTo-HashToYN $dgInObj)) | Out-Null
                    }

                    $null = (& {
                        if ($HealthCheck.ExchangeOnline.Mailboxes) {
                            $null = ($DgObj | Where-Object {
                                $_.'Require Sender Auth' -eq 'No' -and $_.'Accept From' -eq 'Anyone' -and $_.Moderated -eq 'No'
                            } | Set-Style -Style Warning | Out-Null)
                        }
                    })

                    $DgTableParams = @{ Name = "Distribution Groups - $TenantId"; List = $false; ColumnWidths = 16, 20, 14, 14, 8, 9, 8, 6, 5 }
                    if ($Report.ShowTableCaptions) { $DgTableParams['Caption'] = "- $($DgTableParams.Name)" }
                    if ($DgObj.Count -gt 0) { $DgObj | Table @DgTableParams }
                    $script:ExcelSheets['Distribution Groups'] = $DgObj
                }
            }
            #endregion

            #region Mail-Enabled Security Groups
            if ($MailSecGroups.Count -gt 0) {
                Section -Style Heading3 'Mail-Enabled Security Groups' {
                    Paragraph "The following $($MailSecGroups.Count) mail-enabled security group(s) are configured in tenant $TenantId."
                    BlankLine

                    $MsgObj = [System.Collections.ArrayList]::new()
                    foreach ($Grp in ($MailSecGroups | Sort-Object DisplayName)) {
                        $msgInObj = [ordered] @{
                            'Display Name'        = $Grp.DisplayName
                            'Primary SMTP'        = $Grp.PrimarySmtpAddress
                            'Managed By'          = if ($Grp.ManagedBy) { ($Grp.ManagedBy | Select-Object -First 2) -join ', ' } else { 'Not Set' }
                            'Accept From'         = if ($Grp.AcceptMessagesOnlyFromSendersOrMembers) { 'Restricted' } else { 'Anyone' }
                            'Require Sender Auth' = $Grp.RequireSenderAuthenticationEnabled
                            'Moderated'           = $Grp.ModerationEnabled
                            'Hidden from GAL'     = $Grp.HiddenFromAddressListsEnabled
                        }
                        $MsgObj.Add([pscustomobject](ConvertTo-HashToYN $msgInObj)) | Out-Null
                    }

                    $null = (& {
                        if ($HealthCheck.ExchangeOnline.Mailboxes) {
                            $null = ($MsgObj | Where-Object {
                                $_.'Require Sender Auth' -eq 'No' -and $_.'Accept From' -eq 'Anyone' -and $_.Moderated -eq 'No'
                            } | Set-Style -Style Warning | Out-Null)
                        }
                    })

                    $MsgTableParams = @{ Name = "Mail-Enabled Security Groups - $TenantId"; List = $false; ColumnWidths = 20, 26, 18, 10, 10, 8, 8 }
                    if ($Report.ShowTableCaptions) { $MsgTableParams['Caption'] = "- $($MsgTableParams.Name)" }
                    if ($MsgObj.Count -gt 0) { $MsgObj | Table @MsgTableParams }
                    $script:ExcelSheets['Mail-Enabled Security Groups'] = $MsgObj
                }
            }
            #endregion

            #region Dynamic Distribution Groups
            if ($DynGroups.Count -gt 0) {
                Section -Style Heading3 'Dynamic Distribution Groups' {
                    Paragraph "The following $($DynGroups.Count) dynamic distribution group(s) use filters to automatically determine membership in tenant $TenantId."
                    BlankLine

                    $DdgObj = [System.Collections.ArrayList]::new()
                    foreach ($Grp in ($DynGroups | Sort-Object DisplayName)) {
                        $ddgInObj = [ordered] @{
                            'Display Name'          = $Grp.DisplayName
                            'Primary SMTP'          = $Grp.PrimarySmtpAddress
                            'Recipient Filter'      = if ($Grp.RecipientFilter) { ($Grp.RecipientFilter -replace '\s+', ' ').Trim() } else { 'Default' }
                            'Recipient Container'   = if ($Grp.RecipientContainer) { $Grp.RecipientContainer } else { 'All' }
                            'Managed By'            = if ($Grp.ManagedBy) { ($Grp.ManagedBy | Select-Object -First 2) -join ', ' } else { 'Not Set' }
                            'Hidden from GAL'       = $Grp.HiddenFromAddressListsEnabled
                        }
                        $DdgObj.Add([pscustomobject](ConvertTo-HashToYN $ddgInObj)) | Out-Null
                    }

                    $DdgTableParams = @{ Name = "Dynamic Distribution Groups - $TenantId"; List = $false; ColumnWidths = 18, 20, 28, 12, 14, 8 }
                    if ($Report.ShowTableCaptions) { $DdgTableParams['Caption'] = "- $($DdgTableParams.Name)" }
                    if ($DdgObj.Count -gt 0) { $DdgObj | Table @DdgTableParams }
                    $script:ExcelSheets['Dynamic Distribution Groups'] = $DdgObj
                }
            }
            #endregion

            #region Microsoft 365 Groups
            if ($M365Groups.Count -gt 0) {
                Section -Style Heading3 'Microsoft 365 Groups' {
                    Paragraph "The following $($M365Groups.Count) Microsoft 365 Group(s) are configured in tenant $TenantId. These groups provide shared mailbox, calendar, SharePoint site, and Teams collaboration."
                    BlankLine

                    # Pre-build a GUID->DisplayName lookup for ManagedBy resolution
                    # ManagedBy on UnifiedGroups returns ObjectIds (GUIDs), not UPNs
                    $ManagedByCache = @{}

                    $M365Obj = [System.Collections.ArrayList]::new()
                    foreach ($Grp in ($M365Groups | Sort-Object DisplayName)) {
                        # Resolve ManagedBy GUIDs to display names
                        $ManagedByDisplay = 'Not Set'
                        if ($Grp.ManagedBy -and @($Grp.ManagedBy).Count -gt 0) {
                            $ResolvedOwners = @()
                            foreach ($OwnerId in ($Grp.ManagedBy | Select-Object -First 3)) {
                                $OwnerStr = "$OwnerId"
                                if ($ManagedByCache.ContainsKey($OwnerStr)) {
                                    $ResolvedOwners += $ManagedByCache[$OwnerStr]
                                } else {
                                    try {
                                        $Resolved = Get-Recipient -Identity $OwnerStr -ErrorAction SilentlyContinue
                                        $Name = if ($Resolved) { $Resolved.DisplayName } else { $OwnerStr }
                                    } catch { $Name = $OwnerStr }
                                    $ManagedByCache[$OwnerStr] = $Name
                                    $ResolvedOwners += $Name
                                }
                            }
                            $ManagedByDisplay = $ResolvedOwners -join ', '
                            $Extra = @($Grp.ManagedBy).Count - 3
                            if ($Extra -gt 0) { $ManagedByDisplay += " (+$Extra more)" }
                        }

                        # Fix #16: SharePoint enabled = URL is not empty
                        $SharePointEnabled = ($Grp.SharePointDocumentsUrl -and "$($Grp.SharePointDocumentsUrl)" -ne '')

                        $m365InObj = [ordered] @{
                            'Display Name'        = $Grp.DisplayName
                            'Primary SMTP'        = $Grp.PrimarySmtpAddress
                            'Access Type'         = $Grp.AccessType
                            'Managed By'          = $ManagedByDisplay
                            'Allow External'      = $Grp.AllowAddGuests
                            'Hidden from GAL'     = $Grp.HiddenFromAddressListsEnabled
                            'SharePoint Enabled'  = $SharePointEnabled
                        }
                        $M365Obj.Add([pscustomobject](ConvertTo-HashToYN $m365InObj)) | Out-Null
                    }

                    $null = (& {
                        if ($HealthCheck.ExchangeOnline.Mailboxes) {
                            # Fix #15: flag ANY public group that allows external (not just both conditions)
                            $null = ($M365Obj | Where-Object { $_.'Access Type' -eq 'Public' -and $_.'Allow External' -eq 'Yes' } | Set-Style -Style Warning | Out-Null)
                            # Also flag private groups allowing guests (lower risk but worth noting)
                            $null = ($M365Obj | Where-Object { $_.'Access Type' -eq 'Public' } | Set-Style -Style Warning | Out-Null)
                        }
                    })

                    $M365TableParams = @{ Name = "Microsoft 365 Groups - $TenantId"; List = $false; ColumnWidths = 18, 22, 10, 18, 10, 12, 10 }
                    if ($Report.ShowTableCaptions) { $M365TableParams['Caption'] = "- $($M365TableParams.Name)" }
                    if ($M365Obj.Count -gt 0) { $M365Obj | Table @M365TableParams }
                    $script:ExcelSheets['Microsoft 365 Groups'] = $M365Obj
                }
            }
            #endregion

            if ($GrandTotal -eq 0) {
                Paragraph "No distribution groups or mail-enabled groups found in tenant $TenantId."
            }
        }
    }

    end {
        Show-AbrDebugExecutionTime -End -TitleMessage 'DistributionGroups'
    }
}