← Todos los artículos

Verificado no significa que recibe: anatomía de un dominio en Amazon SES

📅 2 Sep 2026 ⏱ 7 min de lectura

Llega una captura de pantalla: Gmail devolvió un correo a hola@ un dominio de cliente con 550 5.1.1 Requested action not taken: mailbox unavailable. Se revisa lo de siempre y todo está impecable. El MX apunta a inbound-smtp.us-east-1.amazonaws.com, el SPF está, la identidad del dominio en SES dice Success y los tres CNAME de DKIM coinciden con los tokens guardados. El panel del producto lo muestra como verificado.

Rebote de Gmail: 'No se encontró la dirección', con la respuesta del servidor remoto 550 5.1.1 Requested action not taken: mailbox unavailable
El rebote tal como lo ve el remitente en Gmail. La línea que importa es la última: 550 5.1.1 la emite SES, no Gmail.

Y aun así no entra un solo correo. Este artículo es sobre por qué eso es perfectamente coherente, cómo se reconstruye lo que pasó con CloudTrail en cinco minutos, y el patrón de código que vuelve el problema imposible de repetir: reconciliar la base de datos contra AWS al arrancar.

¿Qué necesita un dominio para recibir correo en Amazon SES?

Tres recursos distintos, creados por tres llamadas distintas de la API. Se suele hablar de "verificar el dominio" como si fuera un solo paso, pero verificar solo toca al primero:

  1. La identidad (VerifyDomainIdentity + VerifyDomainDkim). Es lo que produce el TXT y los CNAME que pegas en tu DNS, y lo único que SES marca como verified. Sirve para enviar desde el dominio.
  2. La regla de recepción (CreateReceiptRule) dentro del rule set activo. Lista los dominios o direcciones que SES acepta y qué hacer con cada mensaje: guardarlo en S3, avisar por SNS, invocar una Lambda. Es lo único que decide si el correo entra.
  3. El configuration set (CreateConfigurationSet). Etiqueta el correo de salida para recibir eventos de rebote y queja. No afecta la recepción, pero sin él la lista de supresión nunca se alimenta.

El MX en DNS no forma parte de la lista: solo apunta el tráfico hacia SES. Una vez que el mensaje llega, quien lo acepta o lo rechaza es la regla.

Camino de un correo entrante: el DNS apunta a SES, SES consulta el rule set activo, y solo si hay una regla con el dominio entre sus destinatarios el mensaje se guarda en S3 y se avisa a la app; si no hay regla, responde 550. La identidad y el configuration set son recursos aparte que no participan en esa decisión. Gmail remitente MX SES inbound us-east-1 consulta Rule set activo ¿hay una regla con este dominio en Recipients? S3 + SNS → app reenvío, reglas, bandeja no 550 5.1.1 mailbox unavailable NO PARTICIPAN EN ESA DECISIÓN Identidad VerifyDomainIdentity + DKIM esto es lo "verificado" Configuration set etiqueta el correo de salida rebotes y quejas → SNS DNS del cliente MX, SPF, 3 CNAME de DKIM solo apunta el tráfico
La única pregunta que decide si un correo entra es si el rule set activo tiene una regla con ese dominio. La identidad, el DNS y el configuration set pueden estar perfectos y no cambia la respuesta.

¿Por qué un dominio verificado puede rebotar todo con 550 5.1.1?

Porque "verificado" describe la identidad, y la recepción depende de la regla. Son objetos independientes en la API de SES: se crean por separado, se borran por separado, y ninguna consulta de estado del uno dice nada del otro. GetIdentityVerificationAttributes devuelve Success feliz mientras el rule set no tiene ni idea de que el dominio existe.

El código de respuesta lo confirma. 550 5.1.1 es un rechazo permanente emitido durante la conversación SMTP, en el comando RCPT TO. SES no está diciendo "no pude entregarlo"; está diciendo "esa dirección no existe aquí". Y desde su punto de vista es verdad: sin regla, no hay ningún destino configurado para ese dominio.

Lo que hace al caso interesante es la asimetría de la señal. El rechazo se lo lleva el remitente: Gmail le arma un correo bonito con un ícono de interrogación. El dueño del dominio no recibe nada, porque para recibir el aviso tendría que recibir correo. Y el producto que administra el dominio tampoco, porque su verificación pregunta por la identidad. Todos los indicadores en verde, cero mensajes entrando.

¿Cómo se averigua quién borró un recurso en AWS?

Con CloudTrail, sin tocar el servidor. Cada llamada a la API de SES queda registrada con fecha, usuario IAM, parámetros y hasta la versión del SDK que la hizo. Una consulta por nombre de evento devuelve la historia completa:

aws cloudtrail lookup-events \
  --lookup-attribute AttributeKey=EventName,AttributeValue=DeleteReceiptRule \
  --query 'Events[].CloudTrailEvent' --output json

Repetida para DeleteIdentity, CreateReceiptRule y VerifyDomainIdentity, la línea de tiempo del dominio quedó así (horas en UTC):

CuándoEvento en CloudTrailQué significa
29 jul, 23:04:55DeleteReceiptRule, DeleteConfigurationSet, DeleteIdentityLa app borró el dominio completo. Tres llamadas en el mismo segundo, desde el user agent del servidor: es el endpoint de eliminar dominio.
29 jul, entre 23:05 y 23:50— (nada en CloudTrail)Se restauró un respaldo de la base de datos. La fila del dominio revivió; sus recursos en SES no, porque un respaldo de SQLite no sabe nada de AWS.
18 ago, 04:19:25VerifyDomainIdentity, VerifyDomainDkimAlguien notó que la identidad faltaba y la recreó a mano. Solo la identidad: la verificación volvió a verde.
1 sep, ~01:36— (el rebote vive en Gmail, no en AWS)Un cliente escribe a hola@ y recibe el 550.

Dos detalles que solo CloudTrail puede dar. El primero es el user agent: aws-sdk-js/3.995.0 … os/linux#6.12.47-fly apunta directo a la máquina de producción, así que no fue un humano en la consola. El segundo es el hueco: la restauración no deja rastro en AWS, y precisamente por eso es el sospechoso. Cuando la base de datos dice una cosa y AWS otra, y AWS no registra el cambio, el cambio fue en la base.

¿Cómo se evita que la base de datos y AWS se desincronicen?

Reconciliando al arrancar: para cada dominio de la base, preguntar a SES si sus recursos existen y crearlos si no. No hace falta detectar el caso raro; basta con que el estado deseado (la base) se imponga sobre el estado real (AWS) cada vez que el proceso levanta.

La función es corta porque la API ya es idempotente en casi todo. DescribeReceiptRule lanza RuleDoesNotExistException si falta, y CreateConfigurationSet lanza AlreadyExists si sobra; los dos errores son la señal, no un problema:

export async function ensureDomainInbound(domain: string) {
  const ruleName = `mailmask-${domain.replace(/\./g, "-")}`;
  let ruleCreated = false;

  try {
    await ses.send(new DescribeReceiptRuleCommand({ RuleSetName, RuleName: ruleName }));
  } catch (err) {
    if (!String(err).includes("RuleDoesNotExist")) throw err;
    await createReceiptRule(domain);   // S3Action + TopicArn, igual que en el alta
    ruleCreated = true;
  }

  await createConfigurationSet(domain); // tolera AlreadyExists
  await ensureConfigSetEventDestination(domain); // rebotes y quejas → SNS
  return { ruleCreated };
}

// Al arrancar, y también en POST /api/domains/:id/verify
for (const d of listAllDomains()) await ensureDomainInbound(d.domain);

El costo es medido, no estimado: tres llamadas a SES por dominio, unos 350 ms cada dominio en la reconciliación de producción. Con tres dominios, un segundo al arrancar. Con trescientos, dos minutos, que ya pediría paralelizar o correr en segundo plano, pero sigue siendo barato comparado con un mes de correo perdido.

Comparación: sin reconciliación, el alta crea los recursos una vez y nadie vuelve a comprobarlos, así que un respaldo restaurado deja la fila sin recursos para siempre. Con reconciliación, cada arranque compara la base contra SES y recrea lo que falte. SOLO EN EL ALTA POST /api/domains → crea los 3 recursos se restaura un respaldo de la base fila sin recursos, para siempre nadie vuelve a preguntar EN CADA ARRANQUE listAllDomains() de la base DescribeReceiptRule por dominio falta → CreateReceiptRule existe → nada, ~350 ms por dominio
La diferencia no está en el alta, que ya hacía lo correcto. Está en que la base de datos y AWS se comparan de forma rutinaria en vez de una sola vez.

¿Qué más conviene reconciliar mientras se está en eso?

Todo recurso externo que la base "recuerda" haber creado. Una vez que existe el bucle de arranque, agregarle comprobaciones cuesta una función cada una. Estas tres salieron de la misma sesión:

La regla general: si el código tiene una función create* hacia un servicio externo y la base guarda que se llamó, merece una ensure* idempotente que corra sin que nadie se lo pida.

Qué hace MailMask con esto

MailMask administra dominios de clientes sobre SES: cada alta crea los tres recursos, y cada baja los borra. Desde esta semana, además, cada arranque compara la lista de dominios contra el rule set y los configuration sets, y el botón de verificar del panel hace lo mismo para el dominio en cuestión. La verificación sigue mirando la identidad, porque eso es lo que la verificación significa; lo que cambió es que ya no es la única comprobación.

La lección que nos llevamos es más general que SES: una restauración de respaldo es una operación de escritura sobre la base, pero no sobre el mundo. Todo lo que vive fuera de la base y se creó a partir de ella queda potencialmente desfasado, y la única forma honesta de saberlo es preguntar.

Si te interesa cómo tratamos el correo entrante, sigue con qué es el email forwarding, con DKIM, SPF y DMARC, o con cómo una expresión regular tumba un servidor, que salió del mismo tipo de sesión.

Resumen rápido

Dominios que se cuidan solos

Reenvía, filtra y responde desde tu dominio, con SES bien configurado por ti. Desde $49 MXN/mes.

Crear cuenta gratis