This guide explains how to set up an Azure Automation Account to automatically identify Guest users who have not logged in for over 180 days, and send a summary report via a Shared Mailbox.
This solution operates entirely headlessly using a System-Assigned Managed Identity and the Microsoft Graph API.
Because you cannot assign Microsoft Graph Application permissions to a Managed Identity via the Azure Portal GUI, you must use PowerShell.
Open an administrative PowerShell console, ensure the Microsoft.Graph module is installed, and run the following script. Be sure to replace $AppName with the exact name of your Automation Account.
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All", "Application.Read.All"
$AppName = "Your-Automation-Account-Name"
$ManagedIdentity = Get-MgServicePrincipal -Filter "displayName eq '$AppName'"
$GraphApp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
# Required Graph API Permissions
$Roles = @(
"User.Read.All",
"AuditLog.Read.All",
"Directory.Read.All",
"GroupMember.Read.All",
"Mail.Send"
)
foreach ($Role in $Roles) {
$AppRole = $GraphApp.AppRoles | Where-Object { $_.Value -eq $Role }
New-MgServicePrincipalAppRoleAssignment -PrincipalId $ManagedIdentity.Id -ServicePrincipalId $ManagedIdentity.Id -ResourceId $GraphApp.Id -AppRoleId $AppRole.Id
Write-Host "Assigned $Role to $AppName"
}
In your Automation Account, go to Process Automation > Runbooks.
Click Create a runbook.
Name it (e.g., Get-StaleGuestAccounts), set the Runbook type to PowerShell, and set the Runtime version to 5.1 (or 7.2).
Paste the final script into the editor. (Remember to update the configuration variables at the top of the script with your specific Group ID and email addresses).
# 1. Configuration Variables
$TargetGroupId = "YOUR_GROUP_OBJECT_ID_HERE"
$GroupName = "Guest_Accounts" # Ensure this matches your actual group name
$SharedMailboxAddress = "YOUR_SHARED_MAILBOX@yourdomain.com"
$RecipientAddress = "recipient@yourdomain.com"
$180DaysAgo = (Get-Date).AddDays(-180)
# 2. Authenticate and get a Microsoft Graph Access Token
try {
Write-Output "Authenticating with Managed Identity..."
Connect-AzAccount -Identity | Out-Null
$AccessToken = (Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com").Token
}
catch {
Write-Error "Failed to authenticate. Ensure System-Assigned Managed Identity is enabled."
exit
}
$Headers = @{
"Authorization" = "Bearer $AccessToken"
"Content-Type" = "application/json"
}
$GraphApiVersion = "v1.0"
# 3. Fetch members of the target group (Paginated)
Write-Output "Fetching members of target group..."
$GroupMemberIds = @()
$GroupMembersUrl = "https://graph.microsoft.com/$GraphApiVersion/groups/$TargetGroupId/members?`$select=id"
do {
$GroupMembersResponse = Invoke-RestMethod -Method Get -Uri $GroupMembersUrl -Headers $Headers
if ($GroupMembersResponse.value) {
$GroupMemberIds += $GroupMembersResponse.value.id
}
$GroupMembersUrl = $GroupMembersResponse.'@odata.nextLink'
} while ($null -ne $GroupMembersUrl)
# 4. Query Microsoft Graph for Guest Users (Paginated)
Write-Output "Fetching Guest accounts..."
$Guests = @()
$GuestsUrl = "https://graph.microsoft.com/$GraphApiVersion/users?`$filter=userType eq 'Guest'&`$select=id,displayName,mail,userPrincipalName,createdDateTime,accountEnabled,signInActivity"
do {
$GuestsResponse = Invoke-RestMethod -Method Get -Uri $GuestsUrl -Headers $Headers
if ($GuestsResponse.value) {
$Guests += $GuestsResponse.value
}
$GuestsUrl = $GuestsResponse.'@odata.nextLink'
} while ($null -ne $GuestsUrl)
# 5. Process and Format the Data
Write-Output "Processing $($Guests.Count) guest data records..."
[array]$ProcessedGuests = foreach ($Guest in $Guests) {
$LastInteractive = $null
$LastNonInteractive = $null
$AbsoluteLastLogin = $null
if ($null -ne $Guest.signInActivity) {
if ($null -ne $Guest.signInActivity.lastSignInDateTime) {
$LastInteractive = [datetime]$Guest.signInActivity.lastSignInDateTime
}
if ($null -ne $Guest.signInActivity.lastNonInteractiveSignInDateTime) {
$LastNonInteractive = [datetime]$Guest.signInActivity.lastNonInteractiveSignInDateTime
}
}
if ($LastInteractive -and $LastNonInteractive) {
$AbsoluteLastLogin = if ($LastInteractive -gt $LastNonInteractive) { $LastInteractive } else { $LastNonInteractive }
} elseif ($LastInteractive) {
$AbsoluteLastLogin = $LastInteractive
} elseif ($LastNonInteractive) {
$AbsoluteLastLogin = $LastNonInteractive
}
# Evaluate our two specific conditions
$IsStale = if (($null -eq $AbsoluteLastLogin) -or ($AbsoluteLastLogin -lt $180DaysAgo)) { "Yes" } else { "No" }
$InGroup = if ($GroupMemberIds -contains $Guest.id) { "Yes" } else { "No" }
[PSCustomObject]@{
DisplayName = $Guest.displayName
Mail = if ([string]::IsNullOrWhiteSpace($Guest.mail)) { "N/A" } else { $Guest.mail }
UserPrincipalName = $Guest.userPrincipalName
Id = $Guest.id
AccountEnabled = $Guest.accountEnabled
CreatedDateTime = $Guest.createdDateTime
LastLogin = if ($AbsoluteLastLogin) { $AbsoluteLastLogin.ToString("yyyy-MM-dd HH:mm:ss") } else { "Never" }
"In_Target_Group" = $InGroup
"Inactive_180_Days" = $IsStale
SortDate = $AbsoluteLastLogin
}
}
# Sort descending by LastLogin
$ProcessedGuests = $ProcessedGuests | Sort-Object -Property SortDate -Descending
# Create the final array for the CSV
$FinalExport = $ProcessedGuests | Select-Object DisplayName, Mail, UserPrincipalName, Id, AccountEnabled, CreatedDateTime, LastLogin, "In_Target_Group", "Inactive_180_Days"
# 6. Generate CSV and Base64 Encode it for the Attachment (Contains ALL users)
Write-Output "Creating CSV attachment..."
$CsvString = ($FinalExport | ConvertTo-Csv -NoTypeInformation -Delimiter ";") -join "`r`n"
$CsvBytes = [System.Text.Encoding]::UTF8.GetBytes($CsvString)
$CsvBase64 = [Convert]::ToBase64String($CsvBytes)
# 7. FILTER: Identify accounts that are Stale AND Not in the group
[array]$ActionRequiredAccounts = $ProcessedGuests | Where-Object {
($_.Inactive_180_Days -eq "Yes") -and ($_."In_Target_Group" -eq "No")
}
# 8. Build the HTML Body (Contains ONLY Action Required Users)
Write-Output "Building HTML report..."
$TotalGuests = $ProcessedGuests.Count
$TotalActionRequired = $ActionRequiredAccounts.Count
$HtmlBody = @"
<h3>Guest Account Summary</h3>
<p><b>Total Guest Accounts in our Tenant:</b> $TotalGuests</p>
<p><i>The full dataset containing all guests is attached to this email as a CSV. You can filter the "Inactive_180_Days" column to quickly find stale accounts.</i></p>
<hr>
<h4>Action Required: Accounts inactive for 180+ days AND NOT in the "$GroupName" group ($TotalActionRequired found):</h4>
<table border="1" cellpadding="5" style="border-collapse: collapse;">
<tr style="background-color: #f2f2f2;">
<th>Name</th>
<th>User Principal Name</th>
<th>AccountEnabled</th>
<th>Last Login</th>
</tr>
"@
foreach ($Account in $ActionRequiredAccounts) {
$LoginText = if ($Account.LastLogin -eq "Never") { "<span style='color:red;'><b>Never</b></span>" } else { $Account.LastLogin }
$HtmlBody += "<tr><td>$($Account.DisplayName)</td><td>$($Account.UserPrincipalName)</td><td>$($Account.AccountEnabled)</td><td>$LoginText</td></tr>"
}
$HtmlBody += "</table>"
# 9. Send the Email via Graph API with Attachment
Write-Output "Sending email..."
$SendMailUrl = "https://graph.microsoft.com/$GraphApiVersion/users/$SharedMailboxAddress/sendMail"
$EmailPayload = @{
message = @{
subject = "Action Required: Guest Account 180-Day Inactivity Report"
body = @{
contentType = "HTML"
content = $HtmlBody
}
toRecipients = @(
@{
emailAddress = @{
address = $RecipientAddress
}
}
)
attachments = @(
@{
"@odata.type" = "#microsoft.graph.fileAttachment"
name = "GuestAccounts_Report.csv"
contentType = "text/csv"
contentBytes = $CsvBase64
}
)
}
saveToSentItems = "false"
} | ConvertTo-Json -Depth 10
Invoke-RestMethod -Method Post -Uri $SendMailUrl -Headers $Headers -Body $EmailPayload
Write-Output "Script completed successfully. Found $TotalActionRequired accounts requiring action."
Click Save, test it using the Test pane, and then click Publish.
Navigate to Shared Resources > Schedules and click Add a schedule (e.g., “Weekly on Mondays”).
Go back to your published Runbook, click Link to schedule, and attach your new schedule.