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

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

11 min de lectura

Qualisend team
Cómo validar una dirección de correo electrónico en Java

Validar una dirección de correo electrónico en Java son en realidad tres comprobaciones con un solo nombre. La mayoría de los tutoriales recurren a una expresión regular, confirman que la cadena tiene la forma de una dirección y dan el tema por zanjado, pero la forma es la menos útil de las tres cosas que de verdad quieres saber. La validación real es por capas: una comprobación de formato barata, una consulta DNS de la ruta de correo del dominio y un sondeo del buzón por SMTP. La biblioteca estándar de Java y Jakarta Mail cubren las dos primeras con soltura; la tercera es un problema de red que conviene delegar. Esta guía construye cada capa con código funcional y muestra exactamente dónde se detiene cada una.

La respuesta rápida#

Usa jakarta.mail.internet.InternetAddress (o Apache Commons Validator) para la sintaxis, el proveedor DNS de JNDI incorporado en el JDK para la consulta MX y una API de verificación para la comprobación del buzón por SMTP: primero lo más barato, cortocircuitando en cuanto una de ellas es decisiva. No abras conexiones 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. Cada capa descarta direcciones de forma más barata que la anterior; solo la última puede dar por buena una dirección.

Capa 1: sintaxis con Jakarta Mail#

Jakarta Mail —la biblioteca que pasó a llamarse así cuando javax.mail se trasladó al espacio de nombres de Jakarta EE— incluye un analizador de direcciones en el que puedes apoyarte en lugar de escribir una expresión regular a mano. Construye un InternetAddress y llama a validate(): el constructor analiza la dirección, y validate() impone la sintaxis del RFC 822, lanzando AddressException ante cualquier cosa mal formada.

import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;

public static boolean isValidSyntax(String email) {
    if (email == null || email.length() > 320) {
        return false;
    }
    try {
        InternetAddress address = new InternetAddress(email);
        address.validate();
        // Rejects "Jane <jane@example.com>": the parser keeps only the address part.
        return email.equals(address.getAddress());
    } catch (AddressException e) {
        return false;
    }
}

Esa última comparación cierra un hueco que la mayoría de los ejemplos pasa por alto. InternetAddress está documentado para analizar la forma Personal Name <user@host.domain>, y validate() comprueba solo la parte de la dirección, así que Jane <jane@example.com> supera ambos pasos. Sin esa comprobación, la capa 2 recibiría el dominio example.com>. Exigir que getAddress() sea igual a la entrada original rechaza cualquier cosa que lleve un nombre o corchetes angulares.

Conviene saber una cosa: validate() es permisivo con el dominio. Acepta jane@localhost y jane@example —sin exigir punto— porque el RFC 822 permite dominios sin punto. Aquí eso está bien. La capa 1 es una puerta de formato, no una comprobación de entregabilidad, y la consulta DNS de la capa 2 es la que de verdad decide si el dominio puede recibir correo. Mantén esta comprobación permisiva y deja que sea el DNS quien descarte; perseguir la perfección del formato con una expresión regular más grande es una batalla perdida de todos modos.

Si trabajas con un jar antiguo de javax.mail, el código es idéntico salvo por el nombre del paquete. Y si ya dependes de Apache Commons Validator, su EmailValidator es una buena alternativa para la primera capa: es un pelín más estricto (rechaza los dominios sin punto por defecto):

import org.apache.commons.validator.routines.EmailValidator;

boolean valid = EmailValidator.getInstance().isValid(email);

Prefiere Jakarta Mail si ya lo usas para enviar correo: una dependencia menos. Cualquiera de las dos bibliotecas solo responde a la pregunta de la sintaxis. Lo mismo ocurre con la anotación @Email de Bean Validation, que cubre la guía de Spring Boot.

Expresión regular para validar correos en Java (si no queda otra)#

Si no puedes añadir una biblioteca, o necesitas una sola regla compartida con el front end, mantén la expresión regular permisiva y compílala una sola vez:

import java.util.regex.Pattern;

// Dot-separated runs with no whitespace, @ or quotes, one @,
// then a domain with at least one dot.
private static final Pattern EMAIL = Pattern.compile(
    "[^\\s@.\"]+(?:\\.[^\\s@.\"]+)*@[^\\s@.]+(?:\\.[^\\s@.]+)+",
    Pattern.UNICODE_CHARACTER_CLASS);

public static boolean looksLikeEmail(String email) {
    return email != null && email.length() <= 320 && EMAIL.matcher(email).matches();
}

Es el mismo patrón permisivo que recomienda por qué falla la validación de correo con expresiones regulares: el punto queda fuera de los tramos de la parte local, así que a..b@example.com, .a@example.com y a.@example.com fallan las tres. No necesita ^ ni $ porque matches() debe coincidir con toda la entrada; find() busca una subcadena que coincida, así que aceptaría junk jane@example.com junk. UNICODE_CHARACTER_CLASS hace que \s coincida con los espacios Unicode, como ocurre en JavaScript.

Evita dos trampas habituales: un límite de TLD como [a-zA-Z]{2,7}, que rechaza TLD reales como .photography, y los enormes patrones «RFC 5322» que aceptan formas que ningún formulario de registro quiere. Una coincidencia demuestra la forma, nada más.

Capa 2: ¿puede el dominio recibir correo?#

Un dominio sin ruta de correo no puede aceptar correo de nadie, así que una consulta DNS rápida (primero MX y, como alternativa, la propia dirección del dominio) elimina dominios muertos, nombres de empresa mal escritos y TLD inventados. Java lo hace sin ninguna dependencia externa: el JDK incluye un proveedor de servicio DNS para JNDI, de modo que resuelves el atributo MX a través de un DirContext.

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

public static boolean hasMailRoute(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");

    DirContext ctx = null;
    try {
        ctx = new InitialDirContext(env);
        Attribute mx = ctx.getAttributes(domain, new String[] { "MX" }).get("MX");
        if (mx != null && mx.size() > 0) {
            return true;
        }
        // No MX: mail goes to the domain's own address (A or AAAA), the implicit MX.
        // One type per call: asking for several at once sends an ANY query.
        for (String type : new String[] { "A", "AAAA" }) {
            Attribute addr = ctx.getAttributes(domain, new String[] { type }).get(type);
            if (addr != null && addr.size() > 0) {
                return true;
            }
        }
        return false;
    } catch (NameNotFoundException e) {
        return false; // NXDOMAIN: the domain doesn't exist
    } catch (NamingException e) {
        return true;  // timeout or resolver failure: can't tell, so don't reject
    } finally {
        if (ctx != null) {
            try { ctx.close(); } catch (NamingException ignored) { }
        }
    }
}

Separa el dominio de la dirección por la última @ para que las partes locales entrecomilladas no te den problemas:

String domain = email.substring(email.lastIndexOf('@') + 1);

hasMailRoute("gmail.com");               // true
hasMailRoute("company-that-folded.com"); // false

Un atributo MX ausente o vacío significa que el dominio no publica ningún registro MX; una NameNotFoundException (una subclase de NamingException) significa que no resuelve en absoluto. Algunos dominios aceptan correo en un registro A o AAAA con un MX implícito, así que el fragmento recurre a consultar "A" y "AAAA" cuando el conjunto MX vuelve vacío, un tipo por llamada: JNDI convierte una petición de varios tipos en una consulta ANY, que muchos servidores DNS responden solo con un marcador mínimo. Para la inmensa mayoría de las direcciones reales, una comprobación MX es el filtro adecuado. Como la consulta se bloquea a la espera de un resolver DNS, mantén los tiempos de espera de arriba en una ruta de registro y trata un fallo transitorio como «comprobar más tarde», no como un rechazo tajante: un tropiezo del DNS no debería ahuyentar a un cliente real. Por eso cualquier otra NamingException, como un tiempo de espera agotado, devuelve true y deja que decida la capa 3. Para comprobar un dominio a mano, usa la consulta de registros MX.

Capa 3: ¿existe de verdad el buzón?#

Las capas 1 y 2 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 en un dominio con una ruta de correo activa, y sigue siendo un buzón que nunca se creó. Confirmar que existe un buzón concreto implica la conversación de entrega SMTP: conectar con el host de correo, emitir RCPT TO, leer el código de respuesta y desconectar antes de enviar nada. Hay más matices —los dominios catch-all aceptan cualquier dirección y desbaratan un sondeo ingenuo— y cómo funciona la verificación de correo recorre el proceso completo.

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

La API de verificación de Qualisend ejecuta ese proceso desde una infraestructura de reputación gestionada creada para ello. POST /verify devuelve un veredicto instantáneo de las comprobaciones locales (sintaxis, DNS, detección de direcciones desechables y de errores tipográficos) más un job_id; si el sondeo del buzón sigue pendiente, su veredicto llega a ese trabajo. Java 11+ incluye java.net.http.HttpClient, así que no necesitas ninguna dependencia HTTP; combínalo con Jackson (o Gson) para leer el JSON. Guarda tu clave en una variable de entorno y nunca la escribas fija en el código:

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;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class QualisendClient {
    private static final String BASE_URL = "https://app.qualisend.com/api/v1";

    private final HttpClient http = HttpClient.newHttpClient();
    private final ObjectMapper mapper = new ObjectMapper();
    private final String apiKey = System.getenv("QUALISEND_API_KEY"); // holds YOUR_API_KEY

    /** POST /verify: a job_id plus the instant verdict under "result". */
    public JsonNode verify(String email) throws IOException, InterruptedException {
        String payload = mapper.writeValueAsString(Map.of("email", email));
        return send(HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/verify"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(payload)));
    }

    /** GET /jobs/{id}: the job's status and, once completed, its results. */
    public JsonNode job(String jobId) throws IOException, InterruptedException {
        return send(HttpRequest.newBuilder()
            .uri(URI.create(BASE_URL + "/jobs/" + jobId + "?include=results"))
            .GET());
    }

    private JsonNode send(HttpRequest.Builder builder) throws IOException, InterruptedException {
        HttpRequest request = builder
            .header("Authorization", "Bearer " + apiKey)
            .timeout(Duration.ofSeconds(10)) // throws HttpTimeoutException past this
            .build();
        HttpResponse<String> response =
            http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() / 100 != 2) {
            throw new ApiException(response.statusCode(),
                response.headers().firstValue("Retry-After").orElse(null));
        }
        return mapper.readTree(response.body());
    }

    /** A non-2xx reply. It extends IOException, like HttpTimeoutException. */
    public static class ApiException extends IOException {
        public final int status;
        public final String retryAfter; // seconds, sent with a 429

        ApiException(int status, String retryAfter) {
            super("Qualisend responded " + status);
            this.status = status;
            this.retryAfter = retryAfter;
        }
    }
}

Lee los campos que te interesan del nodo result:

JsonNode body   = client.verify("jane@example.com");
JsonNode result = body.get("result");

String  status     = result.get("status").asText();  // deliverable | risky | undeliverable | unknown
int     score      = result.path("score").asInt();    // 0–100 confidence
String  smtp       = result.path("smtp").asText();    // pending | cached | skipped
String  reason     = result.hasNonNull("reason")
                         ? result.get("reason").asText()  // machine-readable reason, null while pending
                         : null;
boolean disposable = result.path("sub_flags").path("disposable").asBoolean();
String  jobId      = body.path("job_id").asText();    // where the mailbox verdict lands

La forma exacta de la respuesta —cada clave de sub_flags y cada código de reason— está en la referencia de la API. Para la validación en vivo durante el registro, el status suele bastar para actuar: rechaza undeliverable, gestiona risky y unknown según tu política, y marca las direcciones disposable en el propio campo de entrada.

Obtener el veredicto SMTP: consulta el trabajo#

Comprueba smtp antes de tratar status como definitivo. Con cached (se reutilizó un resultado reciente) o skipped (las comprobaciones locales fueron decisivas), el result es definitivo y reason viene informado. Con pending, el sondeo del buzón queda en cola y se ejecuta después de que vuelva la respuesta, así que status es un unknown provisional y reason es null: no rechaces por eso. Los servidores que aplican greylisting solo responden a un remitente desconocido en un reintento, así que el veredicto del buzón puede tardar minutos, no segundos.

En el registro, el veredicto instantáneo ya ha descartado la sintaxis incorrecta, los dominios muertos y las direcciones desechables, así que deja pasar un unknown pendiente. Cuando necesites la respuesta del buzón, consulta el trabajo desde un worker en segundo plano, nunca desde el hilo de la petición, y detente tras un número fijo de intentos:

// Runs in a background worker, never on the request thread.
public JsonNode awaitMailboxVerdict(String jobId, int maxAttempts)
        throws InterruptedException {
    for (int attempt = 0; attempt < maxAttempts; attempt++) {
        try {
            JsonNode job = client.job(jobId);
            String status = job.path("status").asText();
            if (status.equals("completed")) {
                return job.path("results").path(0); // the post-SMTP verdict
            }
            if (status.equals("canceled") || status.equals("failed")) {
                break;
            }
        } catch (IOException e) {
            // 429, 5xx or a timeout: try again on the next pass
        }
        // queued or processing: back off (5, 10, 20, 40, then 60 s), then ask again
        Thread.sleep(Math.min(60_000L, 5_000L << Math.min(attempt, 4)));
    }
    return null; // gave up: treat the address as unknown and re-check later
}

La fila trae los mismos campos status y reason que el resultado instantáneo. Cada consulta es una llamada a la API y cuenta para el mismo límite de tasa por espacio de trabajo que /verify, así que espacia cada vez más los intentos en lugar de consultar a un intervalo corto y fijo. No vuelvas a llamar a /verify para refrescar un resultado pendiente: eso inicia una comprobación nueva (y gasta otro crédito) en lugar de terminar esta.

Tiempos de espera, límites de tasa y fallo en abierto#

verify() lanza una excepción ante cualquier respuesta que no sea 2xx. Eso le va bien a un proceso por lotes, no a un formulario de registro: ahí, ramifica según el fallo, deja pasar al cliente cuando el problema está del lado de la API y vuelve a verificar más tarde. La guía de registro serverless usa el mismo patrón de fallo en abierto (fail open):

private final QualisendClient client = new QualisendClient();

private static final JsonNode UNKNOWN =
    new ObjectMapper().createObjectNode().put("status", "unknown");

public JsonNode verifyForSignup(String email) throws InterruptedException {
    try {
        JsonNode body = client.verify(email);
        JsonNode result = body.get("result");
        if ("pending".equals(result.path("smtp").asText())) {
            queueMailboxCheck(body.path("job_id").asText()); // a worker runs awaitMailboxVerdict
        }
        return result;
    } catch (QualisendClient.ApiException e) {
        if (e.status == 429) {
            recheckLater(email, e.retryAfter);     // rate limited: retry after Retry-After seconds
        } else if (e.status == 402) {
            alertOps("Qualisend: out of credits"); // insufficient_credits
            recheckLater(email, null);
        } else if (e.status >= 500) {
            recheckLater(email, null);             // an outage, not the customer's problem
        } else {
            alertOps("Qualisend rejected the request: " + e.status); // 400/401/403: fix the call or key
        }
    } catch (IOException e) {
        recheckLater(email, null);                 // HttpTimeoutException or a dropped connection
    }
    return UNKNOWN;
}

queueMailboxCheck, recheckLater y alertOps representan tu cola de trabajos y tus alertas. Captura ApiException primero: es una IOException, igual que HttpTimeoutException, lo que permite que el último catch cubra los tiempos de espera y las conexiones caídas. El límite de tasa se aplica por espacio de trabajo, no por clave, así que añadir claves no levanta un 429.

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

Primero lo más barato, y detente en cuanto tengas una respuesta. Un pequeño record guarda el veredicto (los records son de Java 16+; en 11–15 usa una clase normal):

// mailboxPending: act on the local verdict now; the worker reports the mailbox result later.
public record Verdict(String status, String reason, boolean mailboxPending) {}

public Verdict validate(String email) throws InterruptedException {
    if (!isValidSyntax(email)) {
        return new Verdict("undeliverable", "invalid_email", false);
    }
    String domain = email.substring(email.lastIndexOf('@') + 1);
    if (!hasMailRoute(domain)) {
        return new Verdict("undeliverable", "invalid_domain", false);
    }
    JsonNode result = verifyForSignup(email); // fails open to "unknown"
    return new Verdict(
        result.path("status").asText("unknown"),
        result.hasNonNull("reason") ? result.get("reason").asText() : null,
        "pending".equals(result.path("smtp").asText()));
}

Las dos capas locales no cuestan nada y atrapan al instante casi toda la basura; la API se ejecuta solo sobre las direcciones que merecen el ida y vuelta de red. Ese orden es todo el truco: la misma estructura que encontrarás en las versiones de esta guía para Node.js, Python, PHP, Go y C#, porque lo que hace que la validación funcione es el enfoque por capas, no el lenguaje.

Para una lista entera, no recorras un CSV llamando a /verify: envíala a POST /verify/bulk (hasta 1.000.000 de filas) o súbela en la app, como describe la guía de limpieza de listas.

Preguntas frecuentes

01¿Basta con InternetAddress.validate() para validar un correo en Java?

Para la sintaxis, es la herramienta adecuada en la primera capa: jakarta.mail.internet.InternetAddress con validate() está mejor probado que una expresión regular hecha a mano y te ahorra reimplementar el RFC 822. Pero solo valida la forma: nunca resuelve DNS ni contacta con un servidor de correo, y es lo bastante permisivo como para aceptar dominios sin punto como jane@localhost. También analiza la forma con nombre visible Jane <jane@example.com>, así que comprueba que getAddress() sea igual a la entrada. Después combínalo con una consulta MX y una comprobación del buzón por SMTP antes de fiarte de la dirección.

02¿Qué expresión regular debo usar para validar un correo en Java?

Una corta y permisiva, compilada una sola vez como static final Pattern y comprobada con matcher(email).matches(), que debe coincidir con toda la cadena (find() acepta una coincidencia en cualquier parte de ella). El patrón de esta guía exige una sola @, ningún espacio, ninguna parte vacía entre puntos y al menos un punto en el dominio. No limites la longitud del TLD con algo como {2,7}: hay TLD reales, como .photography, que son más largos. Una expresión regular solo comprueba la forma, así que no puede decirte si el dominio o el buzón existen; por qué falla la validación de correo con expresiones regulares explica sus límites.

03¿Cómo compruebo los registros MX en Java sin una biblioteca externa?

Usa el proveedor DNS de JNDI incorporado en el JDK. Crea un InitialDirContext con java.naming.factory.initial establecido en com.sun.jndi.dns.DnsContextFactory, luego llama a getAttributes(domain, new String[] {"MX"}) y comprueba el atributo MX devuelto. Si no hay MX, consulta también "A" y "AAAA" (un tipo por llamada), porque el correo puede recurrir a los registros de dirección del propio dominio. Trata una NameNotFoundException como un dominio que no existe, pero no rechaces por un tiempo de espera agotado ni por otra NamingException: eso es un problema del resolver, no una dirección incorrecta.

04¿Puedo verificar un buzón en Java sin una API?

En parte. Jakarta Mail y JNDI confirman que la dirección tiene el formato correcto y que el dominio acepta correo, ambos gratuitos y con pocas dependencias. Confirmar el buzón en sí implica una conversación SMTP, que puedes intentar con un Socket en crudo pero no deberías ejecutar desde tu servidor de aplicaciones: el puerto 25 está bloqueado con frecuencia y el resultado depende de la reputación de tu IP de envío. Esa última capa es la que gestiona un servicio de verificación.

05¿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 como para bloquear la petición y ofrecer feedback instantáneo. Actúa ahí también sobre el veredicto instantáneo de la API (rechaza undeliverable, deja pasar un unknown pendiente) y deja el veredicto del buzón a un worker en segundo plano. Reserva el trabajo por lotes más profundo para la limpieza de listas; la guía de registro serverless muestra el patrón en tiempo real, y la guía de limpieza de listas cubre la parte por lotes.


¿Listo para añadir la capa SMTP? Pasa cualquier dirección por el comprobador de correo gratuito para ver cómo una cadena sintácticamente perfecta vuelve con un veredicto real, o lee la referencia de la API para conocer el endpoint /verify, el sobre result completo y ejemplos listos para copiar y pegar en todos los lenguajes anteriores.

Tu reputación, protegida.

Limpia tu primera lista en minutos. 100 créditos gratis, sin tarjeta.

Empezar