<# .SYNOPSIS Convierte en compartidos los buzones de usuario de un CSV, para conservar el correo y poder recuperar la licencia. .DESCRIPTION Lee un CSV con la columna Cuenta, el mismo formato de set-baja-empleados-csv. Por cada cuenta revisa tipo, tamaño, retención por litigio, archivo y políticas de retención, convierte el buzón y dice en PuedeQuitarLicencia si ya se le puede quitar la licencia. Solo dice Sí cuando el buzón ya es compartido o se acaba de convertir; ante la duda dice No. Una falla no detiene a las demás, y si un buzón no se pudo revisar no se convierte. Las filas vacías se ignoran. La conversión NO bloquea el inicio de sesión: la contraseña sigue funcionando. Usa también set-baja-empleados-csv. Prueba siempre primero con -WhatIf. Las reglas de bandeja se conservan al convertir: si la cuenta estaba comprometida, revísalas con get-reglas-bandeja-sospechosas. CAMBIA EL TENANT: cambia el tipo de los buzones. Para revertir hace falta Set-Mailbox -Type Regular, asignar licencia y restablecer la contraseña. .PARAMETER RutaCsvBuzones CSV de entrada, separado por comas, con una columna llamada Cuenta. .PARAMETER RutaCsv Ruta del reporte de resultados. Si se omite, solo se muestra en pantalla. .EXAMPLE .\set-convertir-a-compartido.ps1 -RutaCsvBuzones .\bajas.csv -RutaCsv .\resultado.csv -WhatIf .EXAMPLE .\set-convertir-a-compartido.ps1 -RutaCsvBuzones .\bajas.csv -RutaCsv .\resultado.csv .NOTES Permisos: ninguno de Graph; usa ExchangeOnlineManagement. Rol mínimo: Recipient Management en Exchange Online, o Administrador de Exchange. Licencia: el buzón debe tener licencia al convertirlo. Sin licencia, un buzón compartido llega a 50 GB; la retención por litigio y el archivo en línea exigen Exchange Online Plan 2, o Plan 1 con Exchange Online Archiving. 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(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory)][string]$RutaCsvBuzones, [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' } # En sesión remota el tamaño llega como texto: "1.2 GB (1,288,490,188 bytes)". function ConvertTo-Gb { param($Valor) $texto = "$Valor".Trim() if (-not $texto) { return $null } if ($texto -eq 'Unlimited') { return 'Sin límite' } # Sin redondear: 50.004 GB no debe pasar por 50 al comparar con el límite. if ($texto -match '\(([\d,.\s]+) bytes\)') { return [double]($Matches[1] -replace '\D', '') / 1GB } if ($texto -match '^0(\.0+)?\s*B') { return [double]0 } $null } if ($RutaCsv) { Set-Content -LiteralPath $RutaCsv -Value $null -Encoding utf8BOM -WhatIf:$false -Confirm:$false } $entrada = @(Import-Csv -Path $RutaCsvBuzones) if (-not $entrada) { throw "El CSV $RutaCsvBuzones está vacío." } if ('Cuenta' -notin $entrada[0].PSObject.Properties.Name) { throw "El CSV debe tener una columna llamada Cuenta, separada por comas. Columnas encontradas: $($entrada[0].PSObject.Properties.Name -join ', ')" } # Una identidad vacía en Get-EXOMailbox devuelve todos los buzones: las filas vacías no deben llegar a ningún cmdlet. $cuentas = @($entrada | ForEach-Object { "$($_.Cuenta)".Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique) $vacias = @($entrada | Where-Object { -not "$($_.Cuenta)".Trim() }).Count if (-not $cuentas) { throw "El CSV $RutaCsvBuzones no trae ninguna cuenta en la columna Cuenta." } $limiteSinLicenciaGb = 50 # En una lista: si se interrumpe a medias, el reporte conserva lo que ya se convirtió. $filas = [System.Collections.Generic.List[object]]::new() Connect-ExchangeOnline -ShowBanner:$false try { # Las políticas de Purview de toda la organización no se ven en el buzón: solo sus exclusiones. $politicasOrg = $null try { $politicasOrg = @((Get-OrganizationConfig -ErrorAction Stop).InPlaceHolds | Where-Object { "$_" -like 'mbx*' } | ForEach-Object { ("$_" -replace '^mbx', '') -replace ':.*$', '' }) } catch { Write-Warning "No se pudieron leer las políticas de retención de la organización: $_" } foreach ($cuenta in $cuentas) { $fila = [pscustomobject]@{ Cuenta = $cuenta; Nombre = ''; TipoAnterior = ''; TamanoGB = ''; RetencionPorLitigio = ''; ArchivoActivo = ''; PuedeQuitarLicencia = ''; Resultado = 'Sin cambios'; Nota = '' } $filas.Add($fila) # Un comodín podría abarcar más buzones de los que se quiso indicar. if ($cuenta -notmatch '^[^\s@*?]+@[^\s@*?]+$') { $fila.Resultado = 'No se pudo consultar'; $fila.Nota = 'No parece un correo válido'; continue } try { $b = Get-EXOMailbox -Identity $cuenta -Properties DisplayName, LitigationHoldEnabled, ArchiveStatus, ArchiveGuid, ArchiveDatabase, InPlaceHolds, ComplianceTagHoldApplied, DelayHoldApplied -ErrorAction Stop $tamano = ConvertTo-Gb (Get-EXOMailboxStatistics -Identity $cuenta -ErrorAction Stop).TotalItemSize } catch { $fila.Resultado = 'No se pudo consultar' $fila.Nota = "$_" continue } $fila.Nombre = "$($b.DisplayName)" $fila.TipoAnterior = "$($b.RecipientTypeDetails)" $fila.TamanoGB = if ($tamano -is [double]) { [math]::Round($tamano, 2) } else { $tamano } $litigio = $b.LitigationHoldEnabled -eq $true $fila.RetencionPorLitigio = if ($litigio) { 'Sí' } else { 'No' } # Microsoft indica no fiarse de ArchiveStatus: puede decir None con un archivo activo. $guid = "$($b.ArchiveGuid)" $archivo = "$($b.ArchiveStatus)" -eq 'Active' -or ($guid -and $guid -ne '00000000-0000-0000-0000-000000000000' -and "$($b.ArchiveDatabase)") $fila.ArchivoActivo = if ($archivo) { 'Sí' } else { 'No' } if ($fila.TipoAnterior -eq 'SharedMailbox') { $fila.Resultado = 'Ya era compartido' } elseif ($fila.TipoAnterior -ne 'UserMailbox') { $fila.Resultado = 'Omitido'; $fila.Nota = "No es un buzón de usuario: $($fila.TipoAnterior)"; continue } $motivos = @() if ($tamano -is [double] -and $tamano -ge $limiteSinLicenciaGb) { $motivos += "Llega o pasa de $limiteSinLicenciaGb GB" } if ($litigio) { $motivos += 'Retención por litigio' } if ($archivo) { $motivos += 'Archivo en línea activo' } if (@($b.InPlaceHolds | Where-Object { "$_" -and "$_" -notlike '-*' })) { $motivos += 'Tiene bloqueos o políticas de retención' } if ($b.ComplianceTagHoldApplied -eq $true) { $motivos += 'Etiqueta de retención aplicada' } if ($b.DelayHoldApplied -eq $true) { $motivos += 'Retención diferida activa' } if ($null -eq $politicasOrg) { $motivos += 'No se pudieron revisar las políticas de la organización' } else { $excluidas = @($b.InPlaceHolds | Where-Object { "$_" -like '-mbx*' } | ForEach-Object { ("$_" -replace '^-mbx', '') -replace ':.*$', '' }) if (@($politicasOrg | Where-Object { $_ -notin $excluidas })) { $motivos += 'Aplica una política de retención de toda la organización' } } $fila.PuedeQuitarLicencia = if ($motivos) { 'No' } elseif ($tamano -isnot [double]) { 'No determinado' } else { 'Sí' } $notas = @() if ($motivos) { $notas += "Conserva la licencia: $($motivos -join ', ')" } elseif ($tamano -isnot [double]) { $notas += 'No se pudo leer el tamaño: revísalo antes de quitar la licencia' } if ($fila.Resultado -eq 'Sin cambios') { if ($PSCmdlet.ShouldProcess($cuenta, 'Convertir en buzón compartido')) { try { Set-Mailbox -Identity $cuenta -Type Shared -Confirm:$false -ErrorAction Stop $fila.Resultado = 'Convertido' } catch { $fila.Resultado = "Error: $_" } } elseif ($WhatIfPreference) { $fila.Resultado = 'Simulado' } else { $fila.Resultado = 'Cancelado' } } # Quitarle la licencia a un buzón de usuario sin convertir lo dejaría sin servicio. if ($fila.Resultado -eq 'Simulado') { if ($fila.PuedeQuitarLicencia -eq 'Sí') { $fila.PuedeQuitarLicencia = 'Sí, al convertir' } } elseif ($fila.Resultado -notin 'Convertido', 'Ya era compartido' -and $fila.PuedeQuitarLicencia -ne 'No') { $fila.PuedeQuitarLicencia = 'No: sin convertir' } $fila.Nota = $notas -join '. ' } } finally { try { Disconnect-ExchangeOnline -Confirm:$false } catch { Write-Warning "No se pudo cerrar la sesión de Exchange: $_" } # Va aquí para no perder el reporte de lo ya convertido si se interrumpe o falla el cierre. if ($RutaCsv) { # Excel ejecutaría como fórmula un nombre que empiece con estos caracteres. 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 } } } # El reporte se escribe también con -WhatIf: es el resultado de la simulación, no un cambio en el tenant. $filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM -WhatIf:$false -Confirm:$false Write-Host "CSV guardado en $RutaCsv" } } $fallas = @($filas | Where-Object { $_.Resultado -like 'Error*' -or $_.Resultado -eq 'No se pudo consultar' }) $mensaje = "Cuentas procesadas: $($filas.Count). Con falla: $($fallas.Count)." if ($vacias) { $mensaje += " Filas vacías ignoradas: $vacias." } Write-Host $mensaje Write-Host 'La conversión no bloquea el inicio de sesión: corre set-baja-empleados-csv para mantener bloqueadas estas cuentas.' if (-not $RutaCsv) { $filas | Format-Table -AutoSize } if ($fallas) { throw "Fallaron $($fallas.Count) cuentas: $($fallas.Cuenta -join ', ')" }