Verificado no significa que recibe: anatomía de un dominio en Amazon SES
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.
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:
- 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. - 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. - 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.
¿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ándo | Evento en CloudTrail | Qué significa |
|---|---|---|
| 29 jul, 23:04:55 | DeleteReceiptRule, DeleteConfigurationSet, DeleteIdentity | La 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:25 | VerifyDomainIdentity, VerifyDomainDkim | Alguien 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.
¿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:
- El event destination del configuration set. Es lo que publica rebotes y quejas a SNS para alimentar la lista de supresión. Se crea con
CreateConfigurationSetEventDestinationy, si la variable con el ARN del tópico no existía cuando se dio de alta el dominio, el set queda mudo. El bucle lo rellena en dominios viejos sin script de migración. - La suscripción HTTPS del tópico SNS al webhook.
ListSubscriptionsByTopicdice si existe y si sigue enPendingConfirmation; en ese caso, volver a suscribir dispara una confirmación nueva. - La firma de esa confirmación. Un detalle que solo aparece al ejercitar la ruta: la cadena a firmar de un
SubscriptionConfirmationincluye el campoToken, ySignatureVersion: "2"significa SHA256, no SHA1. Un verificador escrito solo paraNotificationrechaza toda confirmación, y la suscripción se queda pendiente para siempre sin decir por qué.
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
- Un dominio en SES son tres recursos: identidad, regla de recepción y configuration set. "Verificado" habla solo del primero.
- Sin regla en el rule set activo, SES responde
550 5.1.1a todo, y el único que lo ve es el remitente. - CloudTrail reconstruye la historia de cualquier recurso de AWS por nombre de evento, con usuario, parámetros y user agent.
- Restaurar un respaldo no restaura AWS. Una reconciliación idempotente al arrancar, a ~350 ms por dominio, cierra ese hueco para siempre.