<# .SYNOPSIS Responde qué buzones ocupan más espacio y qué tan cerca están de su cuota de envío. .DESCRIPTION Rankea los buzones por tamaño con número de elementos, cuota de envío real, % de cuota usado y última actividad del usuario. Sirve para planear limpieza, archivado o un cambio de licencia. La última actividad sale del reporte de uso de Microsoft Graph porque Microsoft pide no usar LastUserActionTime. Si un buzón falla, queda en el reporte y el script termina con error al final, después de guardar el CSV. Solo lectura: no cambia nada en el tenant. .PARAMETER Tipo Tipo de buzón: UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox o Todos (por defecto). .PARAMETER Top Muestra solo los N buzones más grandes. Los que fallaron se incluyen siempre. .PARAMETER IncluirEliminados Agrega los buzones eliminados que todavía se pueden recuperar. .PARAMETER RutaCsv Ruta del CSV de salida. Si se omite, solo se muestra en pantalla. .EXAMPLE .\get-buzones-por-tamano.ps1 -Tipo UserMailbox -Top 20 -RutaCsv .\buzones.csv .NOTES Permisos: Reports.Read.All Rol mínimo: Lector global (View-Only Organization Management en Exchange Online) y Lector de informes para el reporte de uso. 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( [ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'Todos')] [string]$Tipo = 'Todos', [ValidateRange(1, [int]::MaxValue)] [int]$Top, [switch]$IncluirEliminados, [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' } # Los tamaños llegan como texto: 1.2 GB (1,288,490,188 bytes). Sin paréntesis (Unlimited o vacío) no hay bytes. function Get-Bytes($valor) { $texto = "$valor".Trim() if ($texto -match '\(([\d,. ]+)\s*bytes\)') { return [long]($Matches[1] -replace '\D') } if ($texto -match '^0(\.0+)?\s*B$') { return [long]0 } $null } 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 } } # 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', 'RoomMailbox', 'EquipmentMailbox' } else { $Tipo } $propiedades = 'ProhibitSendQuota', 'UseDatabaseQuotaDefaults', 'ExchangeGuid', 'WhenSoftDeleted' $filas = [System.Collections.Generic.List[object]]::new() $fallidos = [System.Collections.Generic.List[object]]::new() $fallas = [System.Collections.Generic.List[string]]::new() Connect-ExchangeOnline -ShowBanner:$false try { $buzones = @(Get-EXOMailbox -RecipientTypeDetails $tipos -ResultSize Unlimited -Properties $propiedades -ErrorAction Stop) if ($IncluirEliminados) { $buzones += @(Get-EXOMailbox -SoftDeletedMailbox -RecipientTypeDetails $tipos -ResultSize Unlimited -Properties $propiedades -ErrorAction Stop) } # Sin el reporte de uso el ranking sigue siendo válido: se avisa y solo esa columna queda sin dato. $actividad = @{} $avisoActividad = $null try { Connect-MgGraph -Scopes 'Reports.Read.All' -NoWelcome foreach ($r in Get-ReporteUso) { if ($r.'Is Deleted' -ne 'True') { $actividad["$($r.'User Principal Name')"] = "$($r.'Last Activity Date')" } } # Con los nombres ocultos (valor por defecto del tenant), los UPN del reporte no coinciden con ningún buzón. if ($actividad.Count -and $buzones.Count -and -not ($buzones | Where-Object { $actividad.ContainsKey("$($_.UserPrincipalName)") })) { $avisoActividad = 'el reporte oculta los nombres. Desmarca la opción en el centro de administración de Microsoft 365: Configuración > Configuración de la organización > Servicios > Informes' } } catch { $avisoActividad = "$_" } if ($avisoActividad) { Write-Warning "Sin última actividad: $avisoActividad" } foreach ($b in $buzones) { $eliminado = [bool]$b.WhenSoftDeleted try { $estadisticas = if ($eliminado) { Get-EXOMailboxStatistics -ExchangeGuid $b.ExchangeGuid -IncludeSoftDeletedRecipients -Properties DatabaseProhibitSendQuota -ErrorAction Stop } else { Get-EXOMailboxStatistics -Identity $b.UserPrincipalName -Properties DatabaseProhibitSendQuota -ErrorAction Stop } } catch { $fallas.Add("$($b.PrimarySmtpAddress)") $fallidos.Add([pscustomobject]@{ Buzon = $b.DisplayName; Correo = $b.PrimarySmtpAddress; Tipo = $b.RecipientTypeDetails Estado = "No se pudo consultar: $_"; TamanoGB = $null; Elementos = $null; CuotaEnvioGB = $null; PorcentajeCuota = $null; UltimaActividad = $null }) continue } $bytes = Get-Bytes $estadisticas.TotalItemSize # Sin tamaño no entra al ranking: con -Top desaparecería, y los que no se leyeron siempre se muestran. if ($null -eq $bytes) { $fallidos.Add([pscustomobject]@{ Buzon = $b.DisplayName; Correo = $b.PrimarySmtpAddress; Tipo = $b.RecipientTypeDetails Estado = 'No se pudo leer el tamaño'; TamanoGB = $null; Elementos = $estadisticas.ItemCount; CuotaEnvioGB = $null; PorcentajeCuota = $null; UltimaActividad = $null }) continue } # Con UseDatabaseQuotaDefaults, la cuota del buzón se ignora y aplica la de la base de datos. $cuota = if ($b.UseDatabaseQuotaDefaults -eq $true) { $estadisticas.DatabaseProhibitSendQuota } else { $b.ProhibitSendQuota } $bytesCuota = Get-Bytes $cuota $ultima = if ($avisoActividad) { 'No disponible' } elseif ($eliminado -or -not $actividad.ContainsKey("$($b.UserPrincipalName)")) { 'Sin dato en el reporte' } elseif ($actividad["$($b.UserPrincipalName)"]) { $actividad["$($b.UserPrincipalName)"] } else { 'Sin actividad en 180 días' } $filas.Add([pscustomobject]@{ Buzon = $b.DisplayName Correo = $b.PrimarySmtpAddress Tipo = $b.RecipientTypeDetails Estado = if ($eliminado) { 'Eliminado recuperable' } else { 'Activo' } TamanoGB = if ($null -ne $bytes) { [math]::Round($bytes / 1GB, 2) } Elementos = $estadisticas.ItemCount CuotaEnvioGB = if ($null -ne $bytesCuota) { [math]::Round($bytesCuota / 1GB, 2) } elseif ("$cuota".Trim() -eq 'Unlimited') { 'Ilimitada' } else { 'Sin dato' } PorcentajeCuota = if ($null -ne $bytes -and $bytesCuota) { [math]::Round($bytes / $bytesCuota * 100, 1) } UltimaActividad = $ultima }) } } finally { Disconnect-ExchangeOnline -Confirm:$false } $ranking = @($filas | Sort-Object TamanoGB -Descending) if ($Top) { $ranking = @($ranking | Select-Object -First $Top) } $salida = @($ranking) + @($fallidos) Write-Host "Buzones en el reporte: $($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 } if ($fallas.Count) { throw "No se pudieron consultar $($fallas.Count) buzones: $($fallas -join ', '). Revisa las filas marcadas en el reporte." }