Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / May 9, 2026

How to validate an email address in Spring Boot

7 minutes read

Qualisend team
A Spring Boot code window validating an email through format, DNS, and mailbox layers to a deliverable verdict

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.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started