Skip to content
Comece com 100 créditos de verificação grátis
Qualisend
Todos os artigos
Engenharia / 9 de maio de 2026

Como validar um endereço de e-mail no Spring Boot

9 minutes read

Qualisend team
Uma janela de código Spring Boot validando um e-mail pelas camadas de formato, DNS e caixa postal até um veredito de entregabilidade

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.

Your reputation, protected.

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

Get started