How to validate an email address in Spring Boot
Spring's @Email annotation stops at syntax; here's a custom ConstraintValidator that adds the deliverability layer without pretending to do more than it does.
Contents
Spring Boot hands you email validation for free. Put @Email on a field, add @Valid to the controller method, and a malformed address gets bounced with a 400 before your code runs. For a lot of forms that is the whole job, and you should not add a network call to do work a regex already does. The trouble starts when you assume @Email means "this address can receive mail." It does not. It means the string looks roughly like an email. This post keeps the annotation for what it is good at, then bolts on a second, honest layer for the part it never claimed to cover.
What @Email actually checks (and what it doesn't)
@Email comes from Jakarta Bean Validation, and in a standard Spring Boot app the implementation is Hibernate Validator. It is a syntax check. It runs a regex against the local part and the domain and returns true or false. It does not resolve DNS, it does not connect to a mail server, and it is deliberately lenient about the domain.
The lenience surprises people. By default @Email accepts user@localhost and bob@internal — no dot, no top-level domain, no MX record required. That is not a bug. Bean Validation is meant to be fast, deterministic, and offline; a regex that phoned home would violate every one of those expectations. So the annotation stays in its lane.
public record SignupRequest(
@NotBlank
@Email(message = "That does not look like an email address")
String email
) {}
This is worth keeping. It rejects not-an-email and two@@ats.com instantly, on the request thread, with zero external dependencies. What it will happily wave through is carol@mailinator.com (disposable), sales@yourclient.com (a role inbox nobody reads), and dave@domain-with-no-mx.example (nothing will ever be delivered there). Those are deliverability questions, and no syntax check — in any framework — answers them.
The layer past syntax
To know whether a domain can actually accept mail, you have to look up its MX records. That is a DNS question, not a string question. (If the term is new, what is an MX record walks through it.) You also often want to know whether the domain is a known disposable provider and whether the local part is a role account rather than a person.
Boundstone's POST /v1/verify/email does exactly those three things and tells you, in every response, which checks it ran and which it did not:
{
"valid_syntax": true,
"domain": "example.com",
"mx_found": true,
"disposable": false,
"role_account": false,
"free_provider": false,
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
Read the not_performed array before you trust the performed one. Boundstone does not open an SMTP session to probe the mailbox, and it does not detect catch-all domains — those checks are unreliable, get you rate-limited or blocked by mail servers, and vendors who claim them are usually guessing. A mx_found: true here means the domain is set up to receive mail, not that this specific inbox exists. That distinction is the honesty contract, and it is the whole reason to trust the fields that are there.
Want to eyeball a single address before you write any code? The keyless email validator tool runs the same check in the browser.
A custom constraint that calls the API
Wire the deliverability layer in as its own annotation so it composes with @Email instead of replacing it. First the marker:
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DeliverableEmailValidator.class)
public @interface ValidEmail {
String message() default "Email domain cannot receive mail";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Then the validator. RestClient (Spring Framework 6.1+, Spring Boot 3.2+) is the synchronous HTTP client that fits a blocking validation call; use WebClient instead if your stack is reactive.
@Component
public class DeliverableEmailValidator
implements ConstraintValidator<ValidEmail, String> {
private final RestClient client;
public DeliverableEmailValidator(
@Value("${boundstone.api-key}") String apiKey) {
this.client = RestClient.builder()
.baseUrl("https://api.boundstone.io")
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
@Override
public boolean isValid(String email, ConstraintValidatorContext ctx) {
if (email == null || email.isBlank()) {
return true; // @NotBlank / @Email own emptiness and syntax
}
EmailResult r = client.post()
.uri("/v1/verify/email")
.body(Map.of("email", email))
.retrieve()
.body(EmailResult.class);
if (r == null) return true; // transport failure: fail open, your call
return r.mxFound() && !r.disposable() && !r.roleAccount();
}
public record EmailResult(
@JsonProperty("mx_found") boolean mxFound,
boolean disposable,
@JsonProperty("role_account") boolean roleAccount,
@JsonProperty("free_provider") boolean freeProvider
) {}
}
Note free_provider is a returned flag, not a check in performed — Gmail and Outlook are perfectly deliverable, so decide per use case whether a free provider is a reason to reject at all. Keep the key server-side: read it from boundstone.api-key=${BOUNDSTONE_API_KEY} in application.properties, never hard-code it, and never let a bs_live_ key reach a browser or a client-side config.
Apply both annotations and let each do its half:
public record SignupRequest(
@NotBlank @Email @ValidEmail String email
) {}
Where to put the network call (a caveat)
Bean Validation was designed to be pure and fast, and a validator that makes an HTTP request is neither. Every validated request now depends on a third-party call, so budget a timeout on the RestClient and decide deliberately whether a Boundstone outage should reject signups (fail closed) or wave them through (fail open — what the code above does). If that coupling bothers you, move the same call into your service layer after the controller returns, where retries, caching, and async handling are easier to reason about. The deeper validate an email in Java walkthrough covers those framework-agnostic patterns.
The short version
Keep @Email — it is a fast, offline syntax gate, and it is honest about being nothing more. It accepts user@localhost, so do not read it as "this email works." Add a @ValidEmail ConstraintValidator that calls POST /v1/verify/email for the MX, disposable, and role checks syntax can never do, and read the not_performed array so you know a valid is really valid. Boundstone's free tier is 250 credits a month, no card, and the credits never expire.