En el momento en que necesitas validar una dirección de correo electrónico en
Rails, Active Record lo hace parecer trivial: escribes validates :email, ves
pasar el test y sigues adelante. Pero esa única línea responde solo a la primera de
las tres preguntas que plantea una validación de verdad. ¿Tiene la dirección la
forma correcta? ¿Puede su dominio recibir correo? ¿Existe realmente el buzón? Rails
ofrece una respuesta pulcra a la primera y nada en absoluto para las otras dos. Esta
guía construye cada capa como una validación idiomática de Rails — una regla
format y luego dos clases personalizadas de ActiveModel::EachValidator — y
muestra exactamente dónde se detiene el framework. Amplía la
guía de validación de correo en Ruby: Rails
envuelve los mismos bloques de la biblioteca estándar en la API declarativa de
validaciones.
La respuesta corta#
Usa validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } para la
sintaxis, un validador personalizado respaldado por la biblioteca estándar resolv
para la consulta MX y una API de verificación para la comprobación del buzón por
SMTP: tres validaciones sobre un mismo atributo, la más barata primero, cada una
saltándose cuando una capa más económica ya ha fallado. No abras una sesión SMTP
desde tu aplicación para sondear buzones por tu cuenta: el puerto 25 saliente está
bloqueado en la mayoría de los hosts, y la respuesta depende de la reputación de la
IP de envío y del greylisting que no querrás reimplementar. Un detalle distingue a
Rails de los frameworks con una palabra clave bail: Active Model ejecuta todos
los validadores que declares y recopila todos los errores, así que el
cortocircuito es una guarda que añades, no un flag que pasas. Cada capa descarta
direcciones de forma más barata que la anterior; solo la última puede dar una
dirección por válida.
Capa 1: validación de formato con una regla validates#
Rails se encarga de la capa uno, y no hace falta que escribas una expresión regular
para conseguirlo. La biblioteca uri de Ruby — que Rails ya carga — incluye un
patrón bien probado en URI::MailTo::EMAIL_REGEXP, así que pásaselo a una
validación format en lugar de pegar algo de Stack Overflow:
app/models/user.rb
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
end
Esa constante es la misma en la que se apoya la
guía de Ruby. Ya está anclada con \A y \z,
por lo que comprueba la cadena completa, y es deliberadamente permisiva: sigue la
definición de WHATWG/HTML5, que es más laxa que la de RFC 5322 y acepta sin
problemas jane@localhost porque no exige un punto en el dominio. Eso es una
ventaja. El trabajo de la capa uno es detectar erratas de dedo torpe en la entrada,
no litigar los RFC — lo cual
no serviría de nada de todos modos.
Lo que importa es saber dónde se detiene Rails. Todos los recursos de correo
integrados — la regla format, los scaffolds, el helper email_field — validan la
forma. Ninguno resuelve DNS ni contacta con un servidor de correo, así que
noreply-9f2x@gmail.com, typo@gmial.com y sales@company-that-folded.com pasan
todos. Los tres son no entregables. Rails no incluye ninguna comprobación de
entregabilidad.
Capa 2: ¿puede el dominio recibir correo?#
Esta es la primera capa que Rails no te da hecha, y es barata de acoplar. Un dominio
sin ruta de correo no puede aceptar correo para nadie, así que una sola consulta MX
elimina dominios muertos, nombres de empresa mal escritos y TLD inventados. Un
validador de Rails no es más que una clase que hereda de
ActiveModel::EachValidator e implementa validate_each, así que envuelve la
consulta MX de la biblioteca resolv en uno:
app/validators/deliverable_domain_validator.rb
require "resolv"
class DeliverableDomainValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank? || record.errors[attribute].present?
domain = value.rpartition("@").last
return if domain.present? && mail_route?(domain)
record.errors.add(attribute, options[:message] || "can't receive email")
end
private
def mail_route?(domain)
records = Resolv::DNS.open do |dns|
dns.getresources(domain, Resolv::DNS::Resource::IN::MX)
end
!records.empty?
rescue Resolv::ResolvError, Resolv::ResolvTimeout
false
end
end
Resolv::DNS.open te entrega un resolutor y lo cierra cuando el bloque termina, y
getresources devuelve un array de registros MX — vacío cuando el dominio no
publica ninguno o no existe, que es exactamente el caso de "sin ruta". El rescue
cubre el otro modo de fallo; ten en cuenta que Resolv::ResolvTimeout no es una
subclase de Resolv::ResolvError, así que nombra ambos si quieres que un servidor de
nombres lento pase de largo limpiamente. Colócalo en el modelo mediante su clave
inferida — Rails convierte en camel case deliverable_domain a
DeliverableDomainValidator y encuentra la clase que Zeitwerk cargó automáticamente
desde app/validators:
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
deliverable_domain: true
La guarda return if ... record.errors[attribute].present? es todo el truco.
Active Model ejecuta todos los validadores que declares y agrega los errores — no
hay bail. Pero las validaciones se ejecutan en el orden en que las declaras, así
que para cuando esta se dispara, un fallo de format ya está en
record.errors[:email], y la guarda se salta la consulta DNS por completo. Una
dirección malformada nunca gasta una ida y vuelta al servidor de nombres.
Capa 3: ¿existe realmente el buzón?#
Las capas uno y dos solo pueden descartar una dirección. Un dominio puede publicar
registros MX perfectos y aun así no tener buzón en la dirección que manejas —
noreply-9f2x@gmail.com es sintaxis válida en una ruta de correo activa, y sigue
siendo un buzón que nunca se creó. Confirmar un buzón concreto significa la
conversación de entrega SMTP: conectar con el host de correo, emitir RCPT TO, leer
la respuesta y desconectar antes de enviar nada. Hay más que eso —
cómo funciona la verificación de correo
recorre el pipeline completo, incluidos los dominios catch-all que aceptan todas las
direcciones y derrotan un sondeo ingenuo.
net/smtp de Ruby abrirá encantado esa conversación. El problema no es el código;
es la red. Ejecuta un sondeo SMTP desde tu servidor de aplicación y tres cosas salen
mal: la mayoría de los proveedores de nube bloquean el puerto 25 saliente por
completo, la respuesta depende de la reputación de la IP desde la que conectas, y los
servidores receptores aplican greylisting y limitan la tasa a remitentes
desconocidos — de modo que un sondeo que funciona en tu portátil falla en silencio,
o te mete en una lista de bloqueo, en producción. Esta es la capa que vale la pena
delegar.
Llamar a Qualisend desde un validador personalizado#
La delegación se mantiene limpia porque una API de verificación no es más que otro
validador. El endpoint de verificación de Qualisend ejecuta el pipeline completo —
sintaxis, DNS y el sondeo del buzón por SMTP — desde infraestructura con reputación
gestionada, y devuelve un único veredicto. Es un simple POST JSON, así que el
net/http de la biblioteca estándar lo cubre sin gems. Lee la clave del entorno en
lugar de dejarla codificada a mano:
app/validators/deliverable_email_validator.rb
require "net/http"
require "json"
require "uri"
class DeliverableEmailValidator < ActiveModel::EachValidator
VERIFY_URL = URI("https://api.qualisend.com/v1/verify")
def validate_each(record, attribute, value)
return if value.blank? || record.errors[attribute].present?
result = verify(value)
return if result.nil? # our outage shouldn't block a real signup
if result["status"] == "undeliverable"
record.errors.add(attribute, options[:message] || "appears to be undeliverable")
end
end
private
def verify(email)
request = Net::HTTP::Post.new(VERIFY_URL)
request["Authorization"] = "Bearer #{ENV.fetch('QUALISEND_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(email: email)
response = Net::HTTP.start(
VERIFY_URL.hostname, VERIFY_URL.port,
use_ssl: true, open_timeout: 5, read_timeout: 10
) { |http| http.request(request) }
return nil unless response.is_a?(Net::HTTPSuccess)
JSON.parse(response.body)["result"]
rescue StandardError
nil # unreachable or slow — treat as unknown and re-verify later
end
end
Ese endpoint es un marcador de posición — consulta la
referencia de la API para conocer la URL base exacta, la forma de la
petición y cómo generar una clave con alcance limitado. La respuesta envuelve un
objeto result:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "catch_all": false }
}
}
status es uno de deliverable, risky, undeliverable o unknown. El validador
de arriba solo falla en firme con undeliverable y deja pasar risky y unknown,
de modo que puedes decidir aguas abajo qué hacer con ellos — filtrar por score, o
ramificar según una entrada de sub_flags como disposable — en lugar de convertir
una dirección dudosa en un error de formulario. Y cuando la propia petición falla,
verify devuelve nil y el validador no añade ningún error: las capas de sintaxis y
MX ya se ejecutaron localmente, así que ablandar únicamente la capa de red evita que
una caída transitoria bloquee un registro real. Trata el JSON de arriba como la
forma, no como el contrato; la lista completa de campos vive en la
documentación para desarrolladores.
Juntar las capas para validar una dirección de correo en Rails#
Declara las tres validaciones sobre el atributo, la más barata primero. El orden de declaración es el orden de ejecución, y cada guarda se salta cuando una capa más económica ya ha fallado, así que la regla compuesta se lee de arriba abajo exactamente como se ejecuta:
app/models/user.rb
class User < ApplicationRecord
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
deliverable_domain: true,
deliverable_email: true
end
Ahora user.save solo llega a la API con direcciones que ya han superado la
sintaxis y la comprobación MX — una errata falla en format y nunca gasta una ida y
vuelta al servidor de nombres ni un crédito de API, y un dominio muerto falla en
deliverable_domain antes de la llamada de red. user.valid? ejecuta toda la
cadena, y user.errors[:email] carga la capa que haya objetado. Ese orden, y no el
framework, es lo que hace fiable la validación — la misma forma que la
versión en Ruby, expresada como validaciones
declarativas en lugar de un método encadenado a mano.
Como son validaciones ordinarias, funcionan todas las opciones estándar. Limita las
dos capas de red con if: :email_changed? para que un user.update(name: ...) sin
relación no vuelva a ejecutar una consulta DNS ni a gastar un crédito en una
dirección que ya verificaste. Mantén la ruta síncrona en las capas locales rápidas
más el veredicto inmediato de la API en el registro; cualquier cosa más pesada
pertenece a un trabajo en segundo plano. La
guía de registro serverless muestra el
patrón en tiempo real, y cómo limpiar una lista de correo
cubre el lado por lotes.
Preguntas frecuentes#
¿Tiene Rails una forma integrada de comprobar si un correo es entregable?#
No. Todos los recursos de correo integrados — la validación format con
URI::MailTo::EMAIL_REGEXP, los scaffolds, el helper email_field — validan solo la
forma. Ninguno resuelve DNS ni contacta con un servidor de correo, así que un
dominio inventado o un buzón inexistente pasan. Añade una consulta MX y una
comprobación del buzón por SMTP como validadores personalizados para cubrir la
entregabilidad, ya que Rails no incluye ninguna de las dos.
¿Cómo escribo un validador de correo personalizado en Rails?#
Hereda de ActiveModel::EachValidator e implementa
validate_each(record, attribute, value), llamando a record.errors.add(attribute, message) cuando el valor falle. Guarda la clase en app/validators para que
Zeitwerk la cargue automáticamente y, después, conéctala mediante su clave inferida —
un DeliverableDomainValidator se engancha como deliverable_domain: true. Acepta
las mismas opciones que cualquier validación, así que if:, on: y un message:
personalizado funcionan sin cambios.
¿Puedo verificar que existe un buzón en Rails sin una API?#
En parte. La biblioteca estándar resolv confirma que el dominio acepta correo, lo
que descarta gratis los dominios muertos desde dentro de un validador personalizado.
Confirmar el buzón implica una conversación SMTP que puedes abrir con net/smtp
pero que no deberías ejecutar desde tu servidor de aplicación — el puerto 25 saliente
está bloqueado en muchos sitios y el resultado depende de la reputación de tu IP de
envío. Esa capa final es lo que ejecuta un servicio de verificación desde
infraestructura con reputación gestionada creada para ello.
¿Debo validar los correos en el registro o al limpiar una lista?#
Ambas cosas, con distinta profundidad. Ejecuta las capas de sintaxis y MX de forma
síncrona en el registro — son lo bastante rápidas para bloquear la petición y dar
respuesta instantánea — y actúa allí también sobre el veredicto inmediato de la API.
Reserva la verificación más profunda y por lotes para
limpiar una lista existente, donde la latencia no
importa y la exhaustividad sí. Limitar los validadores de red con
if: :email_changed? evita que las actualizaciones normales vuelvan a verificar una
dirección que ya has comprobado.
¿Listo para añadir la capa del buzón? La referencia de la API tiene el
endpoint /verify, las claves con alcance limitado y fragmentos para copiar y pegar,
o pega una dirección en el comprobador de correo gratuito y
observa cómo una cadena sintácticamente perfecta vuelve como no entregable.