<# .TITLE Stale Device Cleanup Alert Notification .SYNOPSIS Automated runbook to monitor stale devices in Intune and send email alerts for cleanup recommendations. .DESCRIPTION This script is designed to run as a scheduled Azure Automation runbook that monitors devices in Microsoft Intune that haven't checked in for a specified number of days. It identifies stale devices across different platforms (Windows, iOS, Android, macOS) and sends email notifications to administrators with cleanup recommendations. The script helps maintain a clean device inventory and optimize licensing costs by identifying devices that may no longer be in use. .TAGS Notification .MINROLE Intune Administrator .PERMISSIONS DeviceManagementManagedDevices.Read.All,Mail.Send .AUTHOR Ugur Koc .VERSION 1.4 .CHANGELOG 1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors 1.3 - Azure Automation now records script progress, outcomes, and summaries in job history 1.2 - Mail now sends from a mandatory SenderUPN mailbox via /users/{upn}/sendMail (app-only managed identity cannot use /me); send failures now fail the run; device listing uses select and device fields are HTML-encoded in the email; pagination helper preserves single-item arrays 1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing) 1.0 - Initial release .LASTUPDATE 2026-07-30 .EXECUTION RunbookOnly .OUTPUT Email .SCHEDULE Weekly .CATEGORY Notification .EXAMPLE .\stale-device-cleanup-alert.ps1 -StaleAfterDays 90 -EmailRecipients "" -SenderUPN "" Identifies devices that haven't checked in for 90+ days and sends alerts to .EXAMPLE .\stale-device-cleanup-alert.ps1 -StaleAfterDays 60 -EmailRecipients "," -SenderUPN "" Identifies devices that haven't checked in for 60+ days and sends alerts to multiple recipients .NOTES - Requires Microsoft.Graph.Authentication module - For Azure Automation, configure Managed Identity with required permissions - Uses Microsoft Graph Mail API for email notifications only, sent from the SenderUPN mailbox - The Automation account's managed identity requires Mail.Send permission for the SenderUPN mailbox - Recommended to run as scheduled runbook (weekly or monthly) - Consider your organization's device usage patterns when setting staleness threshold - Review cleanup recommendations before taking action on devices - Critical for maintaining accurate device inventory and license optimization - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows #> [CmdletBinding()] param( [Parameter(Mandatory = $true, HelpMessage = "Number of days since last check-in to consider a device stale")] [ValidateRange(7, 365)] [int]$StaleAfterDays, [Parameter(Mandatory = $true, HelpMessage = "Comma-separated list of email addresses to send notifications")] [ValidateNotNullOrEmpty()] [string]$EmailRecipients, [Parameter(Mandatory = $true, HelpMessage = "Mailbox UPN used as the notification sender (managed identity needs Mail.Send permission for it)")] [ValidateNotNullOrEmpty()] [string]$SenderUPN, [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ForceModuleInstall ) # Normalize the local module-install override for Azure Automation parameter binding. $forceModuleInstallRaw = [string]$ForceModuleInstall Remove-Variable -Name ForceModuleInstall if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) { $ForceModuleInstall = $false } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) { $ForceModuleInstall = $true } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) { $ForceModuleInstall = $false } else { throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false." } # ============================================================================ # ENVIRONMENT DETECTION AND SETUP # ============================================================================ function Initialize-RequiredModule { param( [string[]]$ModuleNames, [bool]$IsAutomationEnvironment, [bool]$ForceInstall = $false ) foreach ($ModuleName in $ModuleNames) { Write-Verbose "Checking module: $ModuleName" $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1 if (-not $module) { if ($IsAutomationEnvironment) { $errorMessage = @" Module '$ModuleName' is not available in this Azure Automation Account. To resolve this issue: 1. Go to Azure Portal 2. Navigate to your Automation Account 3. Go to 'Modules' > 'Browse Gallery' 4. Search for '$ModuleName' 5. Click 'Import' and wait for installation to complete Required modules for this script: - Microsoft.Graph.Authentication "@ throw $errorMessage } else { Write-Information "Module '$ModuleName' not found. Attempting to install..." -InformationAction Continue if (-not $ForceInstall) { $response = Read-Host "Install module '$ModuleName'? (Y/N)" if ($response -notmatch '^[Yy]') { throw "Module '$ModuleName' is required but installation was declined." } } try { $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" } Write-Information "Installing '$ModuleName' in scope '$scope'..." -InformationAction Continue Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue } catch { throw "Failed to install module '$ModuleName': $($_.Exception.Message)" } } } try { Write-Verbose "Importing module: $ModuleName" Import-Module -Name $ModuleName -Force -ErrorAction Stop Write-Verbose "✓ Successfully imported '$ModuleName'" } catch { throw "Failed to import module '$ModuleName': $($_.Exception.Message)" } } } # Detect execution environment if ($PSPrivateMetadata.JobId.Guid) { Write-Output "Running inside Azure Automation Runbook" $IsAzureAutomation = $true } else { Write-Output "Running locally in IDE or terminal" $IsAzureAutomation = $false } # Initialize required modules $RequiredModuleList = @( "Microsoft.Graph.Authentication" ) # MgGraphCommunity gives WAM-free interactive sign-in for local runs if (-not $IsAzureAutomation) { $RequiredModuleList += "MgGraphCommunity" } try { Initialize-RequiredModule -ModuleNames $RequiredModuleList -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall Write-Verbose "✓ All required modules are available" } catch { Write-Error "Module initialization failed: $_" exit 1 } # ============================================================================ # AUTHENTICATION # ============================================================================ try { if ($IsAzureAutomation) { Write-Output "Connecting to Microsoft Graph using Managed Identity..." Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph using Managed Identity" } else { Write-Output "Connecting to Microsoft Graph with interactive authentication..." $Scopes = @( "DeviceManagementManagedDevices.Read.All", "Mail.Send" ) Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph" } } catch { Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)" exit 1 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-MgGraphAllPage { param( [Parameter(Mandatory = $true)] [string]$Uri, [int]$DelayMs = 100 ) $AllResults = @() $NextLink = $Uri $RequestCount = 0 do { try { if ($RequestCount -gt 0) { Start-Sleep -Milliseconds $DelayMs } $Response = Invoke-MgGraphRequest -Uri $NextLink -Method GET $RequestCount++ if ($null -ne $Response.value) { $AllResults += $Response.value } else { $AllResults += $Response } $NextLink = $Response.'@odata.nextLink' } catch { if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") { Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue Start-Sleep -Seconds 60 continue } throw "Error fetching data from $NextLink : $($_.Exception.Message)" } } while ($NextLink) # Comma keeps PowerShell from unrolling a single-item array on return return , $AllResults } function Get-DevicePlatform { param([string]$OperatingSystem) switch -Regex ($OperatingSystem) { "^Windows" { return "Windows" } "^iOS" { return "iOS" } "^iPadOS" { return "iPadOS" } "^Android" { return "Android" } "^macOS" { return "macOS" } "^ChromeOS" { return "ChromeOS" } default { return "Other" } } } function Get-DeviceStatus { param( [datetime]$LastSyncDateTime, [int]$StaleThreshold ) $DaysSinceLastSync = ((Get-Date) - $LastSyncDateTime).Days if ($DaysSinceLastSync -gt $StaleThreshold) { return "Stale" } elseif ($DaysSinceLastSync -gt ($StaleThreshold * 0.8)) { return "Warning" } else { return "Active" } } function Format-TimeSpan { param([datetime]$Date) $TimeSpan = (Get-Date) - $Date if ($TimeSpan.TotalDays -lt 1) { return "Today" } elseif ($TimeSpan.TotalDays -lt 2) { return "1 day ago" } else { return "$([math]::Round($TimeSpan.TotalDays)) days ago" } } function Send-EmailNotification { [CmdletBinding(SupportsShouldProcess)] param( [string[]]$Recipients, [string]$Subject, [string]$Body ) try { foreach ($Recipient in $Recipients) { $Message = @{ subject = $Subject body = @{ contentType = "HTML" content = $Body } toRecipients = @( @{ emailAddress = @{ address = $Recipient } } ) } $RequestBody = @{ message = $Message } | ConvertTo-Json -Depth 10 if ($PSCmdlet.ShouldProcess($Recipient, "Send Email Notification")) { $Uri = "https://graph.microsoft.com/beta/users/$SenderUPN/sendMail" Invoke-MgGraphRequest -Uri $Uri -Method POST -Body $RequestBody -ContentType "application/json" | Out-Null Write-Information "✓ Email sent to $Recipient via Microsoft Graph" -InformationAction Continue } } return $true } catch { Write-Error "Failed to send email notification: $($_.Exception.Message)" return $false } } # Function creates email content, does not change system state function New-EmailBody { param( [array]$AllDevices, [array]$StaleDevices, [array]$WarningDevices, [int]$StaleThreshold ) $PlatformSummary = $StaleDevices | Group-Object Platform | Sort-Object Name $TotalDevices = $AllDevices.Count $StaleCount = $StaleDevices.Count $WarningCount = $WarningDevices.Count $ActiveCount = $TotalDevices - $StaleCount - $WarningCount $Body = @"

🧹 Stale Device Cleanup Alert

Devices inactive for $StaleThreshold+ days requiring attention

Device Inventory Summary

$ActiveCount

Active Devices

$WarningCount

Warning Devices

$StaleCount

Stale Devices

$TotalDevices

Total Devices

"@ if ($StaleCount -gt 0) { $Body += @"

🚨Stale Devices - Cleanup Recommended ($StaleCount devices)

Platform Breakdown:

"@ foreach ($Platform in $PlatformSummary) { $Body += @"
$($Platform.Name): $($Platform.Count) devices
"@ } $Body += @"

Device Details:

"@ foreach ($Device in ($StaleDevices | Sort-Object DaysSinceLastSync -Descending | Select-Object -First 20)) { $Body += @" "@ } if ($StaleDevices.Count -gt 20) { $Body += @" "@ } $Body += "
Device Name Platform User Last Check-in Days Inactive Compliance
$([System.Net.WebUtility]::HtmlEncode($Device.DeviceName)) $($Device.Platform) $([System.Net.WebUtility]::HtmlEncode($Device.UserDisplayName)) $($Device.LastSyncDateTime.ToString('yyyy-MM-dd')) $($Device.DaysSinceLastSync) $($Device.ComplianceState)
... and $($StaleDevices.Count - 20) more devices
" } if ($WarningCount -gt 0) { $Body += @"

⚠️Warning Devices - Monitor Closely ($WarningCount devices)

These devices are approaching the staleness threshold and should be monitored:

"@ foreach ($Device in ($WarningDevices | Sort-Object DaysSinceLastSync -Descending | Select-Object -First 10)) { $Body += @" "@ } if ($WarningDevices.Count -gt 10) { $Body += @" "@ } $Body += "
Device Name Platform User Last Check-in Days Inactive
$([System.Net.WebUtility]::HtmlEncode($Device.DeviceName)) $($Device.Platform) $([System.Net.WebUtility]::HtmlEncode($Device.UserDisplayName)) $($Device.LastSyncDateTime.ToString('yyyy-MM-dd')) $($Device.DaysSinceLastSync)
... and $($WarningDevices.Count - 10) more devices
" } $Body += @"

💡Cleanup Recommendations

Before Taking Action:

  • Verify Device Status: Contact device users to confirm devices are truly inactive
  • Check Recent Activity: Review device logs for any recent activity not reflected in Intune
  • Consider Seasonal Patterns: Account for vacation periods, temporary leave, or project cycles
  • Backup Important Data: Ensure any critical data is backed up before device removal

Cleanup Actions:

  • Retire Devices: For devices confirmed as no longer in use
  • Remove from Intune: Clean up device records and free up licenses
  • Update Asset Inventory: Reflect changes in your asset management system
  • Review Policies: Update device-based policies and group memberships

License Impact:

Potential License Savings: Removing $StaleCount stale devices could free up Intune licenses for new device enrollments.

"@ return $Body } # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting stale device cleanup monitoring..." # Parse email recipients $EmailRecipientList = $EmailRecipients -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } if ($EmailRecipientList.Count -eq 0) { throw "No valid email recipients provided" } Write-Output "Email recipients: $($EmailRecipientList -join ', ')" Write-Output "Stale device threshold: $StaleAfterDays days" # Initialize results arrays $AllDevices = @() $StaleDevices = @() $WarningDevices = @() $NotificationFailed = $false # Calculate cutoff date for stale devices $StaleThresholdDate = (Get-Date).AddDays(-$StaleAfterDays) $WarningThresholdDate = (Get-Date).AddDays( - ($StaleAfterDays * 0.8)) Write-Output "Stale threshold date: $($StaleThresholdDate.ToString('yyyy-MM-dd'))" Write-Output "Warning threshold date: $($WarningThresholdDate.ToString('yyyy-MM-dd'))" # ======================================================================== # GET ALL MANAGED DEVICES # ======================================================================== Write-Output "Retrieving all managed devices from Intune..." try { $DevicesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,osVersion,userDisplayName,userPrincipalName,lastSyncDateTime,enrolledDateTime,complianceState,managementState,serialNumber,model,manufacturer" $Devices = Get-MgGraphAllPage -Uri $DevicesUri Write-Output "Found $($Devices.Count) managed devices" foreach ($Device in $Devices) { try { # Skip devices without essential information if (-not $Device.lastSyncDateTime -or -not $Device.id) { Write-Verbose "Skipping device with missing essential data (ID: $($Device.id))" continue } $LastSyncDateTime = [datetime]$Device.lastSyncDateTime $EnrolledDateTime = if ($Device.enrolledDateTime) { [datetime]$Device.enrolledDateTime } else { $null } $DaysSinceLastSync = ((Get-Date) - $LastSyncDateTime).Days $Platform = Get-DevicePlatform -OperatingSystem $Device.operatingSystem $DeviceStatus = Get-DeviceStatus -LastSyncDateTime $LastSyncDateTime -StaleThreshold $StaleAfterDays $DeviceInfo = [PSCustomObject]@{ DeviceId = $Device.id DeviceName = if ($Device.deviceName) { $Device.deviceName } else { "Unknown" } Platform = $Platform OperatingSystem = $Device.operatingSystem OSVersion = $Device.osVersion UserDisplayName = if ($Device.userDisplayName) { $Device.userDisplayName } else { "Unassigned" } UserPrincipalName = if ($Device.userPrincipalName) { $Device.userPrincipalName } else { "N/A" } LastSyncDateTime = $LastSyncDateTime EnrolledDateTime = $EnrolledDateTime DaysSinceLastSync = $DaysSinceLastSync LastSyncStatus = Format-TimeSpan -Date $LastSyncDateTime ComplianceState = if ($Device.complianceState) { $Device.complianceState } else { "Unknown" } ManagementState = if ($Device.managementState) { $Device.managementState } else { "Unknown" } DeviceStatus = $DeviceStatus SerialNumber = $Device.serialNumber Model = $Device.model Manufacturer = $Device.manufacturer } $AllDevices += $DeviceInfo # Categorize devices based on status if ($DeviceStatus -eq "Stale") { $StaleDevices += $DeviceInfo } elseif ($DeviceStatus -eq "Warning") { $WarningDevices += $DeviceInfo } } catch { Write-Verbose "Error processing device (ID: $($Device.id)): $($_.Exception.Message)" continue } } Write-Output "✓ Processed $($AllDevices.Count) devices successfully" Write-Output " • Active devices: $(($AllDevices | Where-Object { $_.DeviceStatus -eq 'Active' }).Count)" Write-Output " • Warning devices: $($WarningDevices.Count)" Write-Output " • Stale devices: $($StaleDevices.Count)" } catch { Write-Error "Failed to retrieve managed devices: $($_.Exception.Message)" exit 1 } # ======================================================================== # SEND NOTIFICATIONS IF STALE DEVICES FOUND # ======================================================================== if ($StaleDevices.Count -gt 0 -or $WarningDevices.Count -gt 0) { Write-Output "Preparing email notification for device cleanup..." $Subject = if ($StaleDevices.Count -gt 0) { "[Intune Alert] CLEANUP REQUIRED: $($StaleDevices.Count) Stale Device(s) Found" } else { "[Intune Alert] WARNING: $($WarningDevices.Count) Device(s) Approaching Staleness" } $EmailBody = New-EmailBody -AllDevices $AllDevices -StaleDevices $StaleDevices -WarningDevices $WarningDevices -StaleThreshold $StaleAfterDays $EmailSent = Send-EmailNotification -Recipients $EmailRecipientList -Subject $Subject -Body $EmailBody if ($EmailSent) { Write-Output "✓ Email notification sent to $($EmailRecipientList.Count) recipients" } else { Write-Warning "Email notification could not be delivered to all recipients" $NotificationFailed = $true } } else { Write-Output "✓ No stale or warning devices found. All devices are actively checking in." } # ======================================================================== # DISPLAY SUMMARY # ======================================================================== Write-Output "`n🧹 STALE DEVICE CLEANUP MONITORING SUMMARY" Write-Output "===========================================" Write-Output "Total Managed Devices: $($AllDevices.Count)" Write-Output "Stale Threshold: $StaleAfterDays days" Write-Output "" $ActiveCount = ($AllDevices | Where-Object { $_.DeviceStatus -eq "Active" }).Count Write-Output "Device Status Breakdown:" Write-Output " • Active: $ActiveCount" Write-Output " • Warning: $($WarningDevices.Count)" Write-Output " • Stale: $($StaleDevices.Count)" Write-Output "" if ($StaleDevices.Count -gt 0) { Write-Output "Platform Breakdown (Stale Devices):" $PlatformGroups = $StaleDevices | Group-Object Platform | Sort-Object Name foreach ($Group in $PlatformGroups) { Write-Output " • $($Group.Name): $($Group.Count) devices" } Write-Output "" } if ($StaleDevices.Count -gt 0) { Write-Output "Top 5 Oldest Stale Devices:" $TopStaleDevices = $StaleDevices | Sort-Object DaysSinceLastSync -Descending | Select-Object -First 5 foreach ($Device in $TopStaleDevices) { Write-Output " 🔸 $($Device.DeviceName) ($($Device.Platform)) - $($Device.DaysSinceLastSync) days" } } Write-Output "`n✓ Stale device cleanup monitoring completed successfully" } catch { Write-Error "Script failed: $($_.Exception.Message)" exit 1 } finally { try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Output "Disconnected from Microsoft Graph" } catch { # Silently ignore disconnect errors as they're not critical Write-Verbose "Disconnect error (ignored): $($_.Exception.Message)" } } # ============================================================================ # SCRIPT SUMMARY # ============================================================================ Write-Output " ======================================== Script Execution Summary ======================================== Script: Stale Device Cleanup Alert Total Devices Analyzed: $($AllDevices.Count) Stale Devices Found: $($StaleDevices.Count) Warning Devices Found: $($WarningDevices.Count) Email Recipients: $($EmailRecipientList.Count) Staleness Threshold: $StaleAfterDays days Status: Completed ======================================== " # Fail the run if notification delivery failed if ($NotificationFailed) { exit 1 } # Copia sin cambios de https://github.com/ugurkocde/IntuneAutomation (commit 797bb2e). # MIT License # # Copyright (c) 2025 Ugur Koc | Microsoft MVP # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE.