Public/Invoke-ModernMailContactGroupReport.ps1
|
function Invoke-ModernMailContactGroupReport { <# .SYNOPSIS Generates a contact-centric distribution group membership report. .DESCRIPTION Reads mail contacts (and optionally mail users and guests) from Exchange Online and resolves which distribution lists, mail-enabled security groups, room lists and (optionally) dynamic distribution groups they belong to. Nested memberships can be expanded so you also see the parent groups a contact receives mail through. Contacts without any group membership are listed as well, which helps with cleanup. Supports optional export to CSV, JSON or HTML. .PARAMETER OutPath Optional path for exporting the report. Provide a folder to create MailContactGroupReport.csv or a specific file path ending in .csv, .json or .html. .PARAMETER RecipientType Which recipient types to analyze. Defaults to MailContact. .PARAMETER IncludeNested Resolve nested group membership (contact is in group A, group A is member of group B). .PARAMETER IncludeDynamic Include dynamic distribution groups (uses Get-DynamicDistributionGroupMember, slower in large tenants). .PARAMETER UnassignedOnly Return only contacts that are not member of any group. .PARAMETER Raw Return one row per contact/group assignment instead of the per-contact summary. .PARAMETER AddHtmlOutput Emit an HTML report (MailContactGroupReport.html) next to the CSV/JSON export. .PARAMETER DisableTelemetry Disable telemetry. .EXAMPLE Invoke-ModernMailContactGroupReport .EXAMPLE Invoke-ModernMailContactGroupReport -OutPath C:\Reports .EXAMPLE Invoke-ModernMailContactGroupReport -IncludeNested -IncludeDynamic -OutPath C:\Reports\MailContactGroupReport.html .EXAMPLE Invoke-ModernMailContactGroupReport -RecipientType MailContact,MailUser,GuestMailUser -Raw -OutPath C:\Reports\contacts.json .EXAMPLE Invoke-ModernMailContactGroupReport -UnassignedOnly | Format-Table DisplayName, ExternalEmailAddress .LINK https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailContactGroupReport #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param( [Parameter(Mandatory = $false, HelpMessage = 'Optional export path (folder, .csv, .json or .html).')] [string]$OutPath, [Parameter(Mandatory = $false, HelpMessage = 'Recipient types to analyze')] [ValidateSet('MailContact', 'MailUser', 'GuestMailUser')] [string[]]$RecipientType = @('MailContact'), [Parameter(Mandatory = $false, HelpMessage = 'Resolve nested group membership')] [switch]$IncludeNested, [Parameter(Mandatory = $false, HelpMessage = 'Include dynamic distribution groups')] [switch]$IncludeDynamic, [Parameter(Mandatory = $false, HelpMessage = 'Return only contacts without group membership')] [switch]$UnassignedOnly, [Parameter(Mandatory = $false, HelpMessage = 'Return detailed rows instead of summary')] [switch]$Raw, [Parameter(Mandatory = $false, HelpMessage = 'Emit an HTML report (MailContactGroupReport.html)')] [switch]$AddHtmlOutput, [Parameter(Mandatory = $false, HelpMessage = 'Disable telemetry')] [switch]$DisableTelemetry ) if ($DisableTelemetry -or $Script:DisableTelemetry) { $Script:DisableTelemetry = $true } else { Write-Telemetry -EventName "InvokeModernMailContactGroupReport" } # Ensure shared session cache exists to keep compatibility with other functions. if (-not (Get-Variable -Name __MMTSession -Scope Script -ErrorAction SilentlyContinue)) { $script:__MMTSession = @{ CloudUserCache = @() CloudMailboxCache = @() CountCache = @{} TableCache = @{} } New-Variable -Name __MMTSession -Value $script:__MMTSession -Scope Script -Force | Out-Null } # Exchange Online session $session = Get-ConnectionInformation | Where-Object { $_.state -eq 'Connected' -and $_.ModulePrefix -eq 'EO' } if (-not $session) { $session = Connect-MMExchangeOnline } function ConvertTo-MMHtmlText { param([object]$Value) return [System.Net.WebUtility]::HtmlEncode([string]$Value) } function Get-MMGroupTypeLabel { param([string]$TypeDetails) switch ($TypeDetails) { 'MailUniversalDistributionGroup' { 'Distribution' } 'MailNonUniversalGroup' { 'Distribution' } 'MailUniversalSecurityGroup' { 'MailSecurity' } 'RoomList' { 'RoomList' } 'DynamicDistributionGroup' { 'Dynamic' } default { $TypeDetails } } } function Get-MMAddress { param([object]$Value) if ($null -eq $Value) { return $null } return ($Value.ToString() -replace '^(?i)smtp:', '') } $groupTypes = @('MailUniversalDistributionGroup', 'MailNonUniversalGroup', 'MailUniversalSecurityGroup', 'RoomList', 'DynamicDistributionGroup') #region Contacts $contactIndex = @{} if ($RecipientType -contains 'MailContact') { foreach ($c in (Get-EOMailContact -ResultSize unlimited)) { $contactIndex[$c.Guid.ToString()] = [pscustomobject]@{ Guid = $c.Guid.ToString() DisplayName = $c.DisplayName ExternalEmailAddress = Get-MMAddress $c.ExternalEmailAddress PrimarySmtpAddress = Get-MMAddress $c.PrimarySmtpAddress RecipientTypeDetails = $c.RecipientTypeDetails.ToString() IsDirSynced = [bool]$c.IsDirSynced HiddenFromAddressListsEnabled = [bool]$c.HiddenFromAddressListsEnabled WhenCreated = $c.WhenCreated } } } if ($RecipientType -contains 'MailUser' -or $RecipientType -contains 'GuestMailUser') { foreach ($u in (Get-EOMailUser -ResultSize unlimited)) { if ($RecipientType -notcontains $u.RecipientTypeDetails.ToString()) { continue } $contactIndex[$u.Guid.ToString()] = [pscustomobject]@{ Guid = $u.Guid.ToString() DisplayName = $u.DisplayName ExternalEmailAddress = Get-MMAddress $u.ExternalEmailAddress PrimarySmtpAddress = Get-MMAddress $u.PrimarySmtpAddress RecipientTypeDetails = $u.RecipientTypeDetails.ToString() IsDirSynced = [bool]$u.IsDirSynced HiddenFromAddressListsEnabled = [bool]$u.HiddenFromAddressListsEnabled WhenCreated = $u.WhenCreated } } } Write-Verbose "Contacts to analyze: $($contactIndex.Count)" #endregion #region Groups and membership $groups = @(Get-MMCloudDistributionGroup) if ($IncludeDynamic) { $groups += @(Get-EODynamicDistributionGroup -ResultSize unlimited) } $groupIndex = @{} foreach ($g in $groups) { $groupIndex[$g.Guid.ToString()] = [pscustomobject]@{ Guid = $g.Guid.ToString() DisplayName = $g.DisplayName PrimarySmtpAddress = Get-MMAddress $g.PrimarySmtpAddress GroupType = Get-MMGroupTypeLabel $g.RecipientTypeDetails.ToString() RequireSenderAuthenticationEnabled = [bool]$g.RequireSenderAuthenticationEnabled } } # contactGuid|groupGuid -> assignment row $assignments = [ordered]@{} # childGroupGuid -> list of parent group guids $parentMap = @{} $i = 0 foreach ($g in $groups) { $i++ $gKey = $g.Guid.ToString() Write-Progress -Activity 'Reading group membership' -Status $g.DisplayName -PercentComplete (($i / [math]::Max($groups.Count, 1)) * 100) try { if ($g.RecipientTypeDetails.ToString() -eq 'DynamicDistributionGroup') { $members = Get-EODynamicDistributionGroupMember -Identity $g.Guid.ToString() -ResultSize unlimited -ErrorAction Stop } else { $members = Get-EODistributionGroupMember -Identity $g.Guid.ToString() -ResultSize unlimited -ErrorAction Stop } } catch { Write-Verbose "Member query failed for $($g.PrimarySmtpAddress): $($_.Exception.Message)" continue } foreach ($m in $members) { $mType = [string]$m.RecipientTypeDetails $mKey = [string]$m.Guid if ($RecipientType -contains $mType) { if (-not $contactIndex.ContainsKey($mKey)) { # Member not returned by the contact query (e.g. filtered); add a minimal entry. $contactIndex[$mKey] = [pscustomobject]@{ Guid = $mKey DisplayName = $m.DisplayName ExternalEmailAddress = Get-MMAddress $m.ExternalEmailAddress PrimarySmtpAddress = Get-MMAddress $m.PrimarySmtpAddress RecipientTypeDetails = $mType IsDirSynced = [bool]$m.IsDirSynced HiddenFromAddressListsEnabled = [bool]$m.HiddenFromAddressListsEnabled WhenCreated = $m.WhenCreated } } $assignments["$mKey|$gKey"] = [pscustomobject]@{ ContactGuid = $mKey GroupGuid = $gKey MembershipType = 'Direct' Via = $null } } elseif ($IncludeNested -and $groupTypes -contains $mType) { if (-not $parentMap.ContainsKey($mKey)) { $parentMap[$mKey] = [System.Collections.Generic.List[string]]::new() } $parentMap[$mKey].Add($gKey) } } } Write-Progress -Activity 'Reading group membership' -Completed if ($IncludeNested) { $directRows = @($assignments.Values | Where-Object { $_.MembershipType -eq 'Direct' }) foreach ($row in $directRows) { $visited = @{ $row.GroupGuid = $true } $queue = [System.Collections.Generic.Queue[object]]::new() $queue.Enqueue(@($row.GroupGuid, @($groupIndex[$row.GroupGuid].DisplayName))) while ($queue.Count -gt 0) { $item = $queue.Dequeue() $childKey = $item[0] $chain = $item[1] if (-not $parentMap.ContainsKey($childKey)) { continue } foreach ($parentKey in $parentMap[$childKey]) { if ($visited.ContainsKey($parentKey)) { continue } $visited[$parentKey] = $true $aKey = "$($row.ContactGuid)|$parentKey" if (-not $assignments.Contains($aKey)) { $assignments[$aKey] = [pscustomobject]@{ ContactGuid = $row.ContactGuid GroupGuid = $parentKey MembershipType = 'Nested' Via = ($chain -join ' > ') } } $queue.Enqueue(@($parentKey, @($chain + $groupIndex[$parentKey].DisplayName))) } } } } #endregion #region Output $expandedRows = foreach ($a in $assignments.Values) { $c = $contactIndex[$a.ContactGuid] $g = $groupIndex[$a.GroupGuid] [pscustomobject]@{ DisplayName = $c.DisplayName ExternalEmailAddress = $c.ExternalEmailAddress RecipientTypeDetails = $c.RecipientTypeDetails Group = $g.DisplayName GroupSmtpAddress = $g.PrimarySmtpAddress GroupType = $g.GroupType MembershipType = $a.MembershipType Via = $a.Via } } $expandedRows = @($expandedRows | Sort-Object DisplayName, Group) $rowsByContact = @{} foreach ($a in $assignments.Values) { if (-not $rowsByContact.ContainsKey($a.ContactGuid)) { $rowsByContact[$a.ContactGuid] = [System.Collections.Generic.List[object]]::new() } $rowsByContact[$a.ContactGuid].Add($a) } $summary = foreach ($c in $contactIndex.Values) { $rows = if ($rowsByContact.ContainsKey($c.Guid)) { @($rowsByContact[$c.Guid]) } else { @() } [pscustomobject]@{ DisplayName = $c.DisplayName ExternalEmailAddress = $c.ExternalEmailAddress PrimarySmtpAddress = $c.PrimarySmtpAddress RecipientTypeDetails = $c.RecipientTypeDetails IsDirSynced = $c.IsDirSynced HiddenFromAddressListsEnabled = $c.HiddenFromAddressListsEnabled WhenCreated = $c.WhenCreated DirectGroupCount = @($rows | Where-Object { $_.MembershipType -eq 'Direct' }).Count TotalGroupCount = $rows.Count Groups = (@($rows | ForEach-Object { $g = $groupIndex[$_.GroupGuid] "$($g.DisplayName) <$($g.PrimarySmtpAddress)> [$($_.MembershipType)]" } | Sort-Object) -join '; ') } } $summary = @($summary | Sort-Object DisplayName) $unassigned = @($summary | Where-Object { $_.TotalGroupCount -eq 0 }) if ($UnassignedOnly) { $output = $unassigned } elseif ($Raw) { $output = $expandedRows } else { $output = $summary } if ($OutPath) { $targetPath = $OutPath if (-not [System.IO.Path]::HasExtension($targetPath)) { $targetPath = Join-Path -Path $OutPath -ChildPath 'MailContactGroupReport.csv' } $dir = Split-Path -Path $targetPath -Parent if ($dir -and -not (Test-Path -Path $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } if ($targetPath.ToLower().EndsWith('.json')) { $output | ConvertTo-Json -Depth 6 | Set-Content -Path $targetPath -Encoding UTF8 Write-Verbose "Report exported to $targetPath" } elseif (-not $targetPath.ToLower().EndsWith('.html')) { $output | Export-Csv -Path $targetPath -NoTypeInformation -Encoding UTF8 Write-Verbose "Report exported to $targetPath" } if ($targetPath.ToLower().EndsWith('.html') -or $AddHtmlOutput) { $htmlPath = if ($targetPath.ToLower().EndsWith('.html')) { $targetPath } else { Join-Path -Path (Split-Path $targetPath -Parent) -ChildPath 'MailContactGroupReport.html' } $htmlTitle = 'Mail Contact Group Report' $htmlDate = Get-Date $tenantId = if ($session.TenantId) { $session.TenantId } else { 'N/A' } $generatedBy = if ($session.UserPrincipalName) { $session.UserPrincipalName } else { 'N/A' } $exoConnected = [bool]$session $contactCount = $summary.Count $assignedCount = $contactCount - $unassigned.Count $unassignedCount = $unassigned.Count $groupsWithContacts = ($expandedRows | Select-Object -ExpandProperty GroupSmtpAddress -Unique | Measure-Object).Count $typeStats = $expandedRows | Group-Object GroupType | Sort-Object Name $chartLabelsString = "[" + (($typeStats | ForEach-Object { "'$($_.Name)'" }) -join ',') + "]" $chartDataString = "[" + (($typeStats | ForEach-Object { $_.Count }) -join ',') + "]" $topGroups = $expandedRows | Group-Object GroupSmtpAddress | Sort-Object Count -Descending | Select-Object -First 15 $page = @" <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>$htmlTitle</title> <script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <body class="bg-gray-100 text-gray-800"> <div class="container mx-auto p-6"> <div class="bg-orange-500 text-white rounded-lg shadow-md p-6 mb-6"> <div class="flex flex-col gap-2"> <h1 class="text-3xl font-bold">$htmlTitle</h1> <p class="text-lg">Tenant ID: $tenantId</p> <p class="text-lg">Generated by: $generatedBy</p> <p class="text-sm opacity-90">Generated on: $htmlDate</p> <div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold"> <a href="https://www.alweys.ch" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Visit Alweys Website (Blog)</a> <a href="https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailContactGroupReport" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Documentation for Cmdlet (Help)</a> <a href="https://www.linkedin.com/in/stefanwey" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Connect on LinkedIn (Stefan Wey)</a> <a href="#" class="bg-gray-500 hover:bg-gray-700 text-white px-3 py-1 rounded shadow-sm">Exchange Online: $exoConnected</a> <a href="#" class="bg-gray-500 hover:bg-gray-700 text-white px-3 py-1 rounded shadow-sm">Nested: $([bool]$IncludeNested) / Dynamic: $([bool]$IncludeDynamic)</a> </div> </div> </div> <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Contacts analyzed</div> <div class="text-2xl font-semibold text-orange-500">$contactCount</div> </div> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Contacts in groups</div> <div class="text-2xl font-semibold text-orange-500">$assignedCount</div> </div> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Contacts without group</div> <div class="text-2xl font-semibold text-orange-500">$unassignedCount</div> </div> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Groups with contacts</div> <div class="text-2xl font-semibold text-orange-500">$groupsWithContacts</div> </div> </div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="bg-white rounded-lg shadow p-4 max-w-lg"> <h2 class="text-xl font-semibold mb-3">Assignments by group type</h2> <canvas id="typeChart" class="w-72 h-72 mx-auto"></canvas> </div> <div class="bg-white rounded-lg shadow p-4 overflow-x-auto"> <h2 class="text-xl font-semibold mb-3">Top groups by contact count</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">Group</th> <th class="px-3 py-2 text-left">Type</th> <th class="px-3 py-2 text-left">Contacts</th> </tr> </thead> <tbody> "@ foreach ($tg in $topGroups) { $first = $tg.Group | Select-Object -First 1 $page += @" <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100"> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $first.Group)<br><span class="text-xs text-gray-500">$(ConvertTo-MMHtmlText $tg.Name)</span></td> <td class="px-3 py-2 whitespace-nowrap">$($first.GroupType)</td> <td class="px-3 py-2 whitespace-nowrap">$($tg.Count)</td> </tr> "@ } $page += @" </tbody> </table> </div> </div> <div class="bg-white rounded-lg shadow p-4 overflow-x-auto mb-6"> <h2 class="text-xl font-semibold mb-3">Contact summary</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">Display Name</th> <th class="px-3 py-2 text-left">External Address</th> <th class="px-3 py-2 text-left">Type</th> <th class="px-3 py-2 text-left">Synced</th> <th class="px-3 py-2 text-left">Hidden</th> <th class="px-3 py-2 text-left">Direct</th> <th class="px-3 py-2 text-left">Total</th> <th class="px-3 py-2 text-left">Groups</th> </tr> </thead> <tbody> "@ foreach ($row in $summary) { $rowClass = if ($row.TotalGroupCount -eq 0) { 'bg-red-50 hover:bg-amber-50 border-b border-gray-100' } else { 'odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100' } $page += @" <tr class="$rowClass"> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.DisplayName)</td> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.ExternalEmailAddress)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.RecipientTypeDetails)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.IsDirSynced)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.HiddenFromAddressListsEnabled)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.DirectGroupCount)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.TotalGroupCount)</td> <td class="px-3 py-2">$(ConvertTo-MMHtmlText $row.Groups)</td> </tr> "@ } $page += @" </tbody> </table> </div> <div class="bg-white rounded-lg shadow p-4 overflow-x-auto"> <h2 class="text-xl font-semibold mb-3">Detailed assignments</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">Display Name</th> <th class="px-3 py-2 text-left">External Address</th> <th class="px-3 py-2 text-left">Group</th> <th class="px-3 py-2 text-left">Group Address</th> <th class="px-3 py-2 text-left">Group Type</th> <th class="px-3 py-2 text-left">Membership</th> <th class="px-3 py-2 text-left">Via</th> </tr> </thead> <tbody> "@ foreach ($row in $expandedRows) { $page += @" <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100"> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.DisplayName)</td> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.ExternalEmailAddress)</td> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.Group)</td> <td class="px-3 py-2 whitespace-nowrap">$(ConvertTo-MMHtmlText $row.GroupSmtpAddress)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.GroupType)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.MembershipType)</td> <td class="px-3 py-2">$(ConvertTo-MMHtmlText $row.Via)</td> </tr> "@ } $page += @" </tbody> </table> </div> </div> <script> const typeLabels = $chartLabelsString; const typeData = $chartDataString; const ctx = document.getElementById('typeChart'); if (ctx && typeLabels.length > 0) { new Chart(ctx, { type: 'doughnut', data: { labels: typeLabels, datasets: [{ data: typeData, backgroundColor: ['#EF4444','#3B82F6','#10B981','#F59E0B','#8B5CF6','#EC4899'], borderWidth: 1 }] }, options: { plugins: { legend: { position: 'bottom' } } } }); } </script> </body> </html> "@ $page | Set-Content -Path $htmlPath -Encoding UTF8 Write-Verbose "Report exported to $htmlPath" } } #endregion foreach ($item in $output) { $item } } |