Skip to main content

Finding obsolete Microsoft 365 groups with PowerShell

Author: Tobias Maestrini

This script is based on the original article written by Tony Redmond.

Like any resource within your Microsoft 365 tenant, M365 Groups can become unused over time.

This routine uses PowerShell with CLI for Microsoft 365

  • to gather insights about SharePoint file activity within the related SharePoint site,
  • to do a check against conversation items in the group mailbox,
  • to denote the amount of active people (group owners, members and guests) in the group.

These metrics can help us understand the extent to which the resource is being used from a governance perspective ā€“ or even not. Use this script to create a report of all M365 groups that are possibly obsolete.

$ErrorActionPreference = "Stop"

class GroupInfo {
[PSCustomObject] $Reference
[PSCustomObject] $Membership
[PSCustomObject] $SharePointStatus
[PSCustomObject] $MailboxStatus
[PSCustomObject] $ChatStatus
[string] $TestStatus
[string[]] $Reasons
}

function Start-Routine {
# START ROUTINE
[CmdletBinding()]
param (
[Parameter(Mandatory = $false)] [Switch] $KeepConnectionsAlive,
[Parameter(Mandatory = $false)] [Switch] $KeepOutputPath
)

try {
Initialize-Params
if ($KeepOutputPath.IsPresent) { Initialize-ExportPath -KeepOutputPath }
else { Initialize-ExportPath }
Get-AllM365Groups
Get-AllGuestUsers
Get-AllTeamSites
Start-GroupInsightsTests

Write-Host "`nāœ”ļøŽ Routine terminated" -ForegroundColor Green
if (!$KeepConnectionsAlive.IsPresent) {
m365 logout
}
}
catch {
Write-Error $_.Exception.Message
}
}

function Initialize-Params {
Write-Host "šŸš€ Generating report of obsolete M365 groups within your organization"

# define globals
if ($null -eq $Global:Path) { $Global:Path = $null }
$Script:ReportPath = $null
$Script:Groups = @()
$Script:Guests = @()
$Script:TeamSites = @()
$Global:ObsoleteGroups = [System.Collections.Generic.Dictionary[string, GroupInfo]]::new()

Write-Output "Connecting to M365 tenant: please follow the instructions."
Write-output "IMPORTANT: You'll need to have at least global reader permissions!`n"
if ((m365 status --output text) -eq "Logged out") {
m365 login
}
}

function Initialize-ExportPath {
[CmdletBinding()]
param (
[Parameter(Mandatory = $false)] [Switch] $KeepOutputPath
)

if (!$KeepOutputPath.IsPresent -or $null -eq $Global:Path) {
$Script:Path = Read-Host "Set the path to the folder where you want to export the report data as csv file"
}

$TestPath = Test-Path -Path $Script:Path
$tStamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
if ($TestPath -ne $true) {
New-Item -ItemType directory -Path $Script:Path | Out-Null
Write-Host "Will create file in $($Script:Path): M365GroupsReport-$tStamp.csv" -ForegroundColor Yellow
}
else {
Write-Host "Following report file will be created in $($Script:Path): 'M365GroupsReport-$($tStamp).csv'."
Write-Host "`nAll data will be exported to $($Script:Path): M365GroupsReport-$($tStamp).csv." -ForegroundColor Blue
Write-Host "Do not edit this file during the scan." -ForegroundColor Blue
}
$Script:ReportPath = "$($Script:Path)/M365GroupsReport-$($tStamp).csv"
}

function Get-AllM365Groups {
$groups = m365 entra m365group list --includeSiteUrl | ConvertFrom-Json
$Script:Groups = $groups | Where-Object { $null -ne $_.siteUrl }
}

function Get-AllGuestUsers {
$Script:Guests = m365 entra user list --type Guest | ConvertFrom-Json
}

function Get-AllTeamSites {
$Script:TeamSites = m365 spo site list --type TeamSite | ConvertFrom-Json
}

function Start-GroupInsightsTests {
Write-Host "Checking $($Script:Groups.Count) groups for activity"

$Script:Groups | ForEach-Object {
$groupInfo = [GroupInfo]::new()
$groupInfo.Reference = $_
$groupInfo.Membership = @{Owners = 0; Members = 0; Guests = 0}
$groupInfo.TestStatus = "šŸŸ¢ OK"

Write-Host "ā˜€ļøŽ $($groupInfo.Reference.displayName)"

# Tests
Test-GroupMembership -Group $groupInfo
Test-SharePointActivity -Group $groupInfo
Test-ConversationActivity -Group $groupInfo

# Report
New-Report -Group $groupInfo
}

#Give feedback to user
Write-Host "`n-------------------------------------------------------------------"
Write-Host "`SUMMARY" -ForegroundColor DarkGreen
Write-Host "`-------------------------------------------------------------------"
Write-Host "`nšŸ‘‰ Found $($Global:ObsoleteGroups.Count) group$($Global:ObsoleteGroups.Count -gt 1 ? 's' : '') with possibly low activity."
Write-Host "` Please review the report: " -NoNewline
Write-Host "$($Script:ReportPath)" -ForegroundColor DarkBlue
}

function Test-GroupMembership {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [GroupInfo] $Group
)

# Original lists
$users = m365 entra m365group user list --groupId $Group.Reference.id | ConvertFrom-Json
$owners = $users | Where-Object { $_.roles -contains "Owner" }
$members = $users | Where-Object { $_.roles -contains "Member" -and $_.id -notin $Script:Guests.id }
$guests = $users | Where-Object { $_.id -in $Script:Guests.id }

# Modify the $members list to only contain users that are not in the $owners list
if($null -ne $owners -and $null -ne $members) {
$members = Compare-Object $members $owners -PassThru
}

$Group.Membership = [PSCustomObject][ordered] @{
Owners = $owners ?? @()
Members = $members ?? @()
Guests = $guests ?? @()
}

if ($owners.Count -eq 0) {
Write-Host " ā†’ potentially obsolete (abandoned group: no owner)" -ForegroundColor Yellow
$reason = "Low user count"

$Group.Membership | Add-Member -MemberType NoteProperty -Name Status -Value "Abandoned ($reason)"
$Group.TestStatus = "šŸŸ” Warning"
$Group.Reasons += $reason

try {
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group)
}
catch { }
return
}

if ($Group.Membership.Owners.Count -le 1 -and ($Group.Membership.Members.Count + $Group.Membership.Guests.Count) -eq 0) {
Write-Host " ā†’ potentially obsolete (abandoned group: only $($Group.Membership.Owners.Count) owner left)" -ForegroundColor Yellow
$reason = "Low owner count"

$Group.Membership | Add-Member -MemberType NoteProperty -Name Status -Value "Abandoned ($reason)"
$Group.TestStatus = "šŸŸ” Warning"
$Group.Reasons += $reason

try {
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group)
}
catch { }
}
}

function Test-SharePointActivity {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [GroupInfo] $Group
)

function Get-ParsedDate {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [String] $JavascriptDateString
)

$dateParts = [regex]::Matches($JavascriptDateString, '\d+') | ForEach-Object { $_.Value }

# Convert the parts to integers
$year = [int]$dateParts[0]
$month = [int]$dateParts[1] + 1
$day = [int]$dateParts[2]
$hour = [int]$dateParts[3]
$minute = [int]$dateParts[4]
$second = [int]$dateParts[5]

# return a DateTime object
$dateObject = New-Object -TypeName DateTime -ArgumentList $year, $month, $day, $hour, $minute, $second
$dateObject
}

$WarningDate = (Get-Date).AddDays(-90)

$spoSite = $Script:TeamSites | Where-Object { $_.GroupId -eq "/Guid($($Group.Reference.id))/" }
$spoSite.LastContentModifiedDate = Get-ParsedDate -JavascriptDateString $spoSite.LastContentModifiedDate
if ($spoSite.LastContentModifiedDate -lt $WarningDate) {
Write-Host " ā†’ potentially obsolete (SPO last content modified: $($spoSite.LastContentModifiedDate))" -ForegroundColor Yellow
$reason = "Low SharePoint activity ($($spoSite.LastContentModifiedDate))"

$Group.SharePointStatus = @{
Reason = $reason
}
$Group.TestStatus = "šŸŸ” Warning"
$Group.Reasons += $reason

try {
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group)
}
catch { }
}
}

function Test-ConversationActivity {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [GroupInfo] $Group
)

$WarningDate = (Get-Date).AddDays(-365)

$conversations = m365 entra m365group conversation list --groupId $Group.Reference.id | ConvertFrom-Json | Sort-Object -Property lastDeliveredDateTime -Descending
$latestConversation = $conversations | Where-Object {
[datetime]$_.lastDeliveredDateTime -gt $WarningDate.Date
} | Select-Object -First 1

$Group.MailboxStatus = @{
NumberOfConversations = $conversations.Length
LastConversation = $conversations ? $conversations[0].lastDeliveredDateTime : "n/a"
OutdatedConversations = 0
Reason = ""
}

# Return if there are no conversations or the latest conversation is not outdated
if (!$conversations -or $latestConversation.Count -eq 1) { return }

$outdatedConversations = $conversations | Where-Object {
[datetime]$_.lastDeliveredDateTime -lt $WarningDate
}

Write-Host " ā†’ potentially obsolete ($($outdatedConversations.Length) conversation item$($outdatedConversations.Length -gt 1 ? 's' : '') created more than 1 year ago)" -ForegroundColor Yellow
$reason = "$($outdatedConversations.Length) conversation item$($outdatedConversations.Length -gt 1 ? 's' : '') created more than 1 year ago"

$Group.MailboxStatus.OutdatedConversations = $outdatedConversations | Sort-Object -Property lastDeliveredDateTime
$Group.MailboxStatus.Reason = $reason
$Group.TestStatus = "šŸŸ” Warning"
$Group.Reasons += $reason

try {
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group)
}
catch { }
}

function New-Report {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [GroupInfo] $Group
)

$exportObject = [PSCustomObject][ordered] @{
"Group Name" = $Group.Reference.displayName
Description = $Group.Reference.description
"Managed by" = $Group.Membership.Owners ? $Group.Membership.Owners.displayName -join ", " : "n/a"
Owners = $Group.Membership.Owners.Count
Members = $Group.Membership.Members.Count
Guests = $Group.Membership.Guests.Count
"Group Status" = $Group.Membership.Status ?? "Normal"
"Number of Conversations" = $Group.MailboxStatus.NumberOfConversations ? $Group.MailboxStatus.NumberOfConversations : "n/a"
"Last Conversation" = $Group.MailboxStatus.LastConversation
"Conversation Status" = $Group.MailboxStatus.Reason ?? "Normal"
"Team enabled" = $Group.Reference.resourceProvisioningOptions -contains 'Team' ? "True" : "False"
"SPO Status" = $Group.SharePointStatus.Reason ?? "Normal"
"SPO Activity" = $Group.SharePointStatus ? "Low / No document library usage" : "Document library in use"
"Number of warnings" = $Group.Reasons.Count
Status = $Group.TestStatus
}

$exportObject | Export-Csv -Path $Script:ReportPath -Append -NoTypeInformation
}

# START the report generation
Start-Routine #-KeepConnectionsAlive -KeepOutputPath
CTRL + M