Stale Device Cleanup Alert Notification

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.

Notification
483 views135 downloads6 runbooks deployedVersion 1.4By Ugur Koc
View on GitHub

New to runbook deployment? Follow the step-by-step guide from the Deploy to Azure button to the first scheduled run, including granting Graph permissions to the managed identity.

// QUALITY CHECKS

Validation status

Quality checks

All checks pass
  • ParsePass
  • LintPass
  • MetadataPass
  • Runbook-readyPass
  • Module depsPass

Tests run automatically on every change. What does each check mean?

// REQUIRED PERMISSIONS

Microsoft Graph scopes

DeviceManagementManagedDevices.Read.All

Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.

Mail.Send

Allows the app to send mail as the signed-in user, without a signed-in user.

Running this as an Azure Automation runbook? These scopes must be granted to the account's managed identity, which has no portal UI. The deployment walkthrough shows the exact Cloud Shell commands.

// CHANGELOG

Version history

  1. Entry · 01

    1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors

  2. Entry · 02

    1.3 - Azure Automation now records script progress, outcomes, and summaries in job history

  3. Entry · 03

    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

  4. Entry · 04

    1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing)

  5. Entry · 05

    1.0 - Initial release

// CODE

Source

stale-device-cleanup-alert.ps1
<#
.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 "<recipient-address>" -SenderUPN "<sender-upn>"
    Identifies devices that haven't checked in for 90+ days and sends alerts to <recipient-address>

.EXAMPLE
    .\stale-device-cleanup-alert.ps1 -StaleAfterDays 60 -EmailRecipients "<recipient-address>,<security-recipient-address>" -SenderUPN "<sender-upn>"
    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 = @"
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; }
        .header { background-color: #0078d4; color: white; padding: 15px; border-radius: 5px; }
        .summary { background-color: #f8f9fa; padding: 15px; margin: 15px 0; border-radius: 5px; border-left: 4px solid #0078d4; }
        .stale { background-color: #fdf2f2; border-left: 4px solid #dc3545; padding: 10px; margin: 10px 0; }
        .warning { background-color: #fffbf0; border-left: 4px solid #ffc107; padding: 10px; margin: 10px 0; }
        .recommendations { background-color: #e8f5e8; border-left: 4px solid #28a745; padding: 10px; margin: 10px 0; }
        .device-item { margin: 5px 0; padding: 8px; background-color: white; border-radius: 3px; font-size: 14px; }
        .platform-summary { margin: 10px 0; padding: 8px; background-color: #f0f0f0; border-radius: 3px; }
        .footer { margin-top: 30px; font-size: 12px; color: #666; }
        .stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 15px 0; }
        .stat-card { background-color: white; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #ddd; }
        h2 { color: #333; }
        h3 { color: #555; margin-top: 20px; }
        .status-icon { font-size: 16px; margin-right: 5px; }
        table { width: 100%; border-collapse: collapse; margin: 10px 0; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background-color: #f2f2f2; }
        .center { text-align: center; }
    </style>
</head>
<body>
    <div class="header">
        <h1>🧹 Stale Device Cleanup Alert</h1>
        <p>Devices inactive for $StaleThreshold+ days requiring attention</p>
    </div>

    <div class="summary">
        <h2>Device Inventory Summary</h2>
        <div class="stats-grid">
            <div class="stat-card">
                <h3 style="margin: 0; color: #28a745;">$ActiveCount</h3>
                <p style="margin: 5px 0;">Active Devices</p>
            </div>
            <div class="stat-card">
                <h3 style="margin: 0; color: #ffc107;">$WarningCount</h3>
                <p style="margin: 5px 0;">Warning Devices</p>
            </div>
            <div class="stat-card">
                <h3 style="margin: 0; color: #dc3545;">$StaleCount</h3>
                <p style="margin: 5px 0;">Stale Devices</p>
            </div>
            <div class="stat-card">
                <h3 style="margin: 0; color: #0078d4;">$TotalDevices</h3>
                <p style="margin: 5px 0;">Total Devices</p>
            </div>
        </div>
    </div>
"@

    if ($StaleCount -gt 0) {
        $Body += @"
    <div class="stale">
        <h3><span class="status-icon">🚨</span>Stale Devices - Cleanup Recommended ($StaleCount devices)</h3>

        <h4>Platform Breakdown:</h4>
"@
        foreach ($Platform in $PlatformSummary) {
            $Body += @"
        <div class="platform-summary">
            <strong>$($Platform.Name):</strong> $($Platform.Count) devices
        </div>
"@
        }

        $Body += @"
        <h4>Device Details:</h4>
        <table>
            <tr>
                <th>Device Name</th>
                <th>Platform</th>
                <th>User</th>
                <th>Last Check-in</th>
                <th>Days Inactive</th>
                <th>Compliance</th>
            </tr>
"@

        foreach ($Device in ($StaleDevices | Sort-Object DaysSinceLastSync -Descending | Select-Object -First 20)) {
            $Body += @"
            <tr>
                <td>$([System.Net.WebUtility]::HtmlEncode($Device.DeviceName))</td>
                <td>$($Device.Platform)</td>
                <td>$([System.Net.WebUtility]::HtmlEncode($Device.UserDisplayName))</td>
                <td>$($Device.LastSyncDateTime.ToString('yyyy-MM-dd'))</td>
                <td class="center">$($Device.DaysSinceLastSync)</td>
                <td>$($Device.ComplianceState)</td>
            </tr>
"@
        }

        if ($StaleDevices.Count -gt 20) {
            $Body += @"
            <tr>
                <td colspan="6" class="center"><em>... and $($StaleDevices.Count - 20) more devices</em></td>
            </tr>
"@
        }

        $Body += "</table></div>"
    }

    if ($WarningCount -gt 0) {
        $Body += @"
    <div class="warning">
        <h3><span class="status-icon">⚠️</span>Warning Devices - Monitor Closely ($WarningCount devices)</h3>
        <p>These devices are approaching the staleness threshold and should be monitored:</p>

        <table>
            <tr>
                <th>Device Name</th>
                <th>Platform</th>
                <th>User</th>
                <th>Last Check-in</th>
                <th>Days Inactive</th>
            </tr>
"@

        foreach ($Device in ($WarningDevices | Sort-Object DaysSinceLastSync -Descending | Select-Object -First 10)) {
            $Body += @"
            <tr>
                <td>$([System.Net.WebUtility]::HtmlEncode($Device.DeviceName))</td>
                <td>$($Device.Platform)</td>
                <td>$([System.Net.WebUtility]::HtmlEncode($Device.UserDisplayName))</td>
                <td>$($Device.LastSyncDateTime.ToString('yyyy-MM-dd'))</td>
                <td class="center">$($Device.DaysSinceLastSync)</td>
            </tr>
"@
        }

        if ($WarningDevices.Count -gt 10) {
            $Body += @"
            <tr>
                <td colspan="5" class="center"><em>... and $($WarningDevices.Count - 10) more devices</em></td>
            </tr>
"@
        }

        $Body += "</table></div>"
    }

    $Body += @"
    <div class="recommendations">
        <h3><span class="status-icon">💡</span>Cleanup Recommendations</h3>
        <h4>Before Taking Action:</h4>
        <ul>
            <li><strong>Verify Device Status:</strong> Contact device users to confirm devices are truly inactive</li>
            <li><strong>Check Recent Activity:</strong> Review device logs for any recent activity not reflected in Intune</li>
            <li><strong>Consider Seasonal Patterns:</strong> Account for vacation periods, temporary leave, or project cycles</li>
            <li><strong>Backup Important Data:</strong> Ensure any critical data is backed up before device removal</li>
        </ul>

        <h4>Cleanup Actions:</h4>
        <ul>
            <li><strong>Retire Devices:</strong> For devices confirmed as no longer in use</li>
            <li><strong>Remove from Intune:</strong> Clean up device records and free up licenses</li>
            <li><strong>Update Asset Inventory:</strong> Reflect changes in your asset management system</li>
            <li><strong>Review Policies:</strong> Update device-based policies and group memberships</li>
        </ul>

        <h4>License Impact:</h4>
        <p><strong>Potential License Savings:</strong> Removing $StaleCount stale devices could free up Intune licenses for new device enrollments.</p>
    </div>

    <div class="footer">
        <p><strong>Next Steps:</strong></p>
        <ol>
            <li>Review the stale device list and verify device status with users</li>
            <li>Use the existing stale device cleanup script in your automation repository</li>
            <li>Monitor device activity for the warning devices over the next few weeks</li>
            <li>Update your device management policies based on usage patterns</li>
        </ol>
        <p><em>This is an automated notification from your Intune monitoring system.</em></p>
        <p><em>Generated on: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')</em></p>
    </div>
</body>
</html>
"@

    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
}

// NOTES

Author 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

// RELATED

Picked by shared tags, category, and script type — nothing magic, just metadata overlap.

  1. App Deployment Failure Alert Notification

    This script is designed to run as a scheduled Azure Automation runbook that monitors application deployment status in Microsoft Intune and identifies applications with high failure rates or deployment issues. It tracks deployment success rates, identifies required applications with failures, and sends email notifications to administrators with detailed deployment reports. The script helps maintain application availability and user productivity by proactively alerting on deployment failures and providing actionable insights for remediation.

    Notification
  2. Apple Token Expiration Alert Notification

    This script is designed to run as a scheduled Azure Automation runbook that monitors the expiration status of Apple Device Enrollment Program (DEP) tokens and Apple Push Notification Service (APNS) certificates in Microsoft Intune. When tokens or certificates are approaching expiration or have expired, the script sends email notifications to specified recipients using Microsoft Graph Mail API.

    Notification
  3. Device Compliance Drift Alert Notification

    This script is designed to run as a scheduled Azure Automation runbook that monitors device compliance status in Microsoft Intune and identifies devices that have fallen out of compliance. It tracks compliance trends, identifies patterns of compliance deterioration, and sends email notifications to administrators with detailed compliance reports. The script helps maintain security posture by proactively alerting on compliance drift and providing actionable insights for remediation.

    Notification