Validar um endereço de e-mail no Spring Boot são, na verdade, três checagens
vestindo uma única anotação. Coloque @Email em um campo do DTO, conecte
@Valid no controller e o Spring vai confirmar de bom grado que a string tem o
formato de um endereço — que é a menos útil das três coisas que você realmente
quer saber. A validação de verdade é em camadas: uma checagem barata de formato,
uma consulta DNS para a rota de e-mail do domínio e uma sondagem SMTP da caixa
postal. O Jakarta Bean Validation te entrega a primeira camada de graça e um
ponto de extensão limpo — o ConstraintValidator — para o resto. Este guia
constrói cada camada com código funcional e mostra exatamente onde o framework
para e uma API de verificação assume.
A resposta curta#
Use o @Email do Jakarta Bean Validation para a sintaxe, uma consulta MX via
JNDI para o domínio e uma API de verificação para a checagem SMTP da caixa postal
— envolvida em uma restrição @Deliverable personalizada que roda depois que o
@Email passa. Não abra conexões SMTP a partir da sua aplicação para sondar
caixas postais por conta própria: a porta 25 de saída é bloqueada na maioria dos
hosts, e a resposta depende da reputação do IP de envio e do greylisting que você
não quer reimplementar. Cada camada descarta endereços de forma mais barata que a
anterior; só a última consegue aprovar um endereço. É o mesmo formato de três
camadas do guia de validação em Java puro,
ajustado ao ciclo de vida de validação do Spring.
Camada 1: validação de formato com @Email e @Valid#
O Spring Boot cuida da camada um por meio do Jakarta Bean Validation. Adicione o
spring-boot-starter-validation ao projeto e anote o campo do seu DTO de
requisição com jakarta.validation.constraints.Email. Combine-o com @NotBlank,
porque as restrições do Bean Validation tratam null como válido por design — o
@Email retorna true para um valor ausente, então a presença é uma
preocupação à parte:
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
) {}
A anotação @Valid no argumento do controller é o que dispara a validação antes
de o corpo do seu método rodar. Uma restrição que falha faz um curto-circuito em
uma MethodArgumentNotValidException, que o Spring transforma em um 400 para
você:
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());
}
}
Saiba exatamente o que isso te dá. O @Email checa o formato contra um padrão
permissivo por default — ele nunca resolve DNS, nunca abre um socket e não faz
ideia se example.com existe. definitely-fake@gmail.com passa.
typo@gmial.com passa. Ambos são inentregáveis, e nenhuma anotação que apenas lê
a string jamais vai te dizer isso, pela mesma razão pela qual
a validação de e-mail por regex falha:
sintaxe e entregabilidade são perguntas diferentes. Uma é um fato sobre a string;
a outra é um fato sobre a internet.
Camada 2: o domínio consegue receber e-mail?#
Um domínio sem registros MX não consegue aceitar e-mail para ninguém, então uma
única consulta MX elimina domínios mortos, nomes de empresa escritos errado e
TLDs inventados. Como o Spring Boot roda na JVM, você tem isso sem nenhuma
dependência externa: o JDK já traz um provedor DNS para o JNDI, então você
resolve o atributo MX por meio de um 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;
}
}
}
Você poderia envolver isso em seu próprio ConstraintValidator e empilhá-lo como
uma segunda anotação. Mas a camada SMTP precisa do mesmo trabalho de DNS mais
uma conversa ao vivo com o host de e-mail — então, em vez de manter um validador
MX separado e depois uma sondagem de caixa postal, é mais limpo deixar uma única
chamada de API cuidar das duas camadas de rede. A checagem MX local continua útil
como um fast-fail opcional; o serviço de verificação faz o passo de DNS
internamente de qualquer jeito.
Camada 3: a caixa postal realmente existe?#
As camadas um e dois só conseguem descartar um endereço. Um domínio pode
publicar registros MX perfeitos e ainda assim não ter caixa postal no endereço
que você tem em mãos — noreply-9f2x@gmail.com é sintaxe válida em uma rota de
e-mail ativa, e ainda assim é uma caixa postal que nunca foi criada. Confirmar
uma caixa postal específica significa a conversa de entrega SMTP: conectar ao
host de e-mail, emitir RCPT TO, ler a resposta e desconectar antes de enviar
qualquer coisa. Tem mais coisa envolvida — domínios catch-all aceitam todo
endereço e derrotam uma sondagem ingênua —
como funciona a verificação de e-mail
percorre o pipeline completo.
Você pode roteirizar SMTP a partir da JVM com um Socket cru, mas não deveria
rodar isso a partir da sua aplicação. A maioria dos provedores de nuvem bloqueia
a porta 25 de saída, a resposta depende da reputação do IP a partir do qual você
conecta, e os servidores receptores fazem greylist e limitam a taxa de
remetentes desconhecidos — então uma sondagem que passa em um teste local falha
silenciosamente, ou te coloca em uma blocklist, em produção. Esta é a camada que
vale a pena delegar.
O gancho idiomático do Spring é uma restrição personalizada. Defina uma anotação
@Deliverable e um validador que chama a API de verificação. Comece pela
anotação:
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 o spring-boot-starter-validation conecta o Hibernate Validator por meio da
SpringConstraintValidatorFactory do Spring, o validador é um bean gerenciado —
então a injeção via @Value e o autowiring por construtor funcionam dentro dele.
O Java 11+ já traz o java.net.http.HttpClient, então você não precisa de
dependência HTTP; combine-o com o Jackson, que o Spring Boot já coloca no
classpath. Mantenha a chave em uma variável de ambiente — nunca coloque em
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;
}
}
}
Aponte a propriedade para a variável de ambiente no application.properties:
qualisend.api-key=${QUALISEND_API_KEY}
O endpoint acima é um placeholder — consulte a referência da API
para a URL base e o formato exato da requisição — mas a resposta volta como um
envelope { "result": { ... } }:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
status é um entre deliverable, risky, undeliverable ou unknown. O
validador acima reprova de forma dura apenas undeliverable e deixa passar
risky e unknown, para que você decida mais adiante o que fazer com eles —
condicione ao score ou ramifique por uma entrada de sub_flags como
disposable — em vez de transformar um endereço limítrofe em um erro de
formulário. Trate o JSON acima como o formato, não como o contrato; a lista
completa de campos está na documentação para desenvolvedores.
Rodando a checagem de entregabilidade depois do @Email com grupos de validação#
Há um porém. Por default, o Bean Validation roda toda restrição de um campo
independentemente das outras, então o @Deliverable dispararia — e gastaria um
crédito de API — mesmo quando o @Email já sabe que a string é lixo. Você quer
ordenação: primeiro o formato, e o ida e volta de rede só se ele passar.
O Bean Validation expressa ordenação com grupos e uma @GroupSequence.
Defina uma interface marcadora para a camada de rede e depois coloque o
@Deliverable nesse grupo, enquanto @NotBlank e @Email ficam no default:
public interface NetworkChecks {}
Agora redefina a sequência de grupos padrão no próprio DTO. Listar a classe
primeiro (que representa suas próprias restrições padrão) e o NetworkChecks
depois diz ao Hibernate Validator para validar o formato primeiro e só chegar à
checagem de entregabilidade se esse passo estiver limpo:
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
) {}
Duas coisas fazem isso funcionar. Como a sequência redefine o grupo default, o
@Valid simples no seu controller ainda a aciona — sem precisar de argumento de
grupo @Validated(...). E como os passos da sequência têm curto-circuito, um
endereço malformado falha no passo do @Email e a API nunca é chamada. Essa
ordenação é todo o truque: a checagem local barata protege a de rede, que é cara.
Juntando as camadas para validar um endereço de e-mail no Spring Boot#
O DTO acima já compõe todas as três camadas; o controller continua sendo o
@Valid @RequestBody de uma linha da camada um. A única coisa que falta é dar
forma à resposta de erro. O Spring emite uma MethodArgumentNotValidException
para qualquer restrição que falha, então um pequeno @RestControllerAdvice a
transforma em um 400 limpo, 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;
}
}
As duas camadas locais não custam nada e pegam a maior parte do lixo na hora; a API roda só nos endereços que valem o ida e volta de rede. Esse é o mesmo formato que você encontra na versão em Java deste guia, porque é o encadeamento em camadas — e não o framework — que faz a validação funcionar. Rode este pipeline síncrono e rápido no cadastro e reserve as passagens mais pesadas para o trabalho de bastidor: o guia de cadastro serverless mostra o padrão em tempo real, e como limpar uma lista de e-mails cobre o lado em lote.
Perguntas frequentes#
O @Email basta para validar um endereço de e-mail no Spring Boot?#
Para a sintaxe, é a ferramenta certa da camada um — o
jakarta.validation.constraints.Email é mais bem testado do que uma regex feita
à mão e se integra ao @Valid e ao tratamento de erros do Spring de graça. Mas
ele valida apenas o formato: nunca resolve DNS nem contata um servidor de e-mail,
e trata null como válido, então combine-o com @NotBlank. Passar significa
"parece um e-mail", não "vai ser entregue" — adicione uma consulta MX e uma
checagem SMTP da caixa postal antes de confiar no endereço.
Como escrevo uma anotação de validação de e-mail personalizada no Spring Boot?#
Crie uma anotação meta-anotada com @Constraint(validatedBy = ...) e uma classe
que implemente ConstraintValidator<SuaAnotacao, String>. Coloque sua lógica no
isValid, retornando false para falhar. Com o spring-boot-starter-validation,
o validador é um bean do Spring, então você pode injetar configuração e clientes
HTTP nele. Retorne true para null/em branco, para que o @NotBlank e o
@Email cuidem desses casos, e anote o campo do DTO para aplicá-lo.
Como faço a checagem de entregabilidade rodar só depois que o @Email passar?#
Use grupos de validação. Coloque a restrição cara @Deliverable em um grupo
marcador como NetworkChecks e depois adicione @GroupSequence({ SignupRequest.class, NetworkChecks.class }) ao DTO para redefinir sua sequência de grupos padrão.
Como os passos da sequência têm curto-circuito, o @Email roda primeiro e a
restrição apoiada em API só dispara se o formato já for válido — e o @Valid
simples ainda a aciona, já que a sequência redefine o grupo padrão.
Devo validar e-mails no cadastro ou ao limpar uma lista?#
Ambos, em profundidades diferentes. Rode as camadas de formato e entregabilidade
de forma síncrona no cadastro — elas são rápidas o suficiente para bloquear a
requisição e dar feedback instantâneo — e aja também sobre o status da API ali
mesmo. Reserve a verificação mais profunda e em lote para limpar uma lista
existente, onde a latência não importa e o rigor importa.
Pronto para adicionar a camada da caixa postal? Jogue um endereço
sintaticamente perfeito no verificador de e-mail gratuito
para ver uma string aprovada pelo @Email voltar como undeliverable, e depois
conecte o mesmo veredito a um ConstraintValidator com o endpoint /verify e o
envelope result completo na referência da API.