Eine E-Mail-Adresse in Spring Boot zu validieren, sind in Wirklichkeit drei
Prüfungen, die sich hinter einer einzigen Annotation verbergen. Setzen Sie
@Email auf ein DTO-Feld, verdrahten Sie @Valid im Controller, und Spring
bestätigt bereitwillig, dass der String wie eine Adresse geformt ist — was das
am wenigsten nützliche der drei Dinge ist, die Sie eigentlich wissen wollen.
Echte Validierung ist geschichtet: eine günstige Formatprüfung, ein DNS-Lookup
für die Mailroute der Domain und eine SMTP-Postfachprüfung. Jakarta Bean
Validation liefert Ihnen die erste Schicht kostenlos und einen sauberen
Erweiterungspunkt — den ConstraintValidator — für den Rest. Diese Anleitung
baut jede Schicht mit lauffähigem Code auf und zeigt genau, wo das Framework
aufhört und eine Verifizierungs-API übernimmt.
Die Kurzantwort#
Verwenden Sie @Email aus Jakarta Bean Validation für die Syntax, ein
JNDI-MX-Lookup für die Domain und eine Verifizierungs-API für die
SMTP-Postfachprüfung — verpackt in einen eigenen @Deliverable-Constraint, der
nach dem Bestehen von @Email läuft. Öffnen Sie keine SMTP-Verbindungen aus
Ihrer Anwendung heraus, um Postfächer selbst zu prüfen: Ausgehender Port 25 ist
auf den meisten Hosts blockiert, und die Antwort hängt von der Reputation der
sendenden IP und von Greylisting ab, das Sie nicht neu implementieren wollen.
Jede Schicht schließt Adressen günstiger aus als die vorherige; nur die letzte
kann eine Adresse einschließen. Es ist dieselbe dreischichtige Form wie in der
schlichten Java-Validierungsanleitung,
angepasst an den Validierungs-Lifecycle von Spring.
Schicht 1: Formatvalidierung mit @Email und @Valid#
Spring Boot übernimmt Schicht eins über Jakarta Bean Validation. Fügen Sie
spring-boot-starter-validation zum Projekt hinzu und annotieren Sie das Feld
Ihres Request-DTOs mit jakarta.validation.constraints.Email. Kombinieren Sie es
mit @NotBlank, denn Bean-Validation-Constraints behandeln null per Design als
gültig — @Email gibt für einen fehlenden Wert true zurück, sodass die
Anwesenheit ein separates Anliegen ist:
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
) {}
Die @Valid-Annotation am Controller-Argument ist das, was die Validierung
auslöst, bevor Ihr Methodenrumpf läuft. Ein fehlschlagender Constraint
kurzschließt in eine MethodArgumentNotValidException, die Spring für Sie in
einen 400 umwandelt:
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());
}
}
Wissen Sie genau, was Ihnen das einbringt. @Email prüft die Form gegen ein
permissives Standardmuster — es löst niemals DNS auf, öffnet niemals einen Socket
und hat keine Ahnung, ob example.com existiert. definitely-fake@gmail.com
besteht. typo@gmial.com besteht. Beide sind nicht zustellbar, und keine
Annotation, die nur den String liest, wird Ihnen das jemals sagen — aus demselben
Grund, aus dem Regex-E-Mail-Validierung
scheitert: Syntax und Zustellbarkeit
sind unterschiedliche Fragen. Die eine ist eine Tatsache über den String; die
andere eine Tatsache über das Internet.
Schicht 2: Kann die Domain überhaupt Mail empfangen?#
Eine Domain ohne MX-Einträge kann für niemanden Mail annehmen, sodass ein
einziges MX-Lookup tote Domains, falsch geschriebene Firmennamen und erfundene
TLDs eliminiert. Da Spring Boot auf der JVM läuft, bekommen Sie das ganz ohne
externe Abhängigkeit: Das JDK bringt einen DNS-Provider für JNDI mit, sodass Sie
das MX-Attribut über einen DirContext auflösen.
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;
}
}
}
Sie könnten dies in einen eigenen ConstraintValidator einwickeln und als zweite
Annotation stapeln. Aber die SMTP-Schicht braucht dieselbe DNS-Arbeit plus ein
Live-Gespräch mit dem Mailhost — statt also einen separaten MX-Validator und dann
eine Postfachprüfung zu pflegen, ist es sauberer, einen einzigen API-Aufruf beide
Netzwerkschichten übernehmen zu lassen. Die lokale MX-Prüfung bleibt als
optionaler Fast-Fail nützlich; der Verifizierungsdienst erledigt den DNS-Schritt
so oder so intern.
Schicht 3: Existiert das Postfach tatsächlich?#
Schicht eins und zwei können eine Adresse nur ausschließen. Eine Domain kann
perfekte MX-Einträge veröffentlichen und trotzdem kein Postfach an der Adresse
haben, die Sie in der Hand halten — noreply-9f2x@gmail.com ist gültige Syntax
auf einer aktiven Mailroute, und es ist trotzdem ein Postfach, das nie angelegt
wurde. Ein bestimmtes Postfach zu bestätigen bedeutet das
SMTP-Zustellgespräch: sich mit dem Mailhost verbinden, RCPT TO absetzen, die
Antwort lesen und trennen, bevor irgendetwas gesendet wird. Es steckt mehr
dahinter — Catch-all-Domains akzeptieren jede Adresse und hebeln eine naive
Prüfung aus —, und wie E-Mail-Verifizierung
funktioniert geht die vollständige Pipeline
durch.
Sie können SMTP von der JVM aus mit einem rohen Socket skripten, aber Sie
sollten es nicht aus Ihrer Anwendung heraus ausführen. Die meisten
Cloud-Anbieter blockieren ausgehenden Port 25, die Antwort hängt von der
Reputation der IP ab, von der aus Sie sich verbinden, und empfangende Server
greylisten und drosseln unbekannte Absender — sodass eine Prüfung, die in einem
lokalen Test besteht, in der Produktion still fehlschlägt oder Sie auf eine
Blocklist bringt. Dies ist die Schicht, die es sich zu delegieren lohnt.
Springs idiomatischer Anknüpfungspunkt ist ein eigener Constraint. Definieren Sie
eine @Deliverable-Annotation und einen Validator, der die Verifizierungs-API
aufruft. Beginnen Sie mit der 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 {};
}
Weil spring-boot-starter-validation den Hibernate Validator über Springs
SpringConstraintValidatorFactory verdrahtet, ist der Validator ein verwalteter
Bean — sodass @Value-Injektion und Constructor-Autowiring darin funktionieren.
Java 11+ bringt java.net.http.HttpClient mit, sodass Sie keine
HTTP-Abhängigkeit brauchen; kombinieren Sie es mit Jackson, das Spring Boot
ohnehin schon auf den Classpath legt. Halten Sie den Schlüssel in einer
Umgebungsvariable — kodieren Sie ihn niemals hart:
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;
}
}
}
Zeigen Sie die Property in application.properties auf die Umgebungsvariable:
qualisend.api-key=${QUALISEND_API_KEY}
Der Endpunkt oben ist ein Platzhalter — prüfen Sie die
API-Referenz für die genaue Basis-URL und Request-Form —, aber die
Antwort liest sich als { "result": { ... } }-Umschlag zurück:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
status ist einer von deliverable, risky, undeliverable oder unknown.
Der Validator oben lässt hart nur undeliverable durchfallen und lässt risky
und unknown passieren, sodass Sie nachgelagert entscheiden können, was mit
ihnen zu tun ist — nach score gaten oder anhand eines sub_flags-Eintrags wie
disposable verzweigen — anstatt eine Grenzfall-Adresse in einen Formularfehler
zu verwandeln. Behandeln Sie das obige JSON als die Form, nicht als den Vertrag;
die vollständige Feldliste steht in den Entwicklerdokumenten.
Die Zustellbarkeitsprüfung mit Validierungsgruppen nach @Email laufen lassen#
Es gibt einen Haken. Standardmäßig führt Bean Validation jeden Constraint eines
Feldes unabhängig von den anderen aus, sodass @Deliverable feuern würde — und
ein API-Guthaben verbrauchen würde —, selbst wenn @Email bereits weiß, dass der
String Müll ist. Sie wollen eine Reihenfolge: erst das Format, der
Netzwerk-Roundtrip nur, wenn es besteht.
Bean Validation drückt Reihenfolge über Gruppen und eine @GroupSequence
aus. Definieren Sie ein Marker-Interface für die Netzwerkschicht und legen Sie
dann @Deliverable in diese Gruppe, während @NotBlank und @Email in der
Standardgruppe bleiben:
public interface NetworkChecks {}
Definieren Sie nun die Standard-Gruppensequenz am DTO selbst neu. Die Klasse
zuerst aufzuführen (die für ihre eigenen Standard-Constraints steht) und
NetworkChecks als zweites weist den Hibernate Validator an, zuerst das Format
zu validieren und die Zustellbarkeitsprüfung nur zu erreichen, wenn dieser
Schritt sauber ist:
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
) {}
Zwei Dinge lassen das funktionieren. Weil die Sequenz die Standard-Gruppe neu
definiert, löst das einfache @Valid an Ihrem Controller sie weiterhin aus —
kein @Validated(...)-Gruppenargument nötig. Und weil Sequenzschritte
kurzschließen, scheitert eine fehlerhafte Adresse am @Email-Schritt, und die
API wird nie aufgerufen. Diese Reihenfolge ist der ganze Trick: Die günstige
lokale Prüfung schützt die teure Netzwerkprüfung.
Die Schichten zusammensetzen, um eine E-Mail-Adresse in Spring Boot zu validieren#
Das obige DTO setzt bereits alle drei Schichten zusammen; der Controller bleibt
das einzeilige @Valid @RequestBody aus Schicht eins. Das Einzige, was noch
bleibt, ist, die Fehlerantwort zu formen. Spring gibt für jeden fehlgeschlagenen
Constraint eine MethodArgumentNotValidException aus, sodass ein kleines
@RestControllerAdvice sie in einen sauberen, feldbezogenen 400 verwandelt:
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;
}
}
Die beiden lokalen Schichten kosten nichts und fangen den meisten Müll sofort ab; die API läuft nur bei Adressen, die den Netzwerk-Roundtrip wert sind. Das ist dieselbe Form, die Sie in der Java-Version dieser Anleitung finden, denn die Schichtung — nicht das Framework — ist das, was Validierung funktionieren lässt. Führen Sie diese schnelle synchrone Pipeline bei der Registrierung aus und reservieren Sie schwerere Durchläufe für die Back-Office-Arbeit: Die Anleitung zur serverlosen Registrierung zeigt das Echtzeit-Muster, und wie man eine E-Mail-Liste bereinigt deckt die Batch-Seite ab.
Häufig gestellte Fragen#
Reicht @Email aus, um eine E-Mail-Adresse in Spring Boot zu validieren?#
Für die Syntax ist es das richtige Werkzeug der ersten Schicht —
jakarta.validation.constraints.Email ist besser getestet als eine selbst
gebaute Regex und lässt sich kostenlos in @Valid und Springs Fehlerbehandlung
integrieren. Aber es prüft nur die Form: Es löst niemals DNS auf, kontaktiert
keinen Mailserver und behandelt null als gültig, also kombinieren Sie es mit
@NotBlank. Ein bestandener Test bedeutet "sieht wie eine E-Mail aus", nicht
"wird zugestellt" — fügen Sie ein MX-Lookup und eine SMTP-Postfachprüfung hinzu,
bevor Sie der Adresse vertrauen.
Wie schreibe ich in Spring Boot eine eigene E-Mail-Validierungsannotation?#
Erstellen Sie eine Annotation, die mit @Constraint(validatedBy = ...)
meta-annotiert ist, und eine Klasse, die
ConstraintValidator<IhreAnnotation, String> implementiert. Legen Sie Ihre Logik
in isValid ab und geben Sie false zurück, um fehlzuschlagen. Mit
spring-boot-starter-validation ist der Validator ein Spring-Bean, sodass Sie
Konfiguration und HTTP-Clients hineininjizieren können. Geben Sie für
null/leer true zurück, damit @NotBlank und @Email diese Fälle übernehmen,
und annotieren Sie das DTO-Feld, um sie anzuwenden.
Wie sorge ich dafür, dass die Zustellbarkeitsprüfung erst nach @Email läuft?#
Verwenden Sie Validierungsgruppen. Legen Sie den teuren @Deliverable-Constraint
in eine Marker-Gruppe wie NetworkChecks und fügen Sie dann
@GroupSequence({ SignupRequest.class, NetworkChecks.class }) zum DTO hinzu, um
dessen Standard-Gruppensequenz neu zu definieren. Da Sequenzschritte
kurzschließen, läuft @Email zuerst, und der API-gestützte Constraint feuert nur,
wenn das Format bereits gültig ist — und einfaches @Valid löst ihn weiterhin
aus, da die Sequenz die Standardgruppe neu definiert.
Sollte ich E-Mails bei der Registrierung oder beim Bereinigen einer Liste validieren?#
Beides, in unterschiedlicher Tiefe. Führen Sie die Format- und
Zustellbarkeitsschichten bei der Registrierung synchron aus — sie sind schnell
genug, um die Anfrage zu blockieren und sofortiges Feedback zu geben — und
reagieren Sie dort auch auf den status der API. Reservieren Sie tiefere,
gebündelte Verifizierung für das Bereinigen einer bestehenden Liste, wo Latenz
keine Rolle spielt und Gründlichkeit zählt.
Bereit, die Postfachschicht hinzuzufügen? Werfen Sie eine syntaktisch perfekte
Adresse in den kostenlosen E-Mail-Checker, um zu sehen, wie
ein von @Email genehmigter String als undeliverable zurückkommt, und
verdrahten Sie dann dasselbe Urteil in einen ConstraintValidator mit dem
/verify-Endpunkt und dem vollständigen result-Umschlag in der
API-Referenz.