<# .SYNOPSIS Responde qué buzones no han tenido actividad del usuario en los últimos días. .DESCRIPTION Cruza los buzones de Exchange Online con el reporte de uso de Microsoft Graph (getMailboxUsageDetail), que cuenta enviar, leer y atender citas. No usa LastUserActionTime porque Microsoft pide no tomarlo como última actividad. El reporte tiene un retraso de 24 a 48 horas y no registra actividad propia de los compartidos. Si el tenant oculta los nombres en los reportes, el script se detiene en lugar de reportar datos falsos. Solo lectura: no cambia nada en el tenant. .PARAMETER Dias Días sin actividad para considerar inactivo un buzón, de 1 a 180. Por defecto, 90. .PARAMETER Tipo UserMailbox (por defecto), SharedMailbox o Todos (ambos). El reporte de uso no cubre buzones de recursos. .PARAMETER RutaCsv Ruta del CSV de salida. Si se omite, solo se muestra en pantalla. .EXAMPLE .\get-buzones-inactivos.ps1 -Dias 120 -RutaCsv .\inactivos.csv .NOTES Permisos: Reports.Read.All Rol mínimo: Lector de informes (el reporte por usuario no está disponible para Lector global) y Lector global para leer los buzones. Licencia: Exchange Online Plan 1 o superior, incluido en Microsoft 365 Business Premium. Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas. #> #Requires -Version 7.0 [CmdletBinding()] param( [ValidateRange(1, 180)] [int]$Dias = 90, [ValidateSet('UserMailbox', 'SharedMailbox', 'Todos')] [string]$Tipo = 'UserMailbox', [string]$RutaCsv ) $ErrorActionPreference = 'Stop' if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) { throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser' } if (-not (Get-Command Connect-MgGraph -ErrorAction SilentlyContinue)) { throw 'Falta el módulo. Instálalo con: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser' } function Limpiar-Fila($fila) { foreach ($p in $fila.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'$($p.Value)" } } $fila } # v1.0 entrega este reporte como CSV. Se guarda en un temporal y, si el archivo no aparece, se usa lo que devolvió la llamada. function Get-ReporteUso { $temporal = Join-Path ([IO.Path]::GetTempPath()) "uso-buzones-$([guid]::NewGuid()).csv" try { $cuerpo = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/reports/getMailboxUsageDetail(period='D180')" -OutputFilePath $temporal $texto = if (Test-Path -LiteralPath $temporal) { Get-Content -LiteralPath $temporal -Raw -Encoding utf8 } else { "$cuerpo" } if ($texto) { @($texto | ConvertFrom-Csv) } else { @() } } finally { Remove-Item -LiteralPath $temporal -ErrorAction SilentlyContinue } } function Get-Fecha($texto) { if ($texto) { [datetime]::Parse($texto, [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal) } } # El CSV se vacía desde el inicio: si algo truena o no hay resultados, no queda uno viejo que parezca actual. if ($RutaCsv) { Set-Content -LiteralPath $RutaCsv -Value $null -Encoding utf8BOM } $tipos = if ($Tipo -eq 'Todos') { 'UserMailbox', 'SharedMailbox' } else { $Tipo } Connect-MgGraph -Scopes 'Reports.Read.All' -NoWelcome Connect-ExchangeOnline -ShowBanner:$false try { $buzones = @(Get-EXOMailbox -RecipientTypeDetails $tipos -ResultSize Unlimited -ErrorAction Stop) } finally { Disconnect-ExchangeOnline -Confirm:$false } $reporte = @{} foreach ($r in Get-ReporteUso) { if ($r.'Is Deleted' -ne 'True') { $reporte["$($r.'User Principal Name')"] = $r } } if ($buzones.Count -and -not $reporte.Count) { throw 'El reporte de uso de Microsoft Graph llegó vacío: sin él no se puede saber qué buzones están inactivos.' } # Con los nombres ocultos (valor por defecto del tenant) nada coincidiría y todos parecerían inactivos. if ($buzones.Count -and -not ($buzones | Where-Object { $reporte.ContainsKey("$($_.UserPrincipalName)") })) { throw 'El reporte oculta los nombres de usuario. Desmarca la opción en el centro de administración de Microsoft 365: Configuración > Configuración de la organización > Servicios > Informes. Después vuelve a correr el script.' } $hoy = (Get-Date).ToUniversalTime().Date $corte = $hoy.AddDays(-$Dias) $filas = foreach ($b in $buzones) { $r = $reporte["$($b.UserPrincipalName)"] $ultima = if ($r) { Get-Fecha $r.'Last Activity Date' } if ($ultima -and $ultima -ge $corte) { continue } # Un buzón recién creado que aún no se usa no es un buzón abandonado. $creado = if ($r) { Get-Fecha $r.'Created Date' } if (-not $ultima -and $creado -and $creado -ge $corte) { continue } $bytes = 0L $conBytes = $r -and [long]::TryParse("$($r.'Storage Used (Byte)')", [ref]$bytes) [pscustomobject]@{ Buzon = $b.DisplayName Correo = $b.PrimarySmtpAddress Tipo = $b.RecipientTypeDetails UltimaActividad = if ($ultima) { $ultima.ToString('yyyy-MM-dd') } else { '' } DiasSinActividad = if ($ultima) { ($hoy - $ultima).Days } else { $null } TamanoGB = if ($conBytes) { [math]::Round($bytes / 1GB, 2) } Elementos = if ($r) { $r.'Item Count' } Estado = if (-not $r) { 'Sin dato en el reporte' } elseif ($ultima) { "Sin actividad en $Dias días o más" } else { 'Sin actividad registrada en 180 días' } Nota = if ("$($b.RecipientTypeDetails)" -eq 'SharedMailbox') { 'Compartido: el reporte no registra su actividad; revisa con sus delegados' } else { '' } } } $salida = @($filas | Sort-Object @{ Expression = { $null -eq $_.DiasSinActividad }; Descending = $true }, @{ Expression = 'DiasSinActividad'; Descending = $true }) Write-Host "Buzones sin actividad en $Dias días: $($salida.Count)" if ($RutaCsv) { if ($salida.Count) { # UTF-8 con BOM para que Excel en español muestre bien los acentos. $salida | ForEach-Object { Limpiar-Fila $_ } | Export-Csv -LiteralPath $RutaCsv -NoTypeInformation -Encoding utf8BOM } Write-Host "CSV guardado en $RutaCsv" } else { $salida | Format-Table -AutoSize }