Rastreo de mensajes
Responde "¿llegó ese correo?" buscando por remitente, destinatario, fechas y asunto, con estado de entrega y hora en México. Avisa si el resultado quedó cortado por el tope.
Solo lecturaSin permisos de GraphProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Despacho contable en Guadalajara, 35 personas
En pleno cierre fiscal, un cliente asegura que mandó su balanza de comprobación el lunes y la contadora no la ve en su bandeja.
.\search-rastreo-mensajes.ps1 -Remitente cliente@empresa.example -Destinatario contadora@empresa.example -Desde "2026-11-02" -Hasta "2026-11-05" -RutaCsv .\rastreo.csvEl mensaje aparece como Quarantined y con su hora de México. Lo liberan de la cuarentena y agregan una nota para la próxima revisión de la política antispam.
Cómo implementarlo
- Instala o actualiza el módulo: Install-Module ExchangeOnlineManagement -Scope CurrentUser.
- Ejecuta el script con al menos un filtro y un rango de hasta 10 días; sin fechas busca las últimas 48 horas.
- Revisa la columna Estado: Delivered, Failed, Quarantined o FilteredAsSpam, entre otros.
- Si TopeAlcanzado dice Sí, reduce el rango o agrega filtros: faltan mensajes.
- Comparte con el área afectada solo lo necesario y borra el CSV cuando termines.
Ver el código completo de search-rastreo-mensajes.ps1
<#
.SYNOPSIS
Rastrea mensajes por remitente, destinatario, fechas y asunto para saber si un correo llegó y qué pasó con él.
.DESCRIPTION
Usa Get-MessageTraceV2, que reemplaza a Get-MessageTrace. Consulta hasta 90 días atrás, con un rango máximo de 10 días
por búsqueda y un tope de 5000 resultados. Si se alcanza el tope, avisa: reduce el rango o agrega filtros.
Sin fechas busca las últimas 48 horas. Las fechas que escribes se toman en la hora de tu computadora.
Solo lectura: no cambia nada en el tenant. El resultado trae datos personales: correos y asuntos.
.PARAMETER Remitente
Uno o más correos de remitente.
.PARAMETER Destinatario
Uno o más correos de destinatario.
.PARAMETER Desde
Inicio del rango, por ejemplo "2026-11-01 08:00". Si se omite, 48 horas antes de -Hasta.
.PARAMETER Hasta
Fin del rango. Si se omite, ahora. Una fecha sin hora incluye ese día completo.
.PARAMETER Asunto
Texto que debe contener el asunto.
.PARAMETER Maximo
Número máximo de resultados, de 1 a 5000. Por defecto, 5000.
.PARAMETER ZonaHoraria
Zona horaria para mostrar la hora de recepción. Por defecto, Central Standard Time (Mexico).
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\search-rastreo-mensajes.ps1 -Remitente proveedor@empresa.example -Destinatario ana@empresa.example -Desde "2026-11-01" -Hasta "2026-11-05" -RutaCsv .\rastreo.csv
.EXAMPLE
.\search-rastreo-mensajes.ps1 -Asunto "Factura" -RutaCsv .\rastreo.csv
.NOTES
Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
Rol mínimo: View-Only Recipients en Exchange Online (incluido en Compliance Management, Help Desk, Hygiene Management, View-Only Organization Management y Organization Management).
Licencia: Exchange Online.
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(
[string[]]$Remitente,
[string[]]$Destinatario,
[datetime]$Desde,
[datetime]$Hasta,
[string]$Asunto,
[ValidateRange(1, 5000)][int]$Maximo = 5000,
[string]$ZonaHoraria = 'Central Standard Time (Mexico)',
[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'
}
# 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 }
try { $zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria) } catch { throw "Zona horaria no reconocida: $ZonaHoraria" }
$ahora = [datetime]::UtcNow
# "2026-11-05" significa medianoche: sin este ajuste se perdería todo ese día.
$fin = if ($PSBoundParameters.ContainsKey('Hasta')) { $(if ($Hasta.TimeOfDay -eq [timespan]::Zero) { $Hasta.AddDays(1) } else { $Hasta }).ToUniversalTime() } else { $ahora }
if ($fin -gt $ahora) { $fin = $ahora }
$inicio = if ($PSBoundParameters.ContainsKey('Desde')) { $Desde.ToUniversalTime() } else { $fin.AddHours(-48) }
if ($inicio -gt $ahora) { throw 'Rango inválido: -Desde está en el futuro.' }
if ($inicio -ge $fin) { throw 'Rango inválido: -Desde debe ser anterior a -Hasta.' }
if (($fin - $inicio).TotalDays -gt 10) { throw 'Rango inválido: Get-MessageTraceV2 acepta máximo 10 días por búsqueda. Parte la búsqueda en tramos.' }
if ($inicio -lt $ahora.AddDays(-90)) { throw 'Rango inválido: Get-MessageTraceV2 solo tiene datos de los últimos 90 días.' }
$filtros = @{ StartDate = $inicio; EndDate = $fin; ResultSize = $Maximo }
if ($Remitente) { $filtros.SenderAddress = $Remitente }
if ($Destinatario) { $filtros.RecipientAddress = $Destinatario }
if ($Asunto) { $filtros.Subject = $Asunto; $filtros.SubjectFilterType = 'Contains' }
Connect-ExchangeOnline -ShowBanner:$false
try {
if (-not (Get-Command Get-MessageTraceV2 -ErrorAction SilentlyContinue)) {
throw 'Tu módulo no trae Get-MessageTraceV2. Actualízalo con: Update-Module ExchangeOnlineManagement'
}
$mensajes = @(Get-MessageTraceV2 @filtros -ErrorAction Stop)
} finally {
Disconnect-ExchangeOnline -Confirm:$false
}
$topeAlcanzado = $mensajes.Count -ge $Maximo
$filas = @(foreach ($m in $mensajes) {
# La salida del cmdlet viene en UTC.
$recibido = "$($m.Received)"
if ($m.Received) {
try {
$fecha = [datetime]$m.Received
if ($fecha.Kind -eq 'Unspecified') { $fecha = [datetime]::SpecifyKind($fecha, 'Utc') }
$recibido = [TimeZoneInfo]::ConvertTime($fecha.ToUniversalTime(), $zona).ToString('yyyy-MM-dd HH:mm')
} catch { Write-Verbose "Fecha no reconocida, se deja como llegó: $recibido" }
}
[pscustomobject]@{
Recibido = $recibido
Remitente = "$($m.SenderAddress)"
Destinatario = "$($m.RecipientAddress)"
Asunto = "$($m.Subject)"
Estado = "$($m.Status)"
TamanoBytes = $m.Size
IpOrigen = "$($m.FromIP)"
IpDestino = "$($m.ToIP)"
IdMensaje = "$($m.MessageId)"
IdRastreo = "$($m.MessageTraceId)"
TopeAlcanzado = if ($topeAlcanzado) { 'Sí' } else { 'No' }
}
})
Write-Host "Mensajes encontrados: $($filas.Count)"
if ($topeAlcanzado) { Write-Warning "Se alcanzó el tope de $Maximo resultados: puede haber más mensajes que no aparecen. Reduce el rango de fechas o agrega filtros." }
if ($RutaCsv) {
# Excel ejecutaría como fórmula un asunto que empiece con estos caracteres, y el asunto lo elige quien envía el correo.
foreach ($f in $filas) { foreach ($p in $f.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'" + $p.Value } } }
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM
Write-Host "CSV guardado en $RutaCsv"
} else {
$filas | Format-Table -AutoSize
}