Validare un indirizzo email in Spring Boot significa in realtà fare tre controlli
nascosti dietro un'unica annotazione. Metti @Email su un campo di un DTO,
aggiungi @Valid nel controller e Spring confermerà volentieri che la stringa ha
la forma di un indirizzo: la meno utile delle tre cose che vuoi davvero sapere.
La vera validazione è a livelli: un controllo di formato economico, una ricerca
DNS dell'instradamento della posta del dominio e un test SMTP della casella.
Jakarta Bean Validation ti dà gratis il primo livello e un punto di estensione
pulito, il ConstraintValidator, per tutto il resto. Questa guida costruisce ogni
livello con codice funzionante e mostra esattamente dove si ferma il framework e
dove subentra un'API di verifica.
In breve#
Usa @Email di Jakarta Bean Validation per la sintassi, una ricerca MX tramite
JNDI per il dominio e un'API di verifica per il controllo SMTP della casella, il
tutto racchiuso in un vincolo personalizzato @Deliverable che viene eseguito
dopo che @Email è stato superato. Non aprire connessioni SMTP dalla tua app
per testare direttamente le caselle: la porta 25 in uscita è bloccata dalla maggior
parte degli host e la risposta dipende dalla reputazione dell'IP di invio e dal
greylisting, cose che non vuoi reimplementare. Ogni livello esclude indirizzi a
un costo minore dell'ultimo, e solo l'ultimo può confermarne uno. È la stessa
struttura a tre livelli della guida alla validazione in Java puro,
adattata al ciclo di vita della validazione di Spring.
Livello 1: validazione del formato con @Email e @Valid#
Spring Boot gestisce il primo livello tramite Jakarta Bean Validation. Aggiungi
spring-boot-starter-validation al progetto e annota il campo del DTO della
richiesta con jakarta.validation.constraints.Email. Abbinalo a @NotBlank,
perché i vincoli di Bean Validation considerano null valido per scelta
progettuale: @Email restituisce true per un valore mancante, quindi la
presenza del valore è una questione separata:
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
) {}
È l'annotazione @Valid sull'argomento del controller ad attivare la
validazione prima che venga eseguito il corpo del metodo. Un vincolo non
soddisfatto interrompe subito il flusso con una MethodArgumentNotValidException,
che Spring trasforma per te in un 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());
}
}
Ecco esattamente che cosa ottieni. @Email controlla la forma rispetto a un
pattern predefinito permissivo: non risolve mai il DNS, non apre mai un socket e
non ha idea se example.com esista. definitely-fake@gmail.com passa.
typo@gmial.com passa. Nessuno dei due è recapitabile, e nessuna annotazione che
si limita a leggere la stringa te lo dirà mai, per lo stesso motivo per cui
la validazione delle email con le regex non funziona:
sintassi e deliverability sono domande diverse. Una è un fatto che riguarda la stringa; l'altra è un fatto che riguarda internet.
Livello 2: il dominio può ricevere posta?#
Un dominio senza record MX non può accettare posta per nessuno, quindi una sola
ricerca MX elimina domini inattivi, nomi aziendali scritti male e TLD
inventati. Dato che Spring Boot gira sulla JVM, la ottieni senza alcuna dipendenza
esterna: il JDK include un provider DNS per JNDI, quindi risolvi l'attributo MX
tramite 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;
}
}
}
Potresti racchiudere questo codice in un ConstraintValidator dedicato e
aggiungerlo come seconda annotazione. Ma il livello SMTP richiede lo stesso lavoro
DNS più una conversazione in tempo reale con il server di posta: invece di mantenere
un validatore MX separato e poi un test della casella, è più pulito lasciare che
un'unica chiamata API gestisca entrambi i livelli di rete. Il controllo MX locale
resta utile come scarto rapido (fast-fail) facoltativo; il servizio di verifica
esegue comunque il passaggio DNS al suo interno.
Livello 3: la casella esiste davvero?#
I primi due livelli possono solo escludere un indirizzo. Un dominio può
pubblicare record MX perfetti e non avere comunque alcuna casella all'indirizzo
che hai in mano: noreply-9f2x@gmail.com ha una sintassi valida su un instradamento di posta attivo, ma resta una casella mai creata.
Confermare una casella specifica richiede la conversazione SMTP di consegna: collegarsi all'host di posta, inviare RCPT TO, leggere la risposta e disconnettersi prima di inviare qualsiasi cosa. C'è dell'altro (i domini
catch-all accettano qualsiasi indirizzo e rendono inutile un test ingenuo):
come funziona la verifica email illustra
l'intera pipeline.
Puoi implementare il dialogo SMTP dalla JVM con un Socket grezzo, ma non dovresti farlo dalla
tua applicazione. La maggior parte dei provider cloud blocca la porta 25 in
uscita, la risposta dipende dalla reputazione dell'IP da cui ti colleghi e i
server riceventi applicano il greylisting e limitano la frequenza dei mittenti
che non conoscono: così un test che funziona in locale, in produzione smette di
funzionare senza avvisi o ti fa finire in blacklist. È questo il livello che
conviene delegare.
In Spring, il punto di aggancio idiomatico è un vincolo personalizzato. Definisci
un'annotazione @Deliverable e un validatore che chiami l'API di verifica.
Inizia dall'annotazione:
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 {};
}
Dato che spring-boot-starter-validation collega Hibernate Validator tramite
SpringConstraintValidatorFactory di Spring, il validatore è un bean gestito:
l'iniezione con @Value e l'autowiring tramite costruttore funzionano al suo
interno. Java 11+ include java.net.http.HttpClient, quindi non ti serve alcuna
dipendenza HTTP; abbinalo a Jackson, che Spring Boot mette già nel classpath.
Tieni la chiave in una variabile d'ambiente e non scriverla mai nel codice:
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;
}
}
}
Collega la proprietà alla variabile d'ambiente in application.properties:
qualisend.api-key=${QUALISEND_API_KEY}
L'endpoint qui sopra è un segnaposto (controlla il riferimento API
per l'URL di base e il formato esatto della richiesta), ma la risposta arriva
racchiusa in un oggetto { "result": { ... } }:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
status è uno tra deliverable, risky, undeliverable e unknown. Il
validatore qui sopra rifiuta in modo netto solo undeliverable e lascia passare
risky e unknown, così puoi decidere a valle che cosa farne (applicare una
soglia sullo score o differenziare la logica in base a una voce di sub_flags come
disposable) invece di trasformare un indirizzo dubbio in un errore del modulo.
Considera il JSON qui sopra come la forma, non come il contratto: l'elenco
completo dei campi è nella documentazione per sviluppatori.
Eseguire il controllo di deliverability dopo @Email con i gruppi di validazione#
C'è un problema. Per impostazione predefinita, Bean Validation esegue ogni
vincolo di un campo indipendentemente dagli altri, quindi @Deliverable
scatterebbe (e consumerebbe un credito API) anche quando @Email sa già che la
stringa è spazzatura. Ti serve un ordine: prima il formato, e la chiamata di rete
solo se il formato è valido.
Bean Validation esprime l'ordine con i gruppi e una @GroupSequence.
Definisci un'interfaccia marker per il livello di rete, poi metti @Deliverable
in quel gruppo, mentre @NotBlank e @Email restano in quello predefinito:
public interface NetworkChecks {}
Ora ridefinisci la sequenza del gruppo predefinito direttamente sul DTO.
Elencare prima la classe (che rappresenta i suoi vincoli predefiniti) e poi
NetworkChecks dice a Hibernate Validator di validare prima il formato e di
arrivare al controllo di deliverability solo se quel passaggio è pulito:
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
) {}
Il tutto funziona grazie a due cose. Dato che la sequenza ridefinisce il gruppo
predefinito, il semplice @Valid sul controller la attiva comunque, senza
bisogno di un argomento di gruppo @Validated(...). E dato che i passaggi della
sequenza si interrompono al primo errore, un indirizzo malformato non supera il
passaggio di @Email e l'API non viene mai chiamata. Tutto il trucco sta in
quell'ordine: il controllo locale economico fa da filtro a quello di rete, che è
costoso.
Mettere insieme i livelli per validare un indirizzo email in Spring Boot#
Il DTO qui sopra compone già tutti e tre i livelli; il controller resta il
@Valid @RequestBody di una riga del primo livello. Resta solo da dare forma
alla risposta di errore. Spring genera una MethodArgumentNotValidException per
qualsiasi vincolo non soddisfatto, quindi un piccolo @RestControllerAdvice la
trasforma in un 400 pulito, con gli errori indicizzati per 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;
}
}
I due livelli locali non costano nulla e intercettano subito la maggior parte degli indirizzi spazzatura; l'API viene chiamata solo per gli indirizzi che meritano la chiamata di rete. È la stessa struttura che trovi nella versione Java di questa guida, perché a far funzionare la validazione è la suddivisione in livelli, non il framework. Esegui questa pipeline sincrona e veloce in fase di registrazione e riserva i passaggi più pesanti al lavoro di back-office: la guida alla verifica serverless in fase di registrazione mostra lo schema in tempo reale, e come pulire una lista email copre la parte in blocco.
Domande frequenti#
@Email basta per validare un indirizzo email in Spring Boot?#
Per la sintassi è lo strumento giusto del primo livello:
jakarta.validation.constraints.Email è testato meglio di una regex scritta a
mano e si integra senza sforzo con @Valid e con la gestione degli errori di Spring.
Ma valida solo la forma: non risolve mai il DNS né contatta un server di posta, e
considera null valido, quindi abbinalo a @NotBlank. Superarlo significa
"sembra un'email", non "verrà recapitata": aggiungi una ricerca MX e un controllo
SMTP della casella prima di fidarti dell'indirizzo.
Come scrivo un'annotazione personalizzata per validare le email in Spring Boot?#
Crea un'annotazione meta-annotata con @Constraint(validatedBy = ...) e una
classe che implementi ConstraintValidator<YourAnnotation, String>. Inserisci la
logica in isValid, restituendo false quando la validazione non deve passare.
Con spring-boot-starter-validation il validatore è un bean di Spring, quindi
puoi iniettarci configurazione e client HTTP. Restituisci true per null o
stringhe vuote, così sono @NotBlank e @Email a gestire quei casi, e annota il
campo del DTO per applicare il vincolo.
Come faccio a eseguire il controllo di deliverability solo dopo che @Email è stato superato?#
Usa i gruppi di validazione. Metti il costoso vincolo @Deliverable in un gruppo
marker come NetworkChecks, poi aggiungi @GroupSequence({ SignupRequest.class, NetworkChecks.class }) al DTO per ridefinirne la sequenza del gruppo
predefinito. Dato che i passaggi della sequenza si interrompono al primo errore,
@Email viene eseguito per primo e il vincolo basato sull'API scatta solo se il
formato è già valido; e il semplice @Valid lo attiva comunque, perché la
sequenza ridefinisce il gruppo predefinito.
Meglio validare le email in fase di registrazione o durante la pulizia di una lista?#
Entrambe le cose, con una profondità diversa. Esegui i livelli di formato e di
deliverability in modo sincrono in fase di registrazione (sono abbastanza veloci
da bloccare la richiesta e dare un riscontro immediato) e lì agisci anche in base
allo status restituito dall'API. Riserva la verifica più approfondita e in
blocco alla pulizia di una lista esistente, dove la latenza non conta e la
completezza sì.
Vuoi aggiungere il livello della casella? Inserisci un indirizzo
sintatticamente perfetto nella verifica email gratuita per
vedere una stringa approvata da @Email tornare come undeliverable, poi
collega lo stesso esito a un ConstraintValidator con l'endpoint /verify e
l'oggetto result completo descritti nel riferimento API.



