¿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.
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 usuarios | Ventajas | Inconvenientes |
---|---|---|---|---|---|
Mail contacts (directorio) | Sí | Sí (límite temporal) | Global, búsquedas y reglas | Mejor integración con flujo y políticas | Puede parar por cuota; requiere lotes/reintentos |
Buzón compartido → Contactos | No | No (no son objetos del directorio) | Al montar el buzón compartido | Sencillo, rápido, sin cuotas de destinatarios | No disponible en GAL; requiere publicar el buzón a usuarios |
Carpeta pública de Contactos | No | No | Al suscribirse a la carpeta | Amplio alcance; permisos granulares | Gestió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.