Skip to content
Commencez avec 100 crédits de vérification gratuits
Qualisend
Tous les articles
Ingénierie / 9 mai 2026

Comment valider une adresse e-mail dans Spring Boot

9 minutes read

Qualisend team
Une fenêtre de code Spring Boot validant un e-mail à travers les couches de format, de DNS et de boîte aux lettres jusqu'à un verdict de délivrabilité

Valider une adresse e-mail dans Spring Boot, c'est en réalité trois vérifications déguisées en une seule annotation. Posez @Email sur un champ de DTO, branchez @Valid dans le contrôleur, et Spring se contentera de confirmer que la chaîne ressemble à une adresse — ce qui est la moins utile des trois choses que vous voulez réellement savoir. La vraie validation se fait par couches : une vérification de format peu coûteuse, une résolution DNS de la route de messagerie du domaine, et une sonde de boîte aux lettres en SMTP. Jakarta Bean Validation vous offre gratuitement la première couche ainsi qu'un point d'extension propre — le ConstraintValidator — pour le reste. Ce guide construit chaque couche avec du code fonctionnel, et montre exactement où le framework s'arrête et où une API de vérification prend le relais.

La réponse courte#

Utilisez le @Email de Jakarta Bean Validation pour la syntaxe, une recherche MX via JNDI pour le domaine, et une API de vérification pour le contrôle de la boîte aux lettres en SMTP — le tout enveloppé dans une contrainte @Deliverable personnalisée qui s'exécute après que @Email a réussi. N'ouvrez pas vous-même de connexions SMTP depuis votre application pour sonder les boîtes aux lettres : le port 25 sortant est bloqué sur la plupart des hébergeurs, et la réponse dépend de la réputation de l'IP d'envoi et d'un greylisting que vous n'avez pas envie de réimplémenter. Chaque couche permet d'écarter des adresses à moindre coût que la précédente ; seule la dernière peut valider une adresse. C'est la même structure en trois couches que le guide de validation en Java, adaptée au cycle de vie de validation de Spring.

Couche 1 : validation du format avec @Email et @Valid#

Spring Boot prend en charge la première couche grâce à Jakarta Bean Validation. Ajoutez spring-boot-starter-validation au projet et annotez le champ de votre DTO de requête avec jakarta.validation.constraints.Email. Associez-le à @NotBlank, car les contraintes de Bean Validation considèrent null comme valide par conception — @Email renvoie true pour une valeur manquante, la présence est donc une préoccupation distincte :

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
) {}

C'est l'annotation @Valid sur l'argument du contrôleur qui déclenche la validation avant l'exécution du corps de votre méthode. Une contrainte en échec court-circuite l'exécution en levant une MethodArgumentNotValidException, que Spring transforme pour vous en 400 :

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());
    }
}

Sachez exactement ce que cela vous apporte. @Email vérifie la forme selon un motif par défaut permissif — il ne résout jamais le DNS, n'ouvre jamais de socket, et n'a aucune idée de l'existence de example.com. definitely-fake@gmail.com passe. typo@gmial.com passe. Les deux sont non distribuables, et aucune annotation qui se contente de lire la chaîne ne vous le dira jamais, pour la même raison qui fait que la validation d'e-mail par regex échoue : la syntaxe et la délivrabilité sont deux questions différentes. L'une est un fait relatif à la chaîne ; l'autre est un fait relatif à Internet.

Couche 2 : le domaine peut-il recevoir des e-mails ?#

Un domaine sans enregistrement MX ne peut recevoir de courrier pour personne : une simple recherche MX élimine donc les domaines morts, les noms d'entreprise mal orthographiés et les TLD inventés. Comme Spring Boot s'exécute sur la JVM, vous obtenez cela sans la moindre dépendance externe : le JDK embarque un fournisseur DNS pour JNDI, ce qui vous permet de résoudre l'attribut MX via 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;
        }
    }
}

Vous pourriez encapsuler cela dans son propre ConstraintValidator et l'empiler en seconde annotation. Mais la couche SMTP nécessite le même travail DNS *ainsi qu'*un échange en direct avec le serveur de messagerie — plutôt que de maintenir un validateur MX distinct, puis une sonde de boîte aux lettres, il est donc plus propre de laisser un seul appel d'API prendre en charge les deux couches réseau. La vérification MX locale reste utile comme échec rapide optionnel ; le service de vérification effectue de toute façon l'étape DNS en interne.

Couche 3 : la boîte aux lettres existe-t-elle réellement ?#

Les couches un et deux ne peuvent qu'écarter une adresse. Un domaine peut publier des enregistrements MX parfaits sans qu'aucune boîte aux lettres n'existe à l'adresse que vous détenez — noreply-9f2x@gmail.com est syntaxiquement valide sur une route de messagerie active, et reste pourtant une boîte aux lettres qui n'a jamais été créée. Confirmer une boîte aux lettres précise passe par l'échange de distribution SMTP : se connecter au serveur de messagerie, émettre RCPT TO, lire la réponse, et se déconnecter avant d'envoyer quoi que ce soit. Ce n'est pas tout — les domaines catch-all acceptent toutes les adresses et déjouent une sonde naïve — le fonctionnement de la vérification d'e-mails détaille l'ensemble du pipeline.

Vous pouvez scripter le SMTP depuis la JVM avec un Socket brut, mais vous ne devriez pas l'exécuter depuis votre application. La plupart des fournisseurs cloud bloquent le port 25 sortant, la réponse dépend de la réputation de l'IP depuis laquelle vous vous connectez, et les serveurs destinataires appliquent du greylisting et limitent le débit des expéditeurs inconnus — si bien qu'une sonde qui réussit dans un test local échoue en silence, ou vous fait blocklister, en production. C'est la couche qu'il vaut la peine de déléguer.

Le point d'ancrage idiomatique de Spring est une contrainte personnalisée. Définissez une annotation @Deliverable et un validateur qui appelle l'API de vérification. Commencez par l'annotation :

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 {};
}

Comme spring-boot-starter-validation câble Hibernate Validator via la SpringConstraintValidatorFactory de Spring, le validateur est un bean géré — l'injection @Value et l'autowiring par constructeur y fonctionnent donc. Java 11+ fournit java.net.http.HttpClient, vous n'avez ainsi besoin d'aucune dépendance HTTP ; associez-le à Jackson, que Spring Boot place déjà dans le classpath. Conservez la clé dans une variable d'environnement — ne la codez jamais en dur :

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;
        }
    }
}

Faites pointer la propriété vers la variable d'environnement dans application.properties :

qualisend.api-key=${QUALISEND_API_KEY}

Le endpoint ci-dessus n'est qu'un exemple — consultez la référence de l'API pour l'URL de base et le format de requête exacts — mais la réponse se lit sous la forme d'une enveloppe { "result": { ... } } :

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

status vaut deliverable, risky, undeliverable ou unknown. Le validateur ci-dessus ne met en échec définitif que undeliverable et laisse passer risky et unknown, ce qui vous permet de décider en aval quoi en faire — filtrer sur le score, ou vous brancher sur une entrée de sub_flags comme disposable — plutôt que de transformer une adresse limite en erreur de formulaire. Considérez le JSON ci-dessus comme le format, non comme le contrat ; la liste complète des champs se trouve dans la documentation développeur.

Exécuter le contrôle de délivrabilité après @Email grâce aux groupes de validation#

Il y a un hic. Par défaut, Bean Validation exécute chaque contrainte d'un champ indépendamment des autres : @Deliverable se déclencherait donc — et consommerait un crédit d'API — même lorsque @Email sait déjà que la chaîne est invalide. Vous voulez un ordre : le format d'abord, l'aller-retour réseau uniquement s'il passe.

Bean Validation exprime cet ordre au moyen de groupes et d'une @GroupSequence. Définissez une interface marqueur pour la couche réseau, puis placez @Deliverable dans ce groupe tandis que @NotBlank et @Email restent dans le groupe par défaut :

public interface NetworkChecks {}

Redéfinissez maintenant la séquence de groupes par défaut sur le DTO lui-même. En listant d'abord la classe (qui représente ses propres contraintes par défaut) puis NetworkChecks en second, vous indiquez à Hibernate Validator de valider le format en premier et de n'atteindre le contrôle de délivrabilité que si cette étape est propre :

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
) {}

Deux éléments rendent cela possible. Comme la séquence redéfinit le groupe par défaut, le simple @Valid de votre contrôleur la déclenche toujours — aucun argument de groupe @Validated(...) n'est nécessaire. Et comme les étapes d'une séquence se court-circuitent, une adresse mal formée échoue à l'étape @Email et l'API n'est jamais appelée. Tout le truc tient dans cet ordre : la vérification locale peu coûteuse protège la vérification réseau onéreuse.

Assembler les couches pour valider une adresse e-mail dans Spring Boot#

Le DTO ci-dessus compose déjà les trois couches ; le contrôleur reste le @Valid @RequestBody d'une seule ligne issu de la première couche. Il ne reste plus qu'à mettre en forme la réponse d'erreur. Spring émet une MethodArgumentNotValidException pour toute contrainte en échec : un petit @RestControllerAdvice la transforme donc en un 400 propre, indexé par champ :

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;
    }
}

Les deux couches locales ne coûtent rien et interceptent instantanément la plupart des déchets ; l'API ne s'exécute que sur les adresses qui valent l'aller-retour réseau. C'est la même structure que celle de la version Java de ce guide, car c'est la mise en couches — et non le framework — qui fait fonctionner la validation. Exécutez ce pipeline synchrone rapide à l'inscription et réservez les passes plus lourdes au travail de back-office : le guide d'inscription serverless présente le schéma en temps réel, et comment nettoyer une liste d'e-mails couvre le versant par lots.

Foire aux questions#

@Email suffit-il pour valider une adresse e-mail dans Spring Boot ?#

Pour la syntaxe, c'est le bon outil de première couche — jakarta.validation.constraints.Email est mieux éprouvé qu'une regex maison et s'intègre gratuitement avec @Valid et la gestion d'erreurs de Spring. Mais il ne valide que la forme : il ne résout jamais le DNS ni ne contacte de serveur de messagerie, et il considère null comme valide, alors associez-le à @NotBlank. Une réussite signifie « ressemble à un e-mail », pas « sera distribué » — ajoutez une recherche MX et un contrôle de boîte aux lettres en SMTP avant de faire confiance à l'adresse.

Comment écrire une annotation de validation d'e-mail personnalisée dans Spring Boot ?#

Créez une annotation méta-annotée avec @Constraint(validatedBy = ...) et une classe implémentant ConstraintValidator<YourAnnotation, String>. Placez votre logique dans isValid, en renvoyant false pour échouer. Avec spring-boot-starter-validation, le validateur est un bean Spring : vous pouvez donc y injecter de la configuration et des clients HTTP. Renvoyez true pour null/vide afin que @NotBlank et @Email prennent en charge ces cas, et annotez le champ du DTO pour l'appliquer.

Comment faire en sorte que le contrôle de délivrabilité ne s'exécute qu'après la réussite de @Email ?#

Utilisez les groupes de validation. Placez la coûteuse contrainte @Deliverable dans un groupe marqueur comme NetworkChecks, puis ajoutez @GroupSequence({ SignupRequest.class, NetworkChecks.class }) au DTO pour redéfinir sa séquence de groupes par défaut. Comme les étapes d'une séquence se court-circuitent, @Email s'exécute en premier et la contrainte adossée à l'API ne se déclenche que si le format est déjà valide — et le simple @Valid la déclenche toujours, puisque la séquence redéfinit le groupe par défaut.

Faut-il valider les e-mails à l'inscription ou lors du nettoyage d'une liste ?#

Les deux, à des profondeurs différentes. Exécutez les couches de format et de délivrabilité de manière synchrone à l'inscription — elles sont assez rapides pour bloquer la requête et fournir un retour instantané — et agissez aussi sur le status de l'API à cet endroit. Réservez une vérification plus poussée et par lots au nettoyage d'une liste existante, où la latence importe peu et la rigueur, beaucoup.


Prêt à ajouter la couche boîte aux lettres ? Soumettez une adresse syntaxiquement parfaite au vérificateur d'e-mails gratuit pour voir une chaîne approuvée par @Email revenir en undeliverable, puis câblez le même verdict dans un ConstraintValidator grâce au endpoint /verify et à l'enveloppe result complète décrite dans la référence de l'API.

Your reputation, protected.

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

Get started