Public/Get-MailSendAppAudit.ps1

<#
.SYNOPSIS
    Audits your apps with Mail.Send permissions and their usage patterns.

.DESCRIPTION
    Finds app registrations in your tenant with Mail.Send/Mail.Send.All permissions,
    then checks audit logs to see which mailboxes they're sending from.
    Excludes Microsoft first-party apps. Helps determine if apps can be scoped
    using Application Access Policies.

.PARAMETER Days
    Number of days to look back in audit logs. Default 30.

.EXAMPLE
    Get-MailSendAppAudit

    Finds all apps with Mail.Send and checks their send activity.

.EXAMPLE
    Get-MailSendAppAudit -Days 90

    Checks 90 days of audit history.

.EXAMPLE
    Get-MailSendAppAudit | Where-Object { $_.CanScope } | Export-Csv apps-to-scope.csv

    Exports apps that can be scoped to CSV.

.NOTES
    Author: Dennis Kämpe / Kent Agent
    Created: 2026-03-12
    Requires: Microsoft.Graph PowerShell module, ExchangeOnlineManagement module
    Permissions: Application.Read.All (Graph), View-Only Audit Logs (Compliance)
    
    Connect first:
    - Connect-MgGraph -Scopes "Application.Read.All"
    - Connect-IPPSSession

.LINK
    https://github.com/kentagent-ai/EntraIDSecurityScripts
#>

function Get-MailSendAppAudit {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 365)]
        [int]$Days = 30
    )

    begin {
        # Verify Graph connection
        $context = Get-MgContext
        if (-not $context) {
            throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'Application.Read.All'"
        }

        # Microsoft tenant ID (to exclude their apps)
        $microsoftTenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47'
    }

    process {
        Write-Host "`n=== Mail.Send Application Audit ===" -ForegroundColor Cyan
        Write-Host "Finding YOUR apps with Mail.Send permissions..." -ForegroundColor Yellow
        Write-Host ""

        # Get Microsoft Graph service principal
        $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property Id, AppRoles -ErrorAction Stop

        # Find Mail.Send role IDs
        $mailSendRoleIds = New-Object System.Collections.ArrayList
        foreach ($role in $graphSp.AppRoles) {
            if ($role.Value -match '^Mail\.(Send|ReadWrite)') {
                [void]$mailSendRoleIds.Add($role.Id)
                Write-Host " Looking for: $($role.Value)" -ForegroundColor Gray
            }
        }

        Write-Host ""

        # Get all service principals
        $allSps = Get-MgServicePrincipal -All -Property Id, DisplayName, AppId, AppOwnerOrganizationId

        # Find apps with Mail.Send permissions (excluding Microsoft apps)
        $appsWithMailSend = New-Object System.Collections.ArrayList

        foreach ($sp in $allSps) {
            # Skip Microsoft first-party apps
            if ($sp.AppOwnerOrganizationId -eq $microsoftTenantId) {
                continue
            }

            # Check if this app has Mail.Send permissions
            $assignments = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue
            
            if ($null -eq $assignments) { continue }

            $hasMailSend = $false
            $grantedPerms = New-Object System.Collections.ArrayList

            foreach ($assignment in $assignments) {
                if ($assignment.ResourceId -eq $graphSp.Id -and $mailSendRoleIds -contains $assignment.AppRoleId) {
                    $hasMailSend = $true
                    $permName = ($graphSp.AppRoles | Where-Object { $_.Id -eq $assignment.AppRoleId }).Value
                    if ($permName -and $grantedPerms -notcontains $permName) {
                        [void]$grantedPerms.Add($permName)
                    }
                }
            }

            if ($hasMailSend) {
                $appInfo = New-Object PSObject -Property @{
                    DisplayName = $sp.DisplayName
                    AppId       = $sp.AppId
                    SpId        = $sp.Id
                    Permissions = ($grantedPerms -join ', ')
                }
                [void]$appsWithMailSend.Add($appInfo)
                Write-Host " [FOUND] $($sp.DisplayName)" -ForegroundColor Green
                Write-Host " Permissions: $($grantedPerms -join ', ')" -ForegroundColor Gray
            }
        }

        Write-Host ""
        Write-Host "Apps with Mail.Send: $($appsWithMailSend.Count)" -ForegroundColor Cyan

        if ($appsWithMailSend.Count -eq 0) {
            Write-Host "[OK] No custom apps have Mail.Send permissions!" -ForegroundColor Green
            return
        }

        # Check if audit log is available
        Write-Host ""
        Write-Host "Checking audit logs for send activity..." -ForegroundColor Yellow

        $cmdletExists = Get-Command -Name Search-UnifiedAuditLog -ErrorAction SilentlyContinue
        $auditAvailable = $null -ne $cmdletExists

        if (-not $auditAvailable) {
            Write-Host "[!] Audit log not available (run Connect-IPPSSession)" -ForegroundColor Yellow
            Write-Host " Showing apps with permissions only - no usage data" -ForegroundColor Gray
        }

        $results = New-Object System.Collections.ArrayList
        $startDate = (Get-Date).AddDays(-$Days)
        $endDate = Get-Date

        foreach ($app in $appsWithMailSend) {
            Write-Host ""
            Write-Host "----------------------------------------" -ForegroundColor Gray
            Write-Host "App: $($app.DisplayName)" -ForegroundColor Cyan
            Write-Host "AppId: $($app.AppId)" -ForegroundColor Gray
            Write-Host "Permissions: $($app.Permissions)" -ForegroundColor White

            $sendCount = 0
            $mailboxes = New-Object System.Collections.ArrayList
            $lastUsed = $null

            if ($auditAvailable) {
                try {
                    # Search audit log for this specific app
                    $sends = Search-UnifiedAuditLog -StartDate $startDate -EndDate $endDate `
                        -RecordType ExchangeItem -Operations Send, SendAs, SendOnBehalf `
                        -FreeText $app.AppId -ResultSize 5000 -ErrorAction SilentlyContinue

                    if ($null -ne $sends -and $sends.Count -gt 0) {
                        foreach ($record in $sends) {
                            try {
                                $data = $record.AuditData | ConvertFrom-Json
                                
                                # Verify it's actually this app
                                if ($data.ClientAppId -eq $app.AppId) {
                                    $sendCount++
                                    
                                    if ($null -ne $data.MailboxOwnerUPN -and $mailboxes -notcontains $data.MailboxOwnerUPN) {
                                        [void]$mailboxes.Add($data.MailboxOwnerUPN)
                                    }
                                    
                                    if ($null -eq $lastUsed -or $data.CreationTime -gt $lastUsed) {
                                        $lastUsed = $data.CreationTime
                                    }
                                }
                            }
                            catch { }
                        }
                    }
                }
                catch {
                    Write-Host " [!] Audit search error: $_" -ForegroundColor Yellow
                }
            }

            # Display results
            if ($sendCount -gt 0) {
                Write-Host "Sends: $sendCount (last $Days days)" -ForegroundColor White
                Write-Host "Mailboxes: $($mailboxes.Count)" -ForegroundColor White
                Write-Host "Last used: $lastUsed" -ForegroundColor Gray

                if ($mailboxes.Count -le 15) {
                    foreach ($mb in $mailboxes) {
                        Write-Host " - $mb" -ForegroundColor Gray
                    }
                }

                # Recommendation
                if ($mailboxes.Count -le 5) {
                    Write-Host ">> SCOPE IT - only $($mailboxes.Count) mailbox(es)!" -ForegroundColor Green
                }
                elseif ($mailboxes.Count -le 20) {
                    Write-Host ">> SCOPE IT - create security group for $($mailboxes.Count) mailboxes" -ForegroundColor Yellow
                }
                else {
                    Write-Host ">> REVIEW - sends from $($mailboxes.Count) mailboxes" -ForegroundColor Red
                }
            }
            else {
                Write-Host "Sends: 0 (no activity in last $Days days)" -ForegroundColor Yellow
                Write-Host ">> REVIEW - has permission but no recent usage" -ForegroundColor Yellow
            }

            $resultObj = New-Object PSObject -Property @{
                AppName      = $app.DisplayName
                AppId        = $app.AppId
                Permissions  = $app.Permissions
                SendCount    = $sendCount
                MailboxCount = $mailboxes.Count
                Mailboxes    = ($mailboxes -join '; ')
                LastUsed     = $lastUsed
                CanScope     = ($mailboxes.Count -gt 0 -and $mailboxes.Count -le 20)
            }
            [void]$results.Add($resultObj)
        }
    }

    end {
        # Summary
        Write-Host ""
        Write-Host "========================================" -ForegroundColor Yellow
        Write-Host "SUMMARY" -ForegroundColor Cyan
        Write-Host "========================================" -ForegroundColor Yellow
        Write-Host ""

        $scopeable = @($results | Where-Object { $_.CanScope -eq $true })
        $unused = @($results | Where-Object { $_.SendCount -eq 0 })
        $needsReview = @($results | Where-Object { $_.MailboxCount -gt 20 })

        Write-Host "Total apps with Mail.Send: $($results.Count)" -ForegroundColor White
        Write-Host "Can be scoped: $($scopeable.Count)" -ForegroundColor Green
        Write-Host "No recent activity: $($unused.Count)" -ForegroundColor Yellow
        Write-Host "Needs review (>20 mbx): $($needsReview.Count)" -ForegroundColor Red

        if ($scopeable.Count -gt 0) {
            Write-Host ""
            Write-Host "========================================" -ForegroundColor Yellow
            Write-Host "APPLICATION ACCESS POLICY COMMANDS" -ForegroundColor Cyan
            Write-Host "========================================" -ForegroundColor Yellow

            foreach ($r in $scopeable) {
                $groupName = $r.AppName -replace '\s', '' -replace '[^a-zA-Z0-9]', ''
                Write-Host ""
                Write-Host "# $($r.AppName) - $($r.MailboxCount) mailbox(es)" -ForegroundColor Gray
                Write-Host "New-ApplicationAccessPolicy ``" -ForegroundColor Cyan
                Write-Host " -AppId '$($r.AppId)' ``" -ForegroundColor Cyan
                Write-Host " -PolicyScopeGroupId 'MailSend-$groupName@yourdomain.com' ``" -ForegroundColor Cyan
                Write-Host " -AccessRight RestrictAccess ``" -ForegroundColor Cyan
                Write-Host " -Description 'Scope $($r.AppName) to specific mailboxes'" -ForegroundColor Cyan
            }
        }

        if ($unused.Count -gt 0) {
            Write-Host ""
            Write-Host "========================================" -ForegroundColor Yellow
            Write-Host "UNUSED APPS - CONSIDER REMOVING PERMISSION" -ForegroundColor Red
            Write-Host "========================================" -ForegroundColor Yellow

            foreach ($r in $unused) {
                Write-Host ""
                Write-Host "# $($r.AppName) - no sends in $Days days" -ForegroundColor Gray
                Write-Host "# Review if Mail.Send is still needed" -ForegroundColor Yellow
                Write-Host "# Azure Portal > App Registrations > $($r.AppName) > API Permissions" -ForegroundColor Gray
            }
        }

        Write-Host ""
        return $results
    }
}

Export-ModuleMember -Function Get-MailSendAppAudit -ErrorAction SilentlyContinue