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.