Entra / Microsoft 365 · Teams
Report inactive teams by email
Report inactive Teams based on activity recorded in audit logs and email the report to administrators.
Connect & set up
Run these once per session. All scopes are read-only unless the script makes changes.
Connect-MgGraph -TenantId $TenantId -AppId $AppId -CertificateSubjectName $Thumbprint
Run it
The main script. Copy it, or download the .ps1 and run it from your console.
param([string] $TenantId = "",[string] $AppId = "",[int] $LookbackDays = 30,[string] $StartDate = (Get-Date).AddDays(-$LookbackDays),[string] $EndDate = (Get-Date).AddDays(1))Connect-MgGraph -TenantId $TenantId -AppId $AppId -CertificateSubjectName $Thumbprint# Run an audit job to find SharePoint FileUpload and FileModified events# SharePoint events at https://learn.microsoft.com/en-us/purview/audit-log-activities?WT.mc_id=M365-MVP-9501#file-and-page-activitiesWrite-Output "Generating audit report for SharePoint file activities..."Set-MgRequestContext -MaxRetry 10 -RetryDelay 15 | Out-Null$AuditQueryName = ("Audit Job SPO Operations created at {0}" -f (Get-Date))$AuditQueryStart = (Get-Date $StartDate -format s)$AuditQueryEnd = (Get-Date $EndDate -format s)[array]$AuditQueryOperations = "FileModified", "FileUploaded"$AuditQueryParameters = @{}#$AuditQueryParameters.Add("@odata.type","#microsoft.graph.security.auditLogQuery")$AuditQueryParameters.Add("displayName", $AuditQueryName)$AuditQueryParameters.Add("OperationFilters", $AuditQueryOperations)$AuditQueryParameters.Add("filterStartDateTime", $AuditQueryStart)$AuditQueryParameters.Add("filterEndDateTime", $AuditQueryEnd)# Submit the audit query job$AuditJob = New-MgBetaSecurityAuditLogQuery -BodyParameter $AuditQueryParameters# Check the audit query job status every 20 seconds until it completes[int]$i = 1[int]$SleepSeconds = 20$SearchFinished = $false; [int]$SecondsElapsed = 20Write-Host "Checking audit query status..."Start-Sleep -Seconds 30$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}" -f $AuditJob.Id)$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method GETWhile ($SearchFinished -eq $false) {$i++Write-Host ("Waiting for audit search to complete. Check {0} after {1} seconds. Current state {2}" -f $i, $SecondsElapsed, $AuditQueryStatus.status)If ($AuditQueryStatus.status -eq 'succeeded') {$SearchFinished = $true} Else {Start-Sleep -Seconds $SleepSeconds$SecondsElapsed = $SecondsElapsed + $SleepSeconds$AuditQueryStatus = Invoke-MgGraphRequest -Uri $Uri -Method GET}}Write-Host "Fetching audit records found by the search..."$Uri = ("https://graph.microsoft.com/beta/security/auditLog/queries/{0}/records?`$Top=999" -f $AuditJob.Id)[array]$SearchRecords = Invoke-MgGraphRequest -Uri $Uri -Method GET[array]$AuditRecords = $SearchRecords.value# Paginate to fetch all available audit records$NextLink = $SearchRecords.'@odata.NextLink'While ($null -ne $NextLink) {$SearchRecords = $null[array]$SearchRecords = Invoke-MgGraphRequest -Uri $NextLink -Method GET$AuditRecords += $SearchRecords.valueWrite-Host ("{0} audit records fetched so far..." -f $AuditRecords.count)$NextLink = $SearchRecords.'@odata.NextLink'}Write-Host ("Total of {0} audit records found" -f $AuditRecords.count) -ForegroundColor Red# Parse the audit records and extract information about the sites where activities occurred.$SPOAuditInfo = [System.Collections.Generic.List[Object]]::new()ForEach ($Record in $AuditRecords) {$SiteUrl = $nullSwitch ($Record.Operation) {"FileModified" {If ($Record.AuditData.SourceRelativeURL -notlike "*PreservationHoldLibrary") {$SiteUrl = $Record.AuditData.SiteUrl}}"FileUploaded" {$SiteUrl = $Record.AuditData.SiteUrl}}$SPOAuditLine = [PSCustomObject]@{Id = $Record.IdCreation = Get-Date $Record.CreatedDateTime -format 'dd-MMM-yyyy HH:mm:ss'User = $Record.UserIdOperation = $Record.OperationSiteURL = $SiteUrl}$SPOAuditInfo.Add($SPOAuditLine)}# Now get the usage report data$ObfuscationChanged = $falseIf ((Get-MgAdminReportSetting).DisplayConcealedNames -eq $True) {$Parameters = @{ displayConcealedNames = $False }Update-MgAdminReportSetting -BodyParameter $Parameters$ObfuscationChanged = $true}Write-Output "Fetching Teams usage data..."$TempFile = "C:\Temp\TeamActivityDetail.csv"Get-MgReportTeamActivityDetail -Period 'D30' -OutFile $TempFile$TeamsData = Import-Csv -Path $TempFileIf ($ObfuscationChanged) {If ((Get-MgAdminReportSetting).DisplayConcealedNames -eq $False) {$Parameters = @{ displayConcealedNames = $True }Update-MgAdminReportSetting -BodyParameter $Parameters}}# Get a list of all teamsWrite-Host "Fetching list of Teams..."[array]$Teams = Get-MgTeam -All -PageSize 500If (!$Teams) {Write-Host "No Teams found"Break}Write-Output ("Found {0} Teams" -f $Teams.Count)Write-Output "Generating report about Teams activity..."# For each team, check what activities we know about$TeamReport = [System.Collections.Generic.List[Object]]::new()ForEach ($Team in $Teams) {# To be able to filter out archived teams and to get the created date, we need to fetch the Team settings# It's silly that these properties aren't returned by default when you list teams.# https://learn.microsoft.com/en-us/graph/api/teams-list?view=graph-rest-1.0&WT.mc_id=M365-MVP-9501$TeamSettings = Get-MgTeam -Team $Team.IdIf ($TeamSettings.IsArchived -eq $true) {Write-Host ("The {0} team is archived and is excluded from active status checks" -f $Team.DisplayName)Continue}$CreatedDate = $TeamSettings.CreatedDateTime$AgeInDays = (New-TimeSpan -Start $CreatedDate -End (Get-Date)).Days# Get the webURL for the Team site - this is one way to get the information$WebURL = $null$Uri = ("https://graph.microsoft.com/v1.0/groups/{0}/sites/root?`$select=webUrl" -f $Team.id)Try {$Data = Invoke-MgGraphRequest -Uri $Uri -Method Get -ErrorAction SilentlyContinue} Catch {Write-Host ("Unable to find SharePoint site for {0}. It might not have been created." -f $Team.DisplayName)Continue}$WebURL = $Data.WebUrl + "/"# Get team owners$OwnersDisplayNames = $null[array]$Owners = Get-MgGroupOwner -GroupId $Team.Id | Select-Object -ExpandProperty AdditionalProperties$OwnersDisplayNames = $Owners.displayName -join ", "# Try and find any audit records for filesuploaded and updated for the site[array]$FilesUploaded = $SPOAuditInfo | Where-Object { $_.SiteURL -eq $WebURL -and $_.Operation -eq 'FileUploaded' }[array]$FilesModified = $SPOAuditInfo | Where-Object { $_.SiteURL -eq $WebURL -and $_.Operation -eq 'FileModified' }[array]$UsageData = $TeamsData | Where-Object { $_.'Team Id' -eq $Team.Id }If ($UsageData) {$ActiveUsers = $UsageData.'Active Users'$ActiveChannels = $UsageData.'Active Channels'$ChannelMessages = $UsageData.'Channel Messages'$Reactions = $UsageData.'Reactions'$MeetingsOrganized = $UsageData.'Meetings Organized'$PostMessages = $UsageData.'Post Messages'$ReplyMessages = $UsageData.'Reply Messages'$UrgentMessages = $UsageData.'Urgent Messages'$Mentions = $UsageData.'Mentions'$ActiveSharedChannels = $UsageData.'Active Shared Channels'} Else {$ActiveUsers = 0$ActiveChannels = 0$ChannelMessages = 0$Reactions = 0$MeetingsOrganized = 0$PostMessages = 0$ReplyMessages = 0$UrgentMessages = 0$Mentions = 0$ActiveSharedChannels = 0}If ($UsageData.'Last Activity Date') {$LastActiveDate = Get-Date $UsageData.'Last Activity Date' -format dd-MMM-yyyy} Else {$LastActiveDate = "Never active"}[int]$CountOfTeamActivities = $FilesUploaded.Count + $FilesModified.Count + $ActiveUsers + $Reactions + $ChannelMessages + $PostMessages + $ReplyMessages$TeamReportLine = [PSCustomObject][Ordered]@{DisplayName = $Team.DisplayNameTeamId = $Team.IdCreated = Get-Date $CreatedDate -format 'dd-MMM-yyyy HH:mm''Age in Days' = $AgeInDaysVisibility = $Team.VisibilityDescription = $Team.DescriptionOwners = $OwnersDisplayNamesSiteURL = $WebURLFilesUploaded = $FilesUploaded.CountFilesModified = $FilesModified.Count'Last active date' = $LastActiveDate'Active Users' = $ActiveUsers'Active Channels' = $ActiveChannels'Channel Messages' = $ChannelMessagesReactions = $Reactions'Meetings Organized' = $MeetingsOrganized'Post Messages' = $PostMessages'Reply Messages' = $ReplyMessages'Urgent Messages' = $UrgentMessagesMentions = $MentionsMembers = $TeamSettings.Summary.MembersCount'Owner count' = $TeamSettings.Summary.OwnersCountGuests = $TeamSettings.Summary.GuestsCount'Active Shared Channels' = $ActiveSharedChannels'Count of Activities' = $CountOfTeamActivities}$TeamReport.Add($TeamReportLine)}$SelectedTeamReport = [System.Collections.Generic.List[Object]]::new()$SelectedTeamReport = $TeamReport | Where-Object {$_.'Count of Activities' -le 100} | Select-Object DisplayName, Created, 'Age in Days', 'Last active date', Owners, 'Count of Activities', SiteURL[array]$MsgAttachments = $null$OutputFile = "C:\Temp\InactiveTeams.csv"$TeamReport | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8$ConvertedContent = [Convert]::ToBase64String([IO.File]::ReadAllBytes($OutputFile))$FileName = [System.IO.Path]::GetFileName($OutputFile)$AttachmentDetails = @{"@odata.type" = "#microsoft.graph.fileAttachment"Name = $FileNameContentBytes = $ConvertedContent}$MsgAttachments += $AttachmentDetails# Define some variables used to construct the HTML content in the message body# HTML header with styles$HtmlHead="<html><style>BODY{font-family: Arial; font-size: 10pt;}H1{font-size: 22px;}H2{font-size: 18px; padding-top: 10px;}H3{font-size: 16px; padding-top: 8px;}H4{font-size: 8px; padding-top: 4px;}</style>"$HtmlBody = $null$HtmlBody = $HtmlBody + "<body> <h1>Check for potentially inactive teams.</h1><p></p>"$HtmlBody = $HtmlBody + ($SelectedTeamReport | Sort-Object DisplayName | ConvertTo-HTML -Fragment -As Table -PreContent "<h2>Administrative alert: Inactive Teams based on 30-day lookback</h2>")$HtmlBody = $HtmlBody + "<p>These teams are highlighted because of their lack of activity in Teams messaging and SharePoint Online. Please check to ensure that they are still needed.</p>"$HtmlBody = $HtmlBody + "<p><h4>Generated:</strong> $(Get-Date -Format 'dd-MMM-yyyy HH:mm')</h4></p>"$HtmlMsg = $HtmlHead + $HtmlBody + "<p></body>"$MsgSubject = "Potentially inactive Teams for review"$MsgFrom = 'Customer.Services@office365itpros.com'$MsgAddressee = "tony.redmond@office365itpros.com"$ToRecipients = @{}$ToRecipients.Add("emailAddress", @{"address"=$MsgAddressee} )[array]$MsgTo = $ToRecipients# Construct the message body$MsgBody = @{}$MsgBody.Add('Content', "$($HtmlMsg)")$MsgBody.Add('ContentType','html')$Message = @{}$Message.Add('subject', $MsgSubject)$Message.Add('toRecipients', $MsgTo)$Message.Add('body', $MsgBody)$Message.Add('attachments', $MsgAttachments)$Params = @{}$Params.Add('message', $Message)$Params.Add('saveToSentItems', $true)$Params.Add('isDeliveryReceiptRequested', $true)Send-MgUserMail -UserId $MsgFrom -BodyParameter $ParamsWrite-Output ("Email with inactive Teams listing sent to {0}" -f $MsgAddressee)
Parameters
ParameterDefaultNotes
-TenantId""Microsoft Entra tenant ID for app-only Graph authentication.-AppId""Application (client) ID for the app registration used to connect.-LookbackDays30Number of days of inactivity used to classify a team as inactive.-StartDate(Get-Date).AddDays(-30)Start of the reporting window.-EndDate(Get-Date).AddDays(1)End of the reporting window.Attribution
Author
Office365itpros