Saltar al contenido
MS Scripts

Scripts de Exchange

Ranking de buzones por tamaño

Te dice qué buzones ocupan más espacio, cuántos elementos tienen, qué tanto de su cuota de envío usan y cuándo tuvieron actividad por última vez. Sirve para decidir a quién limpiar, archivar o cambiar de licencia.

Solo lecturaPide 1 permisoProbado con respuestas simuladas. Sin ejecutar en un tenant real.

Antes de ejecutarloLa última actividad viene del reporte de uso de Microsoft Graph porque Microsoft pide no usar LastUserActionTime; ese reporte trae 24 a 48 horas de retraso, no cubre buzones de recursos y oculta los nombres por defecto. Si no se puede leer, el ranking sale igual y esa columna dice "No disponible". El tamaño no incluye el archivo en línea.

Ejemplo en México

Maquiladora de autopartes en Ciudad Juárez, 400 personas

Los supervisores de turno se quejan de que Outlook va lento y el corporativo pide justificar más licencias antes del cierre fiscal.

.\get-buzones-por-tamano.ps1 -Tipo UserMailbox -Top 20 -RutaCsv .\buzones-grandes.csv

Obtiene los 20 buzones más grandes con su % de cuota. Activa el archivado en los que pasan de 80 % y deja de pedir licencias para el resto.

Cómo implementarlo

  1. Instala los módulos si no los tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser e Install-Module Microsoft.Graph.Authentication -Scope CurrentUser.
  2. Ejecuta el script con -Tipo, -Top y -RutaCsv. Agrega -IncluirEliminados si también quieres ver los buzones eliminados que aún se pueden recuperar.
  3. Inicia sesión en Exchange Online y en Microsoft Graph con una cuenta que tenga los roles indicados.
  4. Abre el CSV: revisa TamanoGB y PorcentajeCuota, y atiende primero a los que estén cerca de 100 %. Si alguna fila dice "No se pudo consultar", corre el script otra vez para ese caso.
Ver el código completo de get-buzones-por-tamano.ps1
<#
.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." }

Parte del playbook

Relacionados