Skip to content
Empieza con 100 créditos de verificación gratis
Qualisend
Todos los artículos
Ingeniería / 9 de mayo de 2026

Cómo validar una dirección de correo electrónico en Spring Boot

9 minutes read

Qualisend team
Una ventana de código de Spring Boot validando un correo a través de las capas de formato, DNS y buzón hasta un veredicto de entregabilidad

Validar una dirección de correo electrónico en Spring Boot son en realidad tres comprobaciones que se esconden bajo una sola anotación. Pon @Email en un campo de un DTO, conecta @Valid en el controlador y Spring confirmará encantado que la cadena tiene forma de dirección, que es lo menos útil de las tres cosas que de verdad quieres saber. La validación real está por capas: una comprobación de formato barata, una consulta DNS de la ruta de correo del dominio y un sondeo SMTP del buzón. Jakarta Bean Validation te da la primera capa gratis y un punto de extensión limpio —el ConstraintValidator— para el resto. Esta guía construye cada capa con código funcional y muestra exactamente dónde se detiene el framework y una API de verificación toma el relevo.

La respuesta corta#

Usa la @Email de Jakarta Bean Validation para la sintaxis, una consulta MX por JNDI para el dominio y una API de verificación para la comprobación SMTP del buzón, todo envuelto en una restricción personalizada @Deliverable que se ejecuta después de que @Email pase. No abras conexiones SMTP desde tu aplicación para sondear buzones tú mismo: 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. Cada capa descarta direcciones de forma más barata que la anterior; solo la última puede dar una dirección por buena. Es la misma estructura de tres capas que en la guía de validación en Java puro, adaptada al ciclo de vida de validación de Spring.

Capa 1: validación de formato con @Email y @Valid#

Spring Boot se encarga de la primera capa a través de Jakarta Bean Validation. Añade spring-boot-starter-validation al proyecto y anota el campo del DTO de la petición con jakarta.validation.constraints.Email. Combínala con @NotBlank, porque las restricciones de Bean Validation tratan null como válido por diseño: @Email devuelve true para un valor ausente, así que la presencia es un asunto aparte:

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record SignupRequest(
    @NotBlank(message = "Email is required.")
    @Email(message = "Enter a valid email address.")
    String email
) {}

La anotación @Valid en el argumento del controlador es lo que dispara la validación antes de que se ejecute el cuerpo de tu método. Una restricción que falla cortocircuita en una MethodArgumentNotValidException, que Spring convierte en un 400 por ti:

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SignupController {

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@Valid @RequestBody SignupRequest request) {
        // If we reach here, request.email() is well-formed.
        return ResponseEntity.ok("Welcome, " + request.email());
    }
}

Ten muy claro qué te aporta esto. @Email comprueba la forma contra un patrón por defecto permisivo: nunca resuelve el DNS, nunca abre un socket y no tiene ni idea de si example.com existe. definitely-fake@gmail.com pasa. typo@gmial.com pasa. Ambas no son entregables, y ninguna anotación que solo lea la cadena te lo dirá jamás, por la misma razón por la que la validación de correo con regex falla: sintaxis y entregabilidad son preguntas distintas. Una es un hecho sobre la cadena; la otra es un hecho sobre internet.

Capa 2: ¿puede el dominio recibir correo?#

Un dominio sin registros MX no puede aceptar correo de nadie, así que una sola consulta MX elimina dominios muertos, nombres de empresa mal escritos y TLD inventados. Como Spring Boot corre sobre la JVM, esto lo consigues sin ninguna dependencia externa: el JDK incluye un proveedor de DNS para JNDI, así que resuelves el atributo MX a través de un DirContext.

import java.util.Hashtable;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;

public final class MailRoute {

    public static boolean exists(String domain) {
        Hashtable<String, String> env = new Hashtable<>();
        env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
        env.put("com.sun.jndi.dns.timeout.initial", "2000"); // don't hang on a slow resolver
        env.put("com.sun.jndi.dns.timeout.retries", "1");
        try {
            DirContext ctx = new InitialDirContext(env);
            try {
                Attributes attrs = ctx.getAttributes(domain, new String[] { "MX" });
                Attribute mx = attrs.get("MX");
                return mx != null && mx.size() > 0;
            } finally {
                ctx.close();
            }
        } catch (NamingException e) {
            // NameNotFoundException → domain doesn't exist; no MX attribute → no mail route
            return false;
        }
    }
}

Podrías envolver esto en su propio ConstraintValidator y apilarlo como una segunda anotación. Pero la capa SMTP necesita el mismo trabajo de DNS más una conversación en vivo con el host de correo, así que, en lugar de mantener un validador MX por separado y luego un sondeo del buzón, es más limpio dejar que una sola llamada a la API se ocupe de ambas capas de red. La comprobación MX local sigue siendo útil como un fast-fail opcional; el servicio de verificación hace el paso del DNS internamente de todas formas.

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 ningún buzón en la dirección que manejas: noreply-9f2x@gmail.com es sintaxis válida sobre una ruta de correo en funcionamiento, y sigue siendo un buzón que nunca se creó. Confirmar un buzón concreto implica 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 matices —los dominios catch-all aceptan todas las direcciones y derrotan un sondeo ingenuo—; cómo funciona la verificación de correo recorre el pipeline completo.

Puedes programar SMTP desde la JVM con un Socket en crudo, pero no deberías ejecutarlo desde tu aplicación. La mayoría de proveedores cloud bloquean el puerto 25 saliente, la respuesta depende de la reputación de la IP desde la que te conectas y los servidores receptores hacen greylisting y limitan la tasa a remitentes desconocidos, así que un sondeo que pasa en una prueba local falla en silencio, o te mete en una lista de bloqueo, en producción. Esta es la capa que merece la pena delegar.

El gancho idiomático de Spring es una restricción personalizada. Define una anotación @Deliverable y un validador que llame a la API de verificación. Empieza por la anotación:

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Target({ FIELD, PARAMETER })
@Retention(RUNTIME)
@Constraint(validatedBy = DeliverableValidator.class)
public @interface Deliverable {
    String message() default "This email address appears to be undeliverable.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

Como spring-boot-starter-validation conecta Hibernate Validator a través del SpringConstraintValidatorFactory de Spring, el validador es un bean gestionado, así que la inyección con @Value y el autowiring por constructor funcionan dentro de él. Java 11+ incluye java.net.http.HttpClient, de modo que no necesitas ninguna dependencia HTTP; combínalo con Jackson, que Spring Boot ya pone en el classpath. Guarda la clave en una variable de entorno, nunca la escribas a mano en el código:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;

@Component
public class DeliverableValidator implements ConstraintValidator<Deliverable, String> {

    // Placeholder endpoint — see /developers for the current base URL and shape.
    private static final String ENDPOINT = "https://api.qualisend.com/v1/verify";

    private final HttpClient http = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();
    private final ObjectMapper mapper = new ObjectMapper();

    @Value("${qualisend.api-key}") // resolves from QUALISEND_API_KEY, holds YOUR_API_KEY
    private String apiKey;

    @Override
    public boolean isValid(String email, ConstraintValidatorContext context) {
        if (email == null || email.isBlank()) {
            return true; // @NotBlank and @Email own emptiness and format
        }
        try {
            String body = mapper.writeValueAsString(Map.of("email", email));
            HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(ENDPOINT))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(10))
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

            HttpResponse<String> response =
                http.send(request, HttpResponse.BodyHandlers.ofString());

            // On our own outage or a rate limit, don't block a real signup.
            if (response.statusCode() >= 400) {
                return true;
            }

            // The verdict lives inside a `result` envelope.
            JsonNode result = mapper.readTree(response.body()).get("result");
            String status = result.get("status").asText(); // deliverable | risky | undeliverable | unknown
            return !"undeliverable".equals(status);         // hard-fail only a confirmed miss
        } catch (IOException e) {
            return true; // transient network failure → treat as unknown, re-verify later
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return true;
        }
    }
}

Apunta la propiedad a la variable de entorno en application.properties:

qualisend.api-key=${QUALISEND_API_KEY}

El endpoint de arriba es un marcador de posición —consulta la referencia de la API para conocer la URL base y la forma exacta de la petición—, pero la respuesta se lee como un envoltorio { "result": { ... } }:

{
  "result": {
    "status": "deliverable",
    "score": 95,
    "reason": null,
    "sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
  }
}

status es uno de deliverable, risky, undeliverable o unknown. El validador de arriba solo hace fallar en firme a undeliverable y deja pasar risky y unknown, para que decidas más adelante qué hacer con ellos —poner una condición sobre el score o ramificar según una entrada de sub_flags como disposable— en vez de convertir una dirección dudosa en un error de formulario. Trata el JSON de arriba como la forma, no como el contrato; la lista completa de campos está en la documentación para desarrolladores.

Ejecutar la comprobación de entregabilidad después de @Email con grupos de validación#

Hay un truco. Por defecto, Bean Validation ejecuta todas las restricciones de un campo sin importar las demás, así que @Deliverable se dispararía —y gastaría un crédito de la API— incluso cuando @Email ya sabe que la cadena es basura. Lo que quieres es un orden: primero el formato, la ida y vuelta a la red solo si pasa.

Bean Validation expresa el orden con grupos y un @GroupSequence. Define una interfaz marcadora para la capa de red y luego pon @Deliverable en ese grupo mientras @NotBlank y @Email se quedan en el grupo por defecto:

public interface NetworkChecks {}

Ahora redefine la secuencia del grupo por defecto en el propio DTO. Listar la clase primero (que representa sus propias restricciones por defecto) y NetworkChecks en segundo lugar le dice a Hibernate Validator que valide primero el formato y solo llegue a la comprobación de entregabilidad si ese paso está limpio:

import jakarta.validation.GroupSequence;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

@GroupSequence({ SignupRequest.class, NetworkChecks.class })
public record SignupRequest(
    @NotBlank(message = "Email is required.")
    @Email(message = "Enter a valid email address.")
    @Deliverable(groups = NetworkChecks.class)
    String email
) {}

Dos cosas hacen que esto funcione. Como la secuencia redefine el grupo por defecto, el simple @Valid de tu controlador la sigue disparando, sin necesidad de un argumento de grupo @Validated(...). Y como los pasos de la secuencia se cortocircuitan, una dirección mal formada falla en el paso de @Email y la API nunca se llama. Ese orden es todo el truco: la comprobación local barata protege a la de red, que es cara.

Uniendo las capas para validar una dirección de correo en Spring Boot#

El DTO de arriba ya compone las tres capas; el controlador se queda con el @Valid @RequestBody de una línea de la primera capa. Lo único que falta es dar forma a la respuesta de error. Spring emite una MethodArgumentNotValidException ante cualquier restricción que falle, así que un pequeño @RestControllerAdvice la convierte en un 400 limpio indexado por campo:

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class ValidationErrorHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> onInvalid(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
            .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return errors;
    }
}

Las dos capas locales no cuestan nada y atrapan casi toda la basura al instante; la API solo se ejecuta con las direcciones que merecen la ida y vuelta a la red. Esa es la misma estructura que encontrarás en la versión en Java de esta guía, porque lo que hace que la validación funcione es el escalonamiento por capas, no el framework. Ejecuta este pipeline síncrono y rápido en el registro y reserva los pasos más pesados para el trabajo de back-office: la guía de registro serverless muestra el patrón en tiempo real, y cómo limpiar una lista de correo cubre la parte por lotes.

Preguntas frecuentes#

¿Basta con @Email para validar una dirección de correo en Spring Boot?#

Para la sintaxis, es la herramienta adecuada de la primera capa: jakarta.validation.constraints.Email está mejor probada que una expresión regular hecha a mano y se integra con @Valid y el manejo de errores de Spring sin coste alguno. Pero solo valida la forma: nunca resuelve el DNS ni contacta con un servidor de correo, y trata null como válido, así que combínala con @NotBlank. Que pase significa «parece un correo», no «se va a entregar»: añade una consulta MX y una comprobación SMTP del buzón antes de fiarte de la dirección.

¿Cómo escribo una anotación de validación de correo personalizada en Spring Boot?#

Crea una anotación meta-anotada con @Constraint(validatedBy = ...) y una clase que implemente ConstraintValidator<TuAnotacion, String>. Pon tu lógica en isValid, devolviendo false para que falle. Con spring-boot-starter-validation, el validador es un bean de Spring, así que puedes inyectarle configuración y clientes HTTP. Devuelve true para null o cadenas en blanco para que @NotBlank y @Email se ocupen de esos casos, y anota el campo del DTO para aplicarla.

¿Cómo hago que la comprobación de entregabilidad se ejecute solo después de que @Email pase?#

Usa grupos de validación. Coloca la costosa restricción @Deliverable en un grupo marcador como NetworkChecks y luego añade @GroupSequence({ SignupRequest.class, NetworkChecks.class }) al DTO para redefinir su secuencia de grupo por defecto. Como los pasos de la secuencia se cortocircuitan, @Email se ejecuta primero y la restricción respaldada por la API solo se dispara si el formato ya es válido; y un simple @Valid la sigue activando, porque la secuencia redefine el grupo por defecto.

¿Debería validar los correos en el registro o al limpiar una lista?#

En ambos, a distinta profundidad. Ejecuta las capas de formato y entregabilidad de forma síncrona en el registro —son lo bastante rápidas para bloquear la petición y dar feedback instantáneo— y actúa también allí sobre el status 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í.


¿Listo para añadir la capa del buzón? Introduce una dirección sintácticamente perfecta en el comprobador de correo gratuito para ver cómo una cadena aprobada por @Email vuelve como undeliverable, y luego conecta ese mismo veredicto a un ConstraintValidator con el endpoint /verify y el envoltorio result completo en la referencia de la API.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started