Skip to content
Inizia con 100 crediti di verifica gratuiti
Qualisend
Tutti gli articoli
Ingegneria / 9 maggio 2026

Come validare un indirizzo email in Spring Boot

8 min di lettura

Qualisend team
Come validare un indirizzo email in Spring Boot

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.

La tua reputazione, protetta..

Pulisci la tua prima lista in pochi minuti. 100 crediti gratuiti, senza carta di credito.

Inizia