Simplify user tasks like bulk creation, updates, password resets, deletions, license checks & more — all from one place.
🚀 Launch ToolkitPrivate Teams are a powerful way to lock down collaboration, but without visibility, they can also become a governance blind spot. Over time, organizations may accumulate dozens or even hundreds of private Teams created for projects, sensitive discussions, or restricted departments. Without a clear overview of which private Teams exist and who owns them, admins can struggle to ensure proper oversight, security, and lifecycle management.
This script solves that by pulling a full list of all private Microsoft Teams, including owner details, saving it into a CSV, and emailing it directly to the administrator.
# ===== Private Teams -> CSV -> Email to Admin =====
# Requires: Microsoft.Graph module
# Scopes: Group.Read.All, User.Read.All, Mail.Send
# --- Email variables ---
$FromUser = "admin@contoso.com" # Sender (must have mailbox)
$To = "it-ops@contoso.com" # Recipient
$Subject = "Private Microsoft Teams report"
$CsvOutDir = "$env:TEMP"
# --- Connect to Microsoft Graph ---
Import-Module Microsoft.Graph -ErrorAction Stop
Connect-MgGraph -Scopes "Group.Read.All","User.Read.All","Mail.Send"
# --- Get Teams-enabled groups (can't filter by 'visibility' server-side) ---
$teams = Get-MgGroup -All -Filter "resourceProvisioningOptions/Any(x:x eq 'Team')" `
-Property "id,displayName,description,visibility,createdDateTime,mailNickname"
# --- Filter PRIVATE Teams client-side ---
$privateTeams = $teams | Where-Object { $_.Visibility -eq "Private" }
# --- Build rows with owner details ---
$rows = foreach ($t in $privateTeams) {
$ownerObjs = Get-MgGroupOwner -GroupId $t.Id -All -ErrorAction SilentlyContinue
$ownerNames = @()
$ownerUpns = @()
foreach ($o in $ownerObjs) {
try {
# Resolve to user to get clean DisplayName + UPN
$u = Get-MgUser -UserId $o.Id -Property DisplayName,UserPrincipalName -ErrorAction Stop
$ownerNames += $u.DisplayName
$ownerUpns += $u.UserPrincipalName
} catch {
# Fallback if non-user or missing fields
$dn = $o.AdditionalProperties['displayName']
$upn = $o.AdditionalProperties['userPrincipalName']
if ($dn) { $ownerNames += $dn }
if ($upn) { $ownerUpns += $upn }
}
}
[PSCustomObject]@{
TeamId = $t.Id
TeamName = $t.DisplayName
Description = $t.Description
Visibility = $t.Visibility
CreatedDate = $t.CreatedDateTime
OwnerNames = ($ownerNames -join "; ")
OwnerUPNs = ($ownerUpns -join "; ")
}
}
# --- Export to CSV ---
if (-not (Test-Path -Path $CsvOutDir)) { New-Item -ItemType Directory -Path $CsvOutDir | Out-Null }
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
$csvPath = Join-Path $CsvOutDir ("Private_Teams_{0}.csv" -f $ts)
$rows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
# --- Prepare HTML Body ---
$summaryHtml = @"
<html>
<body style='font-family:Segoe UI,Arial,sans-serif'>
<h3>Private Microsoft Teams Report</h3>
<p>Total private Teams: <b>$($rows.Count)</b></p>
<p>The full list (with owners) is attached as a CSV.</p>
</body>
</html>
"@
# --- Prepare Attachment ---
$fileBytes = [System.IO.File]::ReadAllBytes($csvPath)
$base64Content = [System.Convert]::ToBase64String($fileBytes)
$csvFileName = [System.IO.Path]::GetFileName($csvPath)
$attachment = @{
"@odata.type" = "#microsoft.graph.fileAttachment"
name = $csvFileName
contentBytes = $base64Content
contentType = "text/csv"
}
# --- Prepare and Send Email ---
$mail = @{
message = @{
subject = "${Subject}"
body = @{
contentType = "HTML"
content = $summaryHtml
}
toRecipients = @(@{ emailAddress = @{ address = $To } })
attachments = @($attachment)
}
saveToSentItems = $true
}
Send-MgUserMail -UserId $FromUser -BodyParameter $mail
Write-Host "Done. CSV saved at: $csvPath" -ForegroundColor Green
The script loads the Microsoft Graph module and connects using the required scopes:
It queries all Microsoft 365 groups where resourceProvisioningOptions contains "Team", ensuring only Teams-backed groups are returned.
Since the Graph API doesn’t support filtering on visibility directly, the script uses PowerShell’s Where-Object to pick Teams where Visibility -eq "Private".
For each private Team, the script retrieves owner objects and resolves them into clean DisplayName and UserPrincipalName. Multiple owners are concatenated into semicolon-separated lists.
The details are exported to a timestamped CSV file, which is attached to an HTML summary email and sent to the administrator..
Error | Cause | Solution |
---|---|---|
Authorization_RequestDenied | Missing Graph scopes or consent | Reconnect with Group.Read.All, User.Read.All, Mail.Send and ensure consent is granted. |
Get-MgGroup not recognized | Microsoft Graph module not installed | Install with Install-Module Microsoft.Graph -Scope CurrentUser. |
Owners missing from CSV | Owners are service principals / non-user objects | Script falls back to AdditionalProperties; enrich only where possible. |
Email not sent | $FromUser not mailbox-enabled | Use a licensed mailbox-enabled account for $FromUser. |
Empty results | No private Teams exist or visibility not populated | Remove filter temporarily to confirm Teams are retrieved. |
Private Teams give organizations secure spaces for sensitive collaboration, but without oversight, they can easily become invisible to administrators. This script provides a complete inventory of all private Teams, along with their owners, packaged neatly into a CSV and emailed directly to IT. By scheduling and enhancing the script, you can strengthen governance, improve compliance, and maintain control of your Microsoft 365 environment.
© m365corner.com. All Rights Reserved. Design by HTML Codex