Saltar al contenido
MS Scripts

Scripts de Exchange

Buzones sin actividad del usuario

Encuentra los buzones cuyo usuario no ha enviado ni leído correo en los últimos días, con su tamaño y su última actividad. Te da candidatos para revisar licencias y bajas pendientes.

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

Antes de ejecutarloMicrosoft está retirando LastUserActionTime y pide no usarlo como última actividad, por eso este script usa el reporte de uso de Microsoft Graph (getMailboxUsageDetail). Ese dato cuenta enviar, leer y atender citas, y recibir correo no está en esa lista; trae 24 a 48 horas de retraso, no registra actividad propia de compartidos y no cubre buzones de recursos. Solo llega a 180 días: "Sin actividad registrada" significa que no hay fecha, no cuánto tiempo lleva. Si el tenant oculta los nombres en los reportes, el script se detiene en lugar de marcar a todos como inactivos.

Ejemplo en México

Cadena de restaurantes en Guadalajara, 250 personas

Por la rotación de personal en sucursales, la renovación anual se ve inflada. Administración quiere saber qué licencias se pueden recuperar.

.\get-buzones-inactivos.ps1 -Dias 120 -RutaCsv .\inactivos.csv

Obtiene los buzones sin actividad en 120 días, los cruza con la nómina y quita la licencia a quienes ya dejaron la empresa.

Cómo implementarlo

  1. Pide a un administrador global que desmarque la opción de ocultar nombres en Configuración > Configuración de la organización > Servicios > Informes del centro de administración de Microsoft 365, si está activa.
  2. Instala los módulos si no los tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser e Install-Module Microsoft.Graph.Authentication -Scope CurrentUser.
  3. Ejecuta el script con -Dias y -RutaCsv, e inicia sesión con una cuenta que tenga los roles indicados.
  4. Confirma cada caso con Recursos Humanos o con el jefe de área antes de quitar una licencia o borrar algo: este reporte da candidatos, no una decisión.
Ver el código completo de get-buzones-inactivos.ps1
<#
.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
}

Parte del playbook

Relacionados