Inventory/Get-GroupInventory.ps1

<#
.SYNOPSIS
    Generates a per-group inventory of distribution lists, mail-enabled security groups,
    and Microsoft 365 groups.
.DESCRIPTION
    Enumerates all distribution lists, mail-enabled security groups, and Microsoft 365
    (Unified) groups in Exchange Online. Reports member counts, owners, group type, and
    key settings. Designed for M&A due diligence and migration planning.
 
    For large tenants with many distribution groups, use -SkipMemberCount to skip
    per-group member enumeration and improve performance.
 
    Requires ExchangeOnlineManagement module and an active Exchange Online connection.
.PARAMETER SkipMemberCount
    Skip per-distribution-group member enumeration. Member counts will show as N/A
    for distribution lists and mail-enabled security groups. M365 group member counts
    are always available via GroupMemberCount property.
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned
    to the pipeline.
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service ExchangeOnline
    PS> .\Inventory\Get-GroupInventory.ps1
 
    Returns inventory of all distribution lists, security groups, and M365 groups.
.EXAMPLE
    PS> .\Inventory\Get-GroupInventory.ps1 -SkipMemberCount -OutputPath '.\group-inventory.csv'
 
    Exports group inventory without per-DL member counts (faster on large tenants).
.NOTES
    M365 Assess — M&A Inventory
#>

[CmdletBinding()]
param(
    [Parameter()]
    [switch]$SkipMemberCount,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath
)

$ErrorActionPreference = 'Stop'

# Verify EXO connection
try {
    $null = Get-OrganizationConfig -ErrorAction Stop
}
catch {
    Write-Error "Not connected to Exchange Online. Run Connect-Service -Service ExchangeOnline first."
    return
}

$results = [System.Collections.Generic.List[PSCustomObject]]::new()

# ------------------------------------------------------------------
# Distribution groups and mail-enabled security groups
# ------------------------------------------------------------------
Write-Verbose "Retrieving distribution groups..."
try {
    $distributionGroups = @(Get-DistributionGroup -ResultSize Unlimited)
}
catch {
    Write-Warning "Failed to retrieve distribution groups: $_"
    $distributionGroups = @()
}

Write-Verbose "Processing $($distributionGroups.Count) distribution groups..."
$counter = 0

foreach ($group in $distributionGroups) {
    $counter++
    if ($counter % 25 -eq 0 -or $counter -eq 1) {
        Write-Verbose "[$counter/$($distributionGroups.Count)] $($group.PrimarySmtpAddress)"
    }

    # Determine group type
    $groupType = if ($group.RecipientTypeDetails -eq 'MailUniversalSecurityGroup') {
        'MailEnabledSecurity'
    }
    else {
        'DistributionList'
    }

    # Get member count (unless skipped)
    $memberCount = 'N/A'
    if (-not $SkipMemberCount) {
        try {
            $members = @(Get-DistributionGroupMember -Identity $group.PrimarySmtpAddress -ResultSize Unlimited)
            $memberCount = $members.Count
        }
        catch {
            Write-Warning "Could not retrieve members for $($group.PrimarySmtpAddress): $_"
        }
    }

    # Format owners
    $managedBy = ''
    if ($group.ManagedBy) {
        $managedBy = ($group.ManagedBy | ForEach-Object { $_.ToString() }) -join '; '
    }

    $results.Add([PSCustomObject]@{
        DisplayName              = $group.DisplayName
        PrimarySmtpAddress       = $group.PrimarySmtpAddress
        GroupType                = $groupType
        MemberCount              = $memberCount
        ExternalMemberCount      = ''
        ManagedBy                = $managedBy
        WhenCreated              = $group.WhenCreated
        AccessType               = ''
        HiddenFromAddressLists   = $group.HiddenFromAddressListsEnabled
        RequireSenderAuthentication = $group.RequireSenderAuthenticationEnabled
    })
}

# ------------------------------------------------------------------
# Microsoft 365 (Unified) groups
# ------------------------------------------------------------------
Write-Verbose "Retrieving Microsoft 365 groups..."
try {
    $m365Groups = @(Get-UnifiedGroup -ResultSize Unlimited -IncludeAllProperties)
}
catch {
    Write-Warning "Failed to retrieve Microsoft 365 groups: $_"
    $m365Groups = @()
}

Write-Verbose "Processing $($m365Groups.Count) Microsoft 365 groups..."
$counter = 0

foreach ($group in $m365Groups) {
    $counter++
    if ($counter % 25 -eq 0 -or $counter -eq 1) {
        Write-Verbose "[$counter/$($m365Groups.Count)] $($group.PrimarySmtpAddress)"
    }

    # Format owners
    $managedBy = ''
    if ($group.ManagedBy) {
        $managedBy = ($group.ManagedBy | ForEach-Object { $_.ToString() }) -join '; '
    }

    $results.Add([PSCustomObject]@{
        DisplayName              = $group.DisplayName
        PrimarySmtpAddress       = $group.PrimarySmtpAddress
        GroupType                = 'M365Group'
        MemberCount              = $group.GroupMemberCount
        ExternalMemberCount      = $group.GroupExternalMemberCount
        ManagedBy                = $managedBy
        WhenCreated              = $group.WhenCreated
        AccessType               = $group.AccessType
        HiddenFromAddressLists   = $group.HiddenFromAddressListsEnabled
        RequireSenderAuthentication = $group.RequireSenderAuthenticationEnabled
    })
}

if ($results.Count -eq 0) {
    Write-Verbose "No groups found in this tenant"
    return
}

$report = @($results) | Sort-Object -Property GroupType, DisplayName

Write-Verbose "Inventory complete: $($report.Count) groups ($($distributionGroups.Count) DLs, $($m365Groups.Count) M365 groups)"

if ($OutputPath) {
    $report | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Output "Exported group inventory ($($report.Count) groups) to $OutputPath"
}
else {
    Write-Output $report
}