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.



