Exchange Online: límite al crear contactos (importación masiva) — causas, solución, PowerShell y alternativas

¿Tu importación masiva de contactos externos en Exchange Online se frena con RecipientUsageExceededRecentQuotaException? Aquí tienes una guía práctica, con scripts y alternativas, para completar la carga sin dolores de cabeza, mantenerla resiliente y hacer que toda la organización vea los contactos donde corresponde.

Índice

Contexto del error y qué significa realmente

Al intentar importar unos 2.100 contactos (CSV + PowerShell) en Exchange Online, es habitual que el proceso se detenga alrededor de varias centenas y aparezca el error:

RecipientUsageExceededRecentQuotaException: You've reached the maximum number of mail users and contacts you can create at this time.

Este mensaje indica que has rebasado una cuota de creación reciente para objetos de destinatario (mail contacts y mail users). Importante:

  • No existe un número fijo público para todos los inquilinos; el umbral varía según factores como el tamaño y actividad del tenant.
  • El límite es temporal y se restablece automáticamente pasado un tiempo.
  • Volver a lanzar todo el paquete de miles de contactos solo provoca más errores y repeticiones. La clave es lotes pequeños + reintentos espaciados.

Estrategia ganadora para completar la importación de mail contacts

La receta práctica se resume en tres pilares: tamaño de lote, reintentos espaciados y scripts resilientes con registro y reanudación.

Definir y validar el CSV

Trabaja con un CSV limpio y consistente. Un encabezado típico podría ser:

DisplayName,GivenName,Surname,ExternalEmailAddress,Company,Department,CustomAttribute1
Acme - Juan Pérez,Juan,Pérez,juan.perez@acme.com,Acme SA,Compras,Import202508
  • DisplayName: cómo aparecerá el contacto en la GAL si eliges mail contacts.
  • ExternalEmailAddress: dirección SMTP principal del contacto externo.
  • CustomAttribute1: etiqueta de lote (ej. ImportYYYYMM) para auditar o revertir fácilmente.

Script robusto con lotes, reintentos y reanudación

Este ejemplo en PowerShell usa Exchange Online PowerShell, procesa en tandas (~100), detecta la cuota, espera y reanuda; además registra lo creado, lo existente y los fallos sin duplicar:

# Requisitos:
- Módulo ExchangeOnlineManagement
- Permisos para crear Mail Contacts
- CSV con las columnas: DisplayName, GivenName, Surname, ExternalEmailAddress, Company, Department, CustomAttribute1

param(
\[Parameter(Mandatory=\$true)]\[string]\$CsvPath,
\[int]\$BatchSize = 100,
\[int]\$PauseSecondsOnQuota = 900,  # 15 minutos
\[int]\$MaxQuotaRetries = 12,       # hasta ~3h de backoff total si fuera necesario
\[string]\$StateFile = ".\import-state.json",
\[string]\$LogOk = ".\import-created.csv",
\[string]\$LogExists = ".\import-exists.csv",
\[string]\$LogFail = ".\import-failed.csv"
)

function Ensure-Logs {
foreach (\$f in @(\$LogOk,\$LogExists,\$LogFail)) {
if (-not (Test-Path \$f)) { "" | Out-File -Encoding UTF8 -FilePath \$f }
}
}

function Load-State {
if (Test-Path \$StateFile) { (Get-Content \$StateFile -Raw) | ConvertFrom-Json } else { @{ Index = 0 } }
}
function Save-State(\[int]\$Index) {
@{ Index = \$Index } | ConvertTo-Json | Out-File -Encoding UTF8 \$StateFile
}

function Test-ContactExists(\[string]\$ExternalEmail) {
try {
\$existing = Get-MailContact -Filter "ExternalEmailAddress -like '\*\$ExternalEmail'" -ResultSize 1 -ErrorAction Stop
return \$null -ne \$existing
} catch { return \$false }
}

function New-ContactFromRow(\$row) {
New-MailContact `    -Name $row.DisplayName`
-DisplayName \$row\.DisplayName `    -ExternalEmailAddress $row.ExternalEmailAddress`
-FirstName \$row\.GivenName `    -LastName $row.Surname`
-ErrorAction Stop | Out-Null

Campos extendidos útiles y trazabilidad

if (\$row\.Company -or \$row\.Department -or \$row\.CustomAttribute1) {
Set-MailContact -Identity \$row\.DisplayName `      -Company $row.Company`
-Department \$row\.Department `      -CustomAttribute1 $row.CustomAttribute1`
-ErrorAction SilentlyContinue
}
}

function Invoke-WithQuotaRetry(\[ScriptBlock]\$Action) {
\$retries = 0
while (\$true) {
try {
& \$Action
return
} catch {
\$msg = \$*.Exception.Message
if (\$msg -match "RecipientUsageExceededRecentQuotaException") {
if (\$retries -ge \$MaxQuotaRetries) { throw \$* }
\$wait = \$PauseSecondsOnQuota \* \[Math]::Pow(1.2, \$retries) # backoff suave
Write-Host "Cuota alcanzada. Esperando \$(\[int]\$wait) s antes de reintentar..." -ForegroundColor Yellow
Start-Sleep -Seconds (\[int]\$wait)
\$retries++
} else {
throw $\_
}
}
}
}

--- Inicio del proceso ---

Import-Module ExchangeOnlineManagement -ErrorAction Stop
Connect-ExchangeOnline

\$rows = Import-Csv -Path \$CsvPath
Ensure-Logs
\$state = Load-State
\$startIndex = \[int]\$state.Index

for (\$i = \$startIndex; \$i -lt \$rows.Count; \$i += \$BatchSize) {
\$chunk = \$rows\[\$i..(\[Math]::Min(\$i+\$BatchSize-1, \$rows.Count-1))]
Write-Host "Procesando lote \$i - \$(\[Math]::Min(\$i+\$BatchSize-1, \$rows.Count-1)) (tamaño: \$(\$chunk.Count))"

foreach (\$row in \$chunk) {
\$ext = \$row\.ExternalEmailAddress.Trim().ToLower()
if (\[string]::IsNullOrWhiteSpace(\$ext)) {
"MISSING\_EMAIL,`"$($row.DisplayName)`"" | Add-Content \$LogFail
continue
}```
if (Test-ContactExists -ExternalEmail $ext) {
  "$ext,`"$($row.DisplayName)`"" | Add-Content $LogExists
  continue
}

try {
  Invoke-WithQuotaRetry { New-ContactFromRow $row }
  "$ext,`"$($row.DisplayName)`"" | Add-Content $LogOk
} catch {
  $err = $_.Exception.Message.Replace("`n"," ").Replace("`r"," ")
  "$ext,`"$($row.DisplayName)`",$err" | Add-Content $LogFail
  if ($err -match "RecipientUsageExceededRecentQuotaException") {
    # Guarda estado antes de salir; relanza el script más tarde para reanudar
    Save-State ($i)
    Write-Host "Cuota alcanzada; estado guardado en $StateFile. Reanuda más tarde." -ForegroundColor Yellow
    Disconnect-ExchangeOnline -Confirm:$false
    exit 1
  }
}
```
}

Save-State (\$i + \$BatchSize)
}

Disconnect-ExchangeOnline -Confirm:\$false
Write-Host "Importación completada." </code></pre>

<p><strong>Cómo usarlo:</strong></p>
<ol>
  <li>Guarda el CSV y el script en la misma carpeta.</li>
  <li>Ejecuta el script: <code>.\Import-Contacts.ps1 -CsvPath .\contactos.csv -BatchSize 100</code>.</li>
  <li>Si aparece la cuota, el script <em>guarda estado</em> y sale. Vuelve a lanzarlo más tarde para reanudar exactamente donde se quedó.</li>
</ol>

<h3>Dividir CSV en archivos más pequeños</h3>
<p>Si prefieres <em>pre-fraccionar</em> el CSV en partes de 100 registros:</p>
<pre><code class="language-powershell">$rows = Import-Csv .\contactos.csv
$batchSize = 100
$outDir = ".\batches"; New-Item -ItemType Directory -Force $outDir | Out-Null

for (\$i=0; \$i -lt \$rows.Count; \$i += \$batchSize) {
\$chunk = \$rows\[\$i..(\[Math]::Min(\$i+\$batchSize-1,\$rows.Count-1))]
\$file = Join-Path \$outDir ("contacts\_{0\:d4}-{1\:d4}.csv" -f \$i, (\$i+\$chunk.Count-1))
\$chunk | Export-Csv \$file -NoTypeInformation -Encoding UTF8
} </code></pre>

<h3>Buenas prácticas de resiliencia</h3>
<ul>
  <li><strong>Etiqueta cada importación</strong> con <code>CustomAttribute1</code> (<code>Import202508</code>) para limpiar después si te arrepientes.</li>
  <li><strong>Registra todo</strong>: creados, ya existentes y fallidos, con mensajes de error.</li>
  <li><strong>Evita duplicados</strong> buscando por <code>ExternalEmailAddress</code> antes de crear.</li>
  <li><strong>Evita “todo o nada”</strong>: procesa por lotes, reanuda si hay cuota y no repitas lo ya creado.</li>
</ul>

<h2>Alternativas sin la cuota de “mail contacts”</h2>
<p>Si no necesitas que los contactos estén en el directorio (GAL), una <strong>libreta compartida</strong> elimina el cuello de botella de la cuota de creación de destinatarios.</p>

<h3>Buzón compartido como libreta común</h3>
<p>Este enfoque crea una carpeta <em>Contactos</em> central accesible por todos, sin crear objetos de directorio:</p>
<ol>
  <li><strong>Crea el buzón compartido</strong> (o desde el EAC):
    <pre><code class="language-powershell">New-Mailbox -Shared -Name "Contactos-Org" -DisplayName "Contactos de la organización" -PrimarySmtpAddress contactos-org@contoso.com
</code></pre>
  </li>
  <li><strong>Permisos</strong>:
    <ul>
      <li>Equipo que importará: <code>FullAccess</code> para cargar y mantener:
        <pre><code class="language-powershell">Add-MailboxPermission -Identity "Contactos-Org" -User "EquipoTI-Svc" -AccessRights FullAccess -AutoMapping:$true
  </li>
  <li>Todos los empleados: Reviewer sobre la carpeta <em>Contactos</em>:
    <pre>Add-MailboxFolderPermission "Contactos-Org:\Contacts" -User "TodosEmpleados" -AccessRights Reviewer
Si la carpeta aparece en otro idioma, ajusta la ruta, p. ej. "Contactos-Org:\Contactos"
</pre>
  </li>
</ul>

Importa el CSV en la carpeta Contactos de ese buzón desde Outlook (Asistente de importación CSV) o automatiza con API.

Ventajas: no creas destinatarios del directorio y no pegas en la cuota; los usuarios ven la libreta compartida y pueden buscar y enviar. Inconveniente: no aparece en la GAL y algunas reglas/flujo que requieren objetos de directorio no podrán usar estos contactos.

Automatización con Microsoft Graph (PowerShell) hacia el buzón compartido

Si quieres automatizar la carga en la carpeta de contactos del buzón compartido sin pasar por Outlook:

# Requisitos:
- Módulo Microsoft.Graph (o Microsoft.Graph.Beta si procede)
- Permiso Contacts.ReadWrite (delegado o aplicación)
- Acceso al buzón compartido "contactos-org@contoso.com"

Install-Module Microsoft.Graph -Scope CurrentUser
Import-Module Microsoft.Graph

Connect-MgGraph -Scopes "Contacts.ReadWrite"

\$UserId = "[contactos-org@contoso.com](mailto:contactos-org@contoso.com)"
\$csv = Import-Csv .\contactos.csv

Opcional: localizar carpeta "Contacts" explícita

\$contactsFolder = Get-MgUserContactFolder -UserId \$UserId | Where-Object {$\_.DisplayName -eq "Contacts"}

foreach (\$c in \$csv) {
\$email = @(@{ address = \$c.ExternalEmailAddress; name = \$c.DisplayName })
\$phones = @()
if (\$c.BusinessPhone) { \$phones += \$c.BusinessPhone }
New-MgUserContact -UserId \$UserId `    -DisplayName  $c.DisplayName`
-GivenName    \$c.GivenName `    -Surname      $c.Surname`
-CompanyName  \$c.Company `    -Department   $c.Department`
-EmailAddresses \$email \`
-BusinessPhones \$phones | Out-Null
} </code></pre>

<p>Con este método no creas contactos del directorio (no hay GAL), por lo que el límite de creación de destinatarios no aplica. Aun así, respeta límites de protección del servicio (ritmo de llamadas) y adecúa el tamaño del lote.</p>

<h3>Carpeta pública de Contactos</h3>
<p>Las carpetas públicas de tipo <em>Contactos</em> también sirven como libreta común:</p>
<ol>
  <li><strong>Prepara el buzón de carpetas públicas</strong> (si no existe):
    <pre><code class="language-powershell">New-Mailbox -PublicFolder -Name "PFMBX01"

Crea la carpeta y asegúrate de que su clase sea IPF.Contact (si la creas desde Outlook te permite elegir “Contactos”):

New-PublicFolder -Name "Contactos_Externos" -Path "\"
Add-PublicFolderClientPermission -Identity "\Contactos_Externos" -User "TodosEmpleados" -AccessRights Reviewer

Importa el CSV a esa carpeta desde Outlook (o con EWS/Graph si sincronizas a una PST temporal).

Comparativa rápida

Opción¿Aparece en GAL?¿Sujeto a cuota de creación?Acceso de usuariosVentajasInconvenientes
Mail contacts (directorio)Sí (límite temporal)Global, búsquedas y reglasMejor integración con flujo y políticasPuede parar por cuota; requiere lotes/reintentos
Buzón compartido → ContactosNoNo (no son objetos del directorio)Al montar el buzón compartidoSencillo, rápido, sin cuotas de destinatariosNo disponible en GAL; requiere publicar el buzón a usuarios
Carpeta pública de ContactosNoNoAl suscribirse a la carpetaAmplio alcance; permisos granularesGestión menos moderna; depende de Outlook

Visibilidad para todos los usuarios: puntos clave

  • Mail contacts: aparecen en la Lista global de direcciones de forma inmediata en el servicio, pero en Outlook en modo caché pueden tardar hasta varias horas en reflejarse por la actualización del Offline Address Book (OAB). Outlook en la web los ve antes.
  • Carpeta de Contactos compartida: no está en la GAL; los usuarios deben montar el buzón compartido o agregar la carpeta a Favoritos (carpetas públicas) para verla y buscar en ella.
  • ABP / Segmentación de libretas: si usas Address Book Policies, verifica que las listas incluyan MailContact para que los nuevos contactos sean visibles a cada segmento.

FAQ y resolución de problemas

¿Existe un límite global fijo? No. El umbral de “creaciones recientes” varía por inquilino y se recupera automáticamente con el tiempo.

¿Cuánto hay que esperar? Depende de la actividad del tenant. Una pauta práctica es reintentar tras varios minutos y, si persiste, ampliar los intervalos (backoff exponencial suave). El script de ejemplo lo hace por ti.

¿Cómo evito duplicados? Comprueba por ExternalEmailAddress antes de crear. Después puedes deduplicar por correo:

# Detectar duplicados por dirección externa
Get-MailContact -ResultSize Unlimited |
  Group-Object ExternalEmailAddress |
  Where-Object { $_.Count -gt 1 } |
  ForEach-Object {
    $_.Group | Select-Object Name,DisplayName,ExternalEmailAddress
  }

¿Cómo revertir una importación? Usa la etiqueta de lote en CustomAttribute1:

Get-MailContact -Filter "CustomAttribute1 -eq 'Import202508'" -ResultSize Unlimited |
  Remove-MailContact -Confirm:$false

El dominio del contacto externo no está aceptado en mi tenant, ¿afecta? No. Para mail contacts la dirección puede ser de cualquier dominio externo; no necesitas agregarlo como dominio aceptado.

¿Puedo “forzar” el límite? No hay parámetro para deshabilitar la protección del servicio. Si el caso de negocio lo justifica, abre un caso de soporte para evaluar opciones, pero planifica pensando en lotes y reintentos.

¿Puedo poblar una carpeta de Contactos por PowerShell? Exchange no tiene un cmdlet nativo para “insertar contactos en Outlook”. Para automatizar usa Microsoft Graph (como en el ejemplo) o EWS; desde PowerShell puedes llamar a esas API.

Checklist de ejecución recomendada

  • Decide si necesitas GAL (usa mail contacts) o solo una libreta compartida (usa buzón/carpeta pública).
  • Prepara y valida el CSV: columnas, email principal, deduplicado.
  • Elige BatchSize inicial de ~100 y etiqueta el lote con CustomAttribute1.
  • Ejecuta el importador resiliente y reanuda cuando la cuota lo indique.
  • Comprueba visibilidad (GAL u Outlook/OWA según el enfoque).
  • Documenta y guarda los logs (created, exists, failed).
  • Planifica mantenimiento y futuras altas con el mismo esquema de lotes.

Plantilla simple de importación (solo demostración)

Para escenarios pequeños, esta versión mínima puede bastar (pero no maneja cuotas con reanudación):

Connect-ExchangeOnline
Import-Csv .\contactos.csv | ForEach-Object {
  if (-not (Get-MailContact -Filter "ExternalEmailAddress -like '*$($_.ExternalEmailAddress)'" -ResultSize 1)) {
    New-MailContact -Name $.DisplayName -DisplayName $.DisplayName -ExternalEmailAddress $_.ExternalEmailAddress `
      -FirstName $.GivenName -LastName $.Surname
    Set-MailContact -Identity $.DisplayName -Company $.Company -Department $.Department -CustomAttribute1 $.CustomAttribute1
  }
}
Disconnect-ExchangeOnline -Confirm:$false

Consejos de operación y mantenimiento

  • Versiona los CSV y conserva el hash o fecha de corte para reproducibilidad.
  • Estandariza DisplayName (ej. Empresa - Nombre Apellido) para búsquedas coherentes.
  • Normaliza teléfonos en formato E.164 si los vas a usar en carpetas compartidas o CRM.
  • Privacidad: importa solo campos necesarios y documenta la fuente y base legal del tratamiento.

Conclusión práctica

El error RecipientUsageExceededRecentQuotaException no significa que “no puedas” crear más contactos, sino que estás creando demasiados en muy poco tiempo. La solución pasa por lotes pequeños, reintentos y scripts con reanudación. Si no necesitas GAL, una libreta compartida (buzón compartido o carpeta pública de Contactos) te ahorra el tope de creación y acelera el proyecto. Con los ejemplos de este artículo podrás elegir el enfoque adecuado, completar la importación sin sobresaltos y dar visibilidad a toda la organización.

Índice