Validating an email address in Spring Boot is really three checks wearing one
annotation. Drop @Email on a DTO field, wire @Valid into the controller, and
Spring will happily confirm the string is shaped like an address — which is the
least useful of the three things you actually want to know. Real validation is
layered: a cheap format check, a DNS lookup for the domain's mail route, and an
SMTP mailbox probe. Jakarta Bean Validation hands you the first layer for free
and a clean extension point — the ConstraintValidator — for the rest. This
guide builds each layer with working code, and shows exactly where the framework
stops and a verification API takes over.
The short answer#
Use Jakarta Bean Validation's @Email for syntax, a JNDI MX lookup for the
domain, and a verification API for the SMTP mailbox check — wrapped in a custom
@Deliverable constraint that runs after @Email passes. Don't open SMTP
connections from your app to probe mailboxes yourself: outbound port 25 is
blocked on most hosts, and the answer depends on sending-IP reputation and
greylisting you don't want to reimplement. Each layer rules addresses out more
cheaply than the last; only the final one can rule an address in. It's the same
three-layer shape as the plain Java validation guide,
fitted to Spring's validation lifecycle.
Layer 1: format validation with @Email and @Valid#
Spring Boot owns layer one through Jakarta Bean Validation. Add
spring-boot-starter-validation to the project and annotate the field on your
request DTO with jakarta.validation.constraints.Email. Pair it with @NotBlank,
because Bean Validation constraints treat null as valid by design — @Email
returns true for a missing value, so presence is a separate concern:
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
) {}
The @Valid annotation on the controller argument is what triggers validation
before your method body runs. A failing constraint short-circuits into a
MethodArgumentNotValidException, which Spring turns into a 400 for you:
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());
}
}
Know exactly what this buys you. @Email checks shape against a permissive
default pattern — it never resolves DNS, never opens a socket, and has no idea
whether example.com exists. definitely-fake@gmail.com passes.
typo@gmial.com passes. Both are undeliverable, and no annotation that only
reads the string will ever tell you so, for the same reason
regex email validation fails: syntax
and deliverability are different questions. One is a fact about the string; the
other is a fact about the internet.
Layer 2: can the domain receive mail?#
A domain with no MX records can't accept mail for anyone, so a single MX lookup
eliminates dead domains, misspelled company names, and invented TLDs. Because
Spring Boot runs on the JVM, you get this with no external dependency at all: the
JDK bundles a DNS provider for JNDI, so you resolve the MX attribute through a
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;
}
}
}
You could wrap this in its own ConstraintValidator and stack it as a second
annotation. But the SMTP layer needs the same DNS work plus a live conversation
with the mail host — so rather than maintain a separate MX validator and then a
mailbox probe, it's cleaner to let one API call own both network layers. The
local MX check stays useful as an optional fast-fail; the verification service
does the DNS step internally either way.
Layer 3: does the mailbox actually exist?#
Layers one and two can only rule an address out. A domain can publish perfect
MX records and still have no mailbox at the address you're holding —
noreply-9f2x@gmail.com is valid syntax on a live mail route, and it's still a
mailbox that was never created. Confirming a specific mailbox means the SMTP
delivery conversation: connect to the mail host, issue RCPT TO, read the reply,
and disconnect before sending anything. There's more to it — catch-all domains
accept every address and defeat a naive probe —
how email verification works walks the full
pipeline.
You can script SMTP from the JVM with a raw Socket, but you shouldn't run it
from your application. Most cloud providers block outbound port 25, the answer
depends on the reputation of the IP you connect from, and receiving servers
greylist and rate-limit unfamiliar senders — so a probe that passes in a local
test quietly fails, or gets you blocklisted, in production. This is the layer
worth delegating.
Spring's idiomatic hook is a custom constraint. Define a @Deliverable
annotation and a validator that calls the verification API. Start with the
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 {};
}
Because spring-boot-starter-validation wires Hibernate Validator through
Spring's SpringConstraintValidatorFactory, the validator is a managed bean —
so @Value injection and constructor autowiring work inside it. Java 11+ ships
java.net.http.HttpClient, so you need no HTTP dependency; pair it with Jackson,
which Spring Boot already puts on the classpath. Keep the key in an environment
variable — never hard-code it:
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;
}
}
}
Point the property at the environment variable in application.properties:
qualisend.api-key=${QUALISEND_API_KEY}
The endpoint above is a placeholder — check the API reference for
the exact base URL and request shape — but the response reads back as a
{ "result": { ... } } envelope:
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
status is one of deliverable, risky, undeliverable, or unknown. The
validator above hard-fails only undeliverable and lets risky and unknown
through, so you can decide downstream what to do with them — gate on the score,
or branch on a sub_flags entry like disposable — instead of turning a
borderline address into a form error. Treat the JSON above as the shape, not the
contract; the full field list lives in the developer docs.
Running the deliverability check after @Email with validation groups#
There's a catch. By default, Bean Validation runs every constraint on a field
regardless of the others, so @Deliverable would fire — and spend an API credit
— even when @Email already knows the string is garbage. You want ordering:
format first, network round-trip only if it passes.
Bean Validation expresses ordering with groups and a @GroupSequence. Define
a marker interface for the network layer, then put @Deliverable in that group
while @NotBlank and @Email stay in the default one:
public interface NetworkChecks {}
Now redefine the default group sequence on the DTO itself. Listing the class
first (which stands for its own default constraints) and NetworkChecks second
tells Hibernate Validator to validate format first and only reach the deliverable
check if that step is clean:
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
) {}
Two things make this work. Because the sequence redefines the default group,
the plain @Valid on your controller still triggers it — no @Validated(...)
group argument needed. And because sequence steps short-circuit, a malformed
address fails at the @Email step and the API is never called. That ordering is
the whole trick: the cheap local check guards the expensive network one.
Putting the layers together to validate an email address in Spring Boot#
The DTO above already composes all three layers; the controller stays the one-line
@Valid @RequestBody from layer one. The only thing left is to shape the error
response. Spring emits a MethodArgumentNotValidException for any failed
constraint, so a small @RestControllerAdvice turns it into a clean field-keyed
400:
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;
}
}
The two local layers cost nothing and catch most junk instantly; the API runs only on addresses worth the network round-trip. That's the same shape you'll find in the Java version of this guide, because the layering — not the framework — is what makes validation work. Run this fast synchronous pipeline at signup and reserve heavier passes for back-office work: the serverless signup guide shows the real-time pattern, and how to clean an email list covers the batch side.
Frequently asked questions#
Is @Email enough to validate an email address in Spring Boot?#
For syntax, it's the right layer-one tool — jakarta.validation.constraints.Email
is better tested than a hand-rolled regex and integrates with @Valid and
Spring's error handling for free. But it validates shape only: it never resolves
DNS or contacts a mail server, and it treats null as valid, so pair it with
@NotBlank. A pass means "looks like an email," not "will deliver" — add an MX
lookup and an SMTP mailbox check before you trust the address.
How do I write a custom email validation annotation in Spring Boot?#
Create an annotation meta-annotated with @Constraint(validatedBy = ...) and a
class implementing ConstraintValidator<YourAnnotation, String>. Put your logic
in isValid, returning false to fail. With spring-boot-starter-validation,
the validator is a Spring bean, so you can inject config and HTTP clients into it.
Return true for null/blank so @NotBlank and @Email own those cases, and
annotate the DTO field to apply it.
How do I make the deliverability check run only after @Email passes?#
Use validation groups. Put the expensive @Deliverable constraint in a marker
group like NetworkChecks, then add @GroupSequence({ SignupRequest.class, NetworkChecks.class }) to the DTO to redefine its default group sequence. Because
sequence steps short-circuit, @Email runs first and the API-backed constraint
only fires if the format is already valid — and plain @Valid still triggers it,
since the sequence redefines the default group.
Should I validate emails at signup or when cleaning a list?#
Both, at different depths. Run the format and deliverability layers synchronously
at signup — they're fast enough to block the request and give instant feedback —
and act on the API's status there too. Reserve deeper, batched verification for
cleaning an existing list, where latency doesn't matter and thoroughness does.
Ready to add the mailbox layer? Drop a syntactically perfect address into the
free email checker to watch an @Email-approved string
come back undeliverable, then wire the same verdict into a ConstraintValidator
with the /verify endpoint and full result envelope in the
API reference.