# How to validate an email address in Spring Boot

_2026-10-26 · Boundstone (https://boundstone.io/blog/validate-email-spring-boot)_


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.

```java
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](/blog/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:

```json
{
  "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](/tools/email-validator) 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:

```java
@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.

```java
@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:

```java
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](/blog/validate-email-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.
