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.
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
DeviceManagementApps.Read.AllAllows the app to read the properties, group assignments and status of apps, app configurations and app protection policies managed by Microsoft Intune, without a signed-in user.
DeviceManagementManagedDevices.Read.AllAllows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.
Mail.SendAllows 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
Entry · 01
1.5 - Download the app installation report payload before parsing its JSON content and add MaxApps for bounded runbook validation
Entry · 02
1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
Entry · 03
1.3 - Azure Automation now records script progress, outcomes, and summaries in job history
Entry · 04
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; per-app report and assignment calls are paced and the app listing uses select; pagination helper preserves single-item arrays
Entry · 05
1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); app install status now read via deviceManagement/reports (mobileApps deviceStatuses was retired from the Graph service)
Entry · 06
1.0 - Initial release
// CODE
Source
<#
.TITLE
App Deployment Failure Alert Notification
.SYNOPSIS
Automated runbook to monitor application deployment failures in Intune and send email alerts for deployment issues.
.DESCRIPTION
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.
.TAGS
Notification
.MINROLE
Intune Administrator
.PERMISSIONS
DeviceManagementApps.Read.All,DeviceManagementManagedDevices.Read.All,Mail.Send
.AUTHOR
Ugur Koc
.VERSION
1.5
.CHANGELOG
1.5 - Download the app installation report payload before parsing its JSON content and add MaxApps for bounded runbook validation
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; per-app report and assignment calls are paced and the app listing uses select; pagination helper preserves single-item arrays
1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); app install status now read via deviceManagement/reports (mobileApps deviceStatuses was retired from the Graph service)
1.0 - Initial release
.LASTUPDATE
2026-07-30
.EXECUTION
RunbookOnly
.OUTPUT
Email
.SCHEDULE
Daily
.CATEGORY
Notification
.EXAMPLE
.\app-deployment-failure-alert.ps1 -FailureThresholdPercent 20 -EmailRecipients "<recipient-address>" -SenderUPN "<sender-upn>"
Alerts when app deployment failure rate exceeds 20% and sends notifications to <recipient-address>
.EXAMPLE
.\app-deployment-failure-alert.ps1 -FailureThresholdPercent 15 -EmailRecipients "<recipient-address>,<app-support-recipient-address>" -SenderUPN "<sender-upn>"
Alerts when app deployment failure rate exceeds 15% and sends notifications 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 (daily)
- Consider your organization's application deployment requirements when setting threshold
- Review application packages and deployment settings based on findings
- Critical for maintaining application availability and user productivity
- 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 = "Maximum acceptable failure percentage for app deployments")]
[ValidateRange(5, 50)]
[int]$FailureThresholdPercent,
[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 = "Maximum number of applications to evaluate; 0 evaluates all applications")]
[ValidateRange(0, 10000)]
[int]$MaxApps = 0,
[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 = @(
"DeviceManagementApps.Read.All",
"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
}
# Retrieves app installation status rows from the reports endpoint with paging
# (the mobileApps deviceStatuses endpoint was retired from the Graph service)
function Get-AppInstallStatusReportRow {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[int]$PageSize = 500,
[int]$DelayMs = 100
)
$ReportUri = "https://graph.microsoft.com/beta/deviceManagement/reports/retrieveDeviceAppInstallationStatusReport"
$AllRows = @()
$Skip = 0
# MaxValue sentinel keeps the loop alive if the very first page hits a 429
# (a zero sentinel would end the do/while before any retry could happen)
$TotalRows = [int]::MaxValue
do {
try {
if ($Skip -gt 0) {
Start-Sleep -Milliseconds $DelayMs
}
$Body = @{
filter = "(ApplicationId eq '$AppId')"
top = $PageSize
skip = $Skip
select = @("DeviceId", "DeviceName", "UserPrincipalName", "Platform", "AppVersion", "InstallState", "InstallStateDetail", "ErrorCode", "LastModifiedDateTime")
} | ConvertTo-Json -Depth 5
$ReportPath = Join-Path ([System.IO.Path]::GetTempPath()) "IntuneAutomation-AppDeploymentAlert-$([guid]::NewGuid().ToString('N')).json"
try {
Invoke-MgGraphRequest -Uri $ReportUri -Method POST -Body $Body -ContentType "application/json" -OutputFilePath $ReportPath | Out-Null
$Response = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json -AsHashtable
}
finally {
if (Test-Path -LiteralPath $ReportPath) {
Remove-Item -LiteralPath $ReportPath -Force
}
}
# The report returns columnar JSON - map Schema columns to row indexes,
# column order is declared by Schema, not by the request
$ColumnIndex = @{}
for ($i = 0; $i -lt $Response['Schema'].Count; $i++) {
$ColumnIndex[$Response['Schema'][$i].Column] = $i
}
foreach ($Row in $Response['Values']) {
$AllRows += [PSCustomObject]@{
DeviceId = $Row[$ColumnIndex['DeviceId']]
DeviceName = $Row[$ColumnIndex['DeviceName']]
UserPrincipalName = $Row[$ColumnIndex['UserPrincipalName']]
Platform = $Row[$ColumnIndex['Platform']]
AppVersion = $Row[$ColumnIndex['AppVersion']]
InstallState = $Row[$ColumnIndex['InstallState']]
InstallStateDetail = $Row[$ColumnIndex['InstallStateDetail']]
ErrorCode = $Row[$ColumnIndex['ErrorCode']]
LastModifiedDateTime = $Row[$ColumnIndex['LastModifiedDateTime']]
}
}
$TotalRows = $Response['TotalRowCount']
$Skip += $PageSize
}
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 install status report for app $AppId : $($_.Exception.Message)"
}
} while ($Skip -lt $TotalRows)
# Comma preserves a single-row result as an array so .Count is correct
return , $AllRows
}
function Get-AppType {
param([string]$ODataType)
switch ($ODataType) {
"#microsoft.graph.win32LobApp" { return "Win32 App" }
"#microsoft.graph.microsoftStoreForBusinessApp" { return "Store App" }
"#microsoft.graph.webApp" { return "Web App" }
"#microsoft.graph.officeSuiteApp" { return "Office Suite" }
"#microsoft.graph.winGetApp" { return "WinGet App" }
"#microsoft.graph.iosLobApp" { return "iOS LOB App" }
"#microsoft.graph.iosStoreApp" { return "iOS Store App" }
"#microsoft.graph.androidManagedStoreApp" { return "Android Store App" }
"#microsoft.graph.androidLobApp" { return "Android LOB App" }
"#microsoft.graph.macOSLobApp" { return "macOS LOB App" }
"#microsoft.graph.macOSOfficeSuiteApp" { return "macOS Office Suite" }
default { return "Other" }
}
}
function Get-InstallIntentDisplay {
param([string]$Intent)
switch ($Intent) {
"required" { return "Required" }
"available" { return "Available" }
"uninstall" { return "Uninstall" }
"availableWithoutEnrollment" { return "Available (No Enrollment)" }
default { return $Intent }
}
}
function Get-InstallStateDisplay {
param([string]$InstallState)
switch ($InstallState) {
"installed" { return "Installed" }
"failed" { return "Failed" }
"notInstalled" { return "Not Installed" }
"uninstallFailed" { return "Uninstall Failed" }
"pendingInstall" { return "Pending Install" }
"unknown" { return "Unknown" }
"notApplicable" { return "Not Applicable" }
default { return $InstallState }
}
}
function Get-DeploymentSeverity {
param([string]$InstallState, [string]$Intent)
if ($Intent -eq "required") {
switch ($InstallState) {
"failed" { return "Critical" }
"uninstallFailed" { return "Critical" }
"pendingInstall" { return "Warning" }
"notInstalled" { return "Warning" }
"installed" { return "Success" }
default { return "Info" }
}
}
else {
switch ($InstallState) {
"failed" { return "Warning" }
"uninstallFailed" { return "Warning" }
"installed" { return "Success" }
default { return "Info" }
}
}
}
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 New-EmailBody {
param(
[array]$AllApps,
[array]$FailedApps,
[array]$RequiredFailedApps,
[hashtable]$AppStats,
[int]$FailureThreshold
)
$TotalApps = $AllApps.Count
$AppsWithFailures = ($AllApps | Where-Object { $_.FailureCount -gt 0 }).Count
$OverallFailureRate = if ($AppStats.TotalDeployments -gt 0) {
[math]::Round(($AppStats.TotalFailures / $AppStats.TotalDeployments) * 100, 1)
}
else { 0 }
$AppTypeSummary = $FailedApps | Group-Object AppType | Sort-Object Count -Descending
$PlatformSummary = $FailedApps | Group-Object TargetPlatform | Sort-Object Count -Descending
$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; }
.critical { 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; }
.success { background-color: #e8f5e8; border-left: 4px solid #28a745; padding: 10px; margin: 10px 0; }
.info { background-color: #e7f3ff; border-left: 4px solid #17a2b8; padding: 10px; margin: 10px 0; }
.app-item { margin: 5px 0; padding: 8px; background-color: white; border-radius: 3px; font-size: 14px; }
.category-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(3, 1fr); gap: 10px; margin: 15px 0; }
.stat-card { background-color: white; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #ddd; }
.failure-meter { width: 100%; height: 20px; background-color: #e0e0e0; border-radius: 10px; margin: 10px 0; position: relative; }
.failure-fill { height: 100%; border-radius: 10px; transition: width 0.3s ease; }
.failure-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: bold; color: white; text-shadow: 1px 1px 1px rgba(0,0,0,0.5); }
.progress-bar { width: 100%; height: 8px; background-color: #e0e0e0; border-radius: 4px; margin: 5px 0; }
.progress-fill { height: 100%; border-radius: 4px; }
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; }
.right { text-align: right; }
</style>
</head>
<body>
<div class="header">
<h1>📱 App Deployment Failure Alert</h1>
<p>Failure threshold: $FailureThreshold% | Current overall rate: $OverallFailureRate%</p>
</div>
<div class="summary">
<h2>Application Deployment Overview</h2>
<div class="failure-meter">
<div class="failure-fill" style="width: $(if ($OverallFailureRate -gt 100) { 100 } else { $OverallFailureRate })%; background-color: $(if ($OverallFailureRate -le $FailureThreshold) { '#28a745' } elseif ($OverallFailureRate -le ($FailureThreshold * 1.5)) { '#ffc107' } else { '#dc3545' });"></div>
<div class="failure-text">$OverallFailureRate%</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3 style="margin: 0; color: #0078d4;">$TotalApps</h3>
<p style="margin: 5px 0;">Total Applications</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #dc3545;">$AppsWithFailures</h3>
<p style="margin: 5px 0;">Apps with Failures</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #ffc107;">$($RequiredFailedApps.Count)</h3>
<p style="margin: 5px 0;">Required Apps Failing</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3 style="margin: 0; color: #17a2b8;">$($AppStats.TotalDeployments)</h3>
<p style="margin: 5px 0;">Total Deployments</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #28a745;">$($AppStats.TotalSuccesses)</h3>
<p style="margin: 5px 0;">Successful Installs</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #dc3545;">$($AppStats.TotalFailures)</h3>
<p style="margin: 5px 0;">Failed Installs</p>
</div>
</div>
</div>
"@
if ($RequiredFailedApps.Count -gt 0) {
$Body += @"
<div class="critical">
<h3><span class="status-icon">🚨</span>Required Applications with Failures - Critical Impact ($($RequiredFailedApps.Count) apps)</h3>
<p>These required applications are failing to install and may impact user productivity:</p>
<table>
<tr>
<th>Application Name</th>
<th>App Type</th>
<th>Platform</th>
<th>Total Deployments</th>
<th>Failures</th>
<th>Failure Rate</th>
<th>Success Rate</th>
</tr>
"@
foreach ($App in ($RequiredFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 15)) {
$SuccessRate = [math]::Round((($App.TotalDeployments - $App.FailureCount) / $App.TotalDeployments) * 100, 1)
$Body += @"
<tr>
<td>$($App.AppName)</td>
<td>$($App.AppType)</td>
<td>$($App.TargetPlatform)</td>
<td class="center">$($App.TotalDeployments)</td>
<td class="center">$($App.FailureCount)</td>
<td class="center" style="color: #dc3545; font-weight: bold;">$($App.FailureRate)%</td>
<td class="center">
<div class="progress-bar">
<div class="progress-fill" style="width: $SuccessRate%; background-color: $(if ($SuccessRate -ge 90) { '#28a745' } elseif ($SuccessRate -ge 70) { '#ffc107' } else { '#dc3545' });"></div>
</div>
$SuccessRate%
</td>
</tr>
"@
}
if ($RequiredFailedApps.Count -gt 15) {
$Body += @"
<tr>
<td colspan="7" class="center"><em>... and $($RequiredFailedApps.Count - 15) more required applications with failures</em></td>
</tr>
"@
}
$Body += "</table></div>"
}
if ($FailedApps.Count -gt $RequiredFailedApps.Count) {
$AvailableFailedApps = $FailedApps | Where-Object { $_.InstallIntent -ne "Required" }
$Body += @"
<div class="warning">
<h3><span class="status-icon">⚠️</span>Available Applications with Failures ($($AvailableFailedApps.Count) apps)</h3>
<p>These available applications have deployment failures that may affect user experience:</p>
<table>
<tr>
<th>Application Name</th>
<th>App Type</th>
<th>Install Intent</th>
<th>Platform</th>
<th>Failures</th>
<th>Failure Rate</th>
</tr>
"@
foreach ($App in ($AvailableFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 10)) {
$Body += @"
<tr>
<td>$($App.AppName)</td>
<td>$($App.AppType)</td>
<td>$($App.InstallIntentDisplay)</td>
<td>$($App.TargetPlatform)</td>
<td class="center">$($App.FailureCount)</td>
<td class="center" style="color: #ffc107; font-weight: bold;">$($App.FailureRate)%</td>
</tr>
"@
}
if ($AvailableFailedApps.Count -gt 10) {
$Body += @"
<tr>
<td colspan="6" class="center"><em>... and $($AvailableFailedApps.Count - 10) more available applications with failures</em></td>
</tr>
"@
}
$Body += "</table></div>"
}
if ($AppTypeSummary.Count -gt 0) {
$Body += @"
<div class="info">
<h3><span class="status-icon">📊</span>Failure Analysis by App Type</h3>
"@
foreach ($AppType in $AppTypeSummary) {
$Body += @"
<div class="category-summary">
<strong>$($AppType.Name):</strong> $($AppType.Count) applications with failures
</div>
"@
}
$Body += "</div>"
}
if ($PlatformSummary.Count -gt 0) {
$Body += @"
<div class="info">
<h3><span class="status-icon">🖥️</span>Failure Analysis by Platform</h3>
"@
foreach ($Platform in $PlatformSummary) {
$Body += @"
<div class="category-summary">
<strong>$($Platform.Name):</strong> $($Platform.Count) applications with failures
</div>
"@
}
$Body += "</div>"
}
$Body += @"
<div class="$(if ($OverallFailureRate -le $FailureThreshold) { 'success' } else { 'critical' })">
<h3><span class="status-icon">💡</span>Deployment Improvement Recommendations</h3>
<h4>Immediate Actions:</h4>
<ul>
<li><strong>Prioritize Required Apps:</strong> Focus on fixing required applications first as they have the highest business impact</li>
<li><strong>Review App Packages:</strong> Check application packages for corruption or compatibility issues</li>
<li><strong>Validate Dependencies:</strong> Ensure all application dependencies are properly installed</li>
<li><strong>Check Target Requirements:</strong> Verify device requirements match application specifications</li>
</ul>
<h4>Technical Investigation:</h4>
<ul>
<li><strong>Review Install Logs:</strong> Examine detailed installation logs for root cause analysis</li>
<li><strong>Test Deployment Groups:</strong> Validate deployments with smaller test groups first</li>
<li><strong>Network Connectivity:</strong> Ensure devices have reliable connectivity for large app downloads</li>
<li><strong>Storage Space:</strong> Verify target devices have sufficient storage for installations</li>
</ul>
<h4>Process Improvements:</h4>
<ul>
<li><strong>Staging Deployments:</strong> Implement phased rollouts for better failure detection</li>
<li><strong>Monitoring Enhancement:</strong> Set up proactive monitoring for deployment status</li>
<li><strong>User Communication:</strong> Inform users about application issues and expected resolution times</li>
<li><strong>Rollback Plans:</strong> Prepare rollback procedures for problematic deployments</li>
</ul>
</div>
<div class="footer">
<p><strong>Next Steps:</strong></p>
<ol>
<li>Investigate required applications with highest failure rates first</li>
<li>Use the existing application reports to get detailed deployment status</li>
<li>Review application packages and deployment settings for problematic apps</li>
<li>Consider implementing staged deployments for better failure management</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 app deployment failure 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 "Failure threshold: $FailureThresholdPercent%"
# Initialize results arrays
$AllApps = @()
$FailedApps = @()
$RequiredFailedApps = @()
$NotificationFailed = $false
$AppProcessingErrors = 0
# Initialize statistics
$AppStats = @{
TotalDeployments = 0
TotalSuccesses = 0
TotalFailures = 0
TotalPending = 0
}
# ========================================================================
# GET ALL MOBILE APPLICATIONS
# ========================================================================
Write-Output "Retrieving mobile applications..."
try {
$AppsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime"
$Apps = Get-MgGraphAllPage -Uri $AppsUri
if ($MaxApps -gt 0) {
$Apps = @($Apps | Select-Object -First $MaxApps)
}
Write-Output "Found $($Apps.Count) applications"
foreach ($App in $Apps) {
try {
# Skip apps without essential information
if (-not $App.id -or -not $App.displayName) {
Write-Verbose "Skipping app with missing essential data"
continue
}
Write-Verbose "Processing app: $($App.displayName)"
# Get app install status for this application via the reports endpoint
$InstallStatuses = Get-AppInstallStatusReportRow -AppId $App.id
# Get app assignments to determine install intent
$AppAssignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($App.id)/assignments"
$Assignments = Get-MgGraphAllPage -Uri $AppAssignmentsUri
# Determine primary install intent (prioritize required)
$InstallIntent = "available"
$TargetPlatform = "Unknown"
if ($Assignments) {
foreach ($Assignment in $Assignments) {
if ($Assignment.intent -eq "required") {
$InstallIntent = "required"
break
}
elseif ($Assignment.intent -eq "available" -and $InstallIntent -ne "required") {
$InstallIntent = "available"
}
}
}
# Determine target platform from app type
switch -Regex ($App.'@odata.type') {
"win32|office" { $TargetPlatform = "Windows" }
"ios" { $TargetPlatform = "iOS" }
"android" { $TargetPlatform = "Android" }
"macOS" { $TargetPlatform = "macOS" }
"web" { $TargetPlatform = "Web" }
default { $TargetPlatform = "Cross-Platform" }
}
# Calculate deployment statistics
# InstallState values per the resultantAppState enum (Microsoft Graph beta):
# 1 installed, 2 failed, 3 notInstalled, 4 uninstallFailed, 5 pendingInstall
$TotalDeployments = $InstallStatuses.Count
$SuccessfulInstalls = ($InstallStatuses | Where-Object { $_.InstallState -eq 1 }).Count
$FailedInstalls = ($InstallStatuses | Where-Object { $_.InstallState -in @(2, 4) }).Count
$PendingInstalls = ($InstallStatuses | Where-Object { $_.InstallState -eq 5 }).Count
$FailureRate = if ($TotalDeployments -gt 0) {
[math]::Round(($FailedInstalls / $TotalDeployments) * 100, 1)
}
else { 0 }
$AppType = Get-AppType -ODataType $App.'@odata.type'
$InstallIntentDisplay = Get-InstallIntentDisplay -Intent $InstallIntent
$AppInfo = [PSCustomObject]@{
AppId = $App.id
AppName = $App.displayName
AppType = $AppType
TargetPlatform = $TargetPlatform
InstallIntent = $InstallIntent
InstallIntentDisplay = $InstallIntentDisplay
Publisher = $App.publisher
TotalDeployments = $TotalDeployments
SuccessfulInstalls = $SuccessfulInstalls
FailedInstalls = $FailedInstalls
FailureCount = $FailedInstalls
PendingInstalls = $PendingInstalls
FailureRate = $FailureRate
CreatedDateTime = if ($App.createdDateTime) { [datetime]$App.createdDateTime } else { $null }
LastModifiedDateTime = if ($App.lastModifiedDateTime) { [datetime]$App.lastModifiedDateTime } else { $null }
}
# Update overall statistics
$AppStats.TotalDeployments += $TotalDeployments
$AppStats.TotalSuccesses += $SuccessfulInstalls
$AppStats.TotalFailures += $FailedInstalls
$AppStats.TotalPending += $PendingInstalls
$AllApps += $AppInfo
# Categorize apps with failures
if ($FailedInstalls -gt 0 -and $FailureRate -gt $FailureThresholdPercent) {
$FailedApps += $AppInfo
if ($InstallIntent -eq "required") {
$RequiredFailedApps += $AppInfo
}
}
# Pace per-app report and assignment calls to avoid throttling
Start-Sleep -Milliseconds 200
}
catch {
$AppProcessingErrors++
Write-Warning "Error processing app '$($App.displayName)' (ID: $($App.id)): $($_.Exception.Message)"
continue
}
}
if ($AppProcessingErrors -gt 0) {
throw "The report could not fully evaluate $AppProcessingErrors application(s). No healthy result will be reported from partial data."
}
Write-Output "✓ Processed $($AllApps.Count) applications successfully"
Write-Output " • Applications with failures: $($FailedApps.Count)"
Write-Output " • Required apps with failures: $($RequiredFailedApps.Count)"
}
catch {
Write-Error "Failed to retrieve mobile applications: $($_.Exception.Message)"
exit 1
}
# ========================================================================
# CALCULATE OVERALL STATISTICS
# ========================================================================
$OverallFailureRate = if ($AppStats.TotalDeployments -gt 0) {
[math]::Round(($AppStats.TotalFailures / $AppStats.TotalDeployments) * 100, 1)
}
else { 0 }
Write-Output " • Total deployments: $($AppStats.TotalDeployments)"
Write-Output " • Successful installs: $($AppStats.TotalSuccesses)"
Write-Output " • Failed installs: $($AppStats.TotalFailures)"
Write-Output " • Overall failure rate: $OverallFailureRate%"
# ========================================================================
# SEND NOTIFICATIONS IF DEPLOYMENT FAILURES DETECTED
# ========================================================================
$RequiresNotification = ($OverallFailureRate -gt $FailureThresholdPercent) -or
($RequiredFailedApps.Count -gt 0) -or
($FailedApps.Count -gt 0)
if ($RequiresNotification) {
Write-Output "Preparing email notification for app deployment failures..."
$Subject = if ($RequiredFailedApps.Count -gt 0) {
"[Intune Alert] CRITICAL: $($RequiredFailedApps.Count) Required App(s) Failing to Deploy"
}
elseif ($OverallFailureRate -gt $FailureThresholdPercent) {
"[Intune Alert] APP DEPLOYMENT ISSUES: $OverallFailureRate% Failure Rate (Threshold: $FailureThresholdPercent%)"
}
else {
"[Intune Alert] APPLICATION MONITORING: $($FailedApps.Count) App(s) with Deployment Failures"
}
$EmailBody = New-EmailBody -AllApps $AllApps -FailedApps $FailedApps -RequiredFailedApps $RequiredFailedApps -AppStats $AppStats -FailureThreshold $FailureThresholdPercent
$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 significant app deployment failures detected. All applications are deploying successfully."
}
# ========================================================================
# DISPLAY SUMMARY
# ========================================================================
Write-Output "`n📱 APP DEPLOYMENT FAILURE MONITORING SUMMARY"
Write-Output "============================================="
Write-Output "Total Applications: $($AllApps.Count)"
Write-Output "Failure Threshold: $FailureThresholdPercent%"
Write-Output "Overall Failure Rate: $OverallFailureRate%"
Write-Output ""
Write-Output "Deployment Statistics:"
Write-Output " • Total Deployments: $($AppStats.TotalDeployments)"
Write-Output " • Successful: $($AppStats.TotalSuccesses)"
Write-Output " • Failed: $($AppStats.TotalFailures)"
Write-Output " • Pending: $($AppStats.TotalPending)"
Write-Output ""
Write-Output "Applications with Issues:"
Write-Output " • Apps with failures: $($FailedApps.Count)"
Write-Output " • Required apps failing: $($RequiredFailedApps.Count)"
Write-Output ""
if ($RequiredFailedApps.Count -gt 0) {
Write-Output "Top Required Apps with Failures:"
$TopRequiredFailed = $RequiredFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 5
foreach ($App in $TopRequiredFailed) {
Write-Output " 🔸 $($App.AppName) ($($App.AppType)) - $($App.FailureRate)% failure rate"
}
Write-Output ""
}
$StatusIcon = if ($OverallFailureRate -le $FailureThresholdPercent -and $RequiredFailedApps.Count -eq 0) { "✅" } else { "⚠️" }
Write-Output "$StatusIcon Deployment Status: $(if ($OverallFailureRate -le $FailureThresholdPercent -and $RequiredFailedApps.Count -eq 0) { 'HEALTHY' } else { 'ISSUES DETECTED' })"
Write-Output "`n✓ App deployment failure 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: App Deployment Failure Alert
Total Applications Analyzed: $($AllApps.Count)
Overall Failure Rate: $OverallFailureRate%
Failure Threshold: $FailureThresholdPercent%
Total Deployments: $($AppStats.TotalDeployments)
Failed Deployments: $($AppStats.TotalFailures)
Apps with Failures: $($FailedApps.Count)
Required Apps Failing: $($RequiredFailedApps.Count)
Email Recipients: $($EmailRecipientList.Count)
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 (daily) - Consider your organization's application deployment requirements when setting threshold - Review application packages and deployment settings based on findings - Critical for maintaining application availability and user productivity - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
// RELATED
Scripts that travel together.
Picked by shared tags, category, and script type — nothing magic, just metadata overlap.
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.
NotificationDevice 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.
NotificationLicense Threshold Alert Notification
This script is designed to run as a scheduled Azure Automation runbook. It reads the tenant's subscribed SKUs, identifies the ones that include an Intune service plan, and compares consumed against purchased units. When utilization crosses the configured threshold (default 90 percent) or a SKU is suspended or in warning state, administrators get an email before new users fail to enroll for lack of a license. Non-Intune SKUs can be included with a switch for a full license overview.
Notification