Skip to content
無料の100クレジットで検証を始める
Qualisend
すべての記事
エンジニアリング / 2026年5月9日

Spring Bootでメールアドレスを検証する方法

約11分で読めます

Qualisend team
Spring Bootでメールアドレスを検証する方法

Spring Bootでメールアドレスを検証する処理は、実は1つのアノテーションの裏に3つのチェックが隠れています。DTOのフィールドに@Emailを付け、コントローラーに@Validを組み込めば、Springは文字列がアドレスの形をしていることを確認してくれます。ただし、これは本当に知りたい3つの事柄のうち、最も役に立たないものです。実際の検証は層構造で行います。手軽な形式チェック、ドメインのメール経路を調べるDNS検索、そしてSMTPによるメールボックスの確認です。Jakarta Bean Validationは、1層目を標準で提供し、残りの層には拡張ポイントのConstraintValidatorを用意しています。このガイドでは、各層を動作するコードで構築し、フレームワークの守備範囲が終わって検証APIの出番となる境目を具体的に示します。

結論#

構文チェックにはJakarta Bean Validationの@Email、ドメインにはJNDIによるMX検索、SMTPによるメールボックス確認には検証APIを使います。このうち検証APIを、@Emailに成功した後に実行されるカスタム制約@Deliverableに組み込みます。アプリからSMTP接続を開いてメールボックスを自前で確認するのはおすすめしません。ほとんどのホスティング環境ではアウトバウンドのポート25がブロックされています。また、結果は送信元IPのレピュテーションやグレーリスティングに左右され、これを自前で再現するのは割に合いません。後の層ほど、アドレスを除外するコストは高くなりますが、アドレスを有効と断定できるのは最後の層だけです。この3層構造は、通常のJavaでの検証ガイドと同じで、Springの検証ライフサイクルに合わせたものです。

1層目:@Emailと@Validによる形式チェック#

Spring Bootでは、Jakarta Bean Validationが1層目を担当します。プロジェクトにspring-boot-starter-validationを追加し、リクエストDTOのフィールドにjakarta.validation.constraints.Emailを付けます。@NotBlankも併用してください。Bean Validationの制約は仕様上nullを有効とみなすため、@Emailは値がなくてもtrueを返します。つまり、値の有無は別の関心事です。

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
) {}

コントローラーの引数に付けた@Validが、メソッド本体の実行前に検証を起動します。制約に違反するとMethodArgumentNotValidExceptionが発生し、Springがこれを400に変換します。

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());
    }
}

これで何が得られるのかを正確に理解しておきましょう。@Emailが確認するのは、寛容なデフォルトパターンに照らした形だけです。DNSの解決もソケットのオープンもせず、example.comが存在するかどうかも分かりません。definitely-fake@gmail.comは通ります。typo@gmial.comも通ります。どちらも配信できないアドレスですが、文字列を読むだけのアノテーションでは判別できません。理由は正規表現によるメール検証が失敗するのと同じで、構文と到達可能性は別の問いだからです。前者は文字列についての事実であり、後者はインターネットについての事実です。

2層目:ドメインはメールを受信できるか#

MXレコードのないドメインは、誰宛てのメールも受け付けられません。そのため、MX検索を1回行うだけで、機能していないドメイン、社名のスペルミス、架空のTLDを除外できます。Spring BootはJVM上で動作するので、外部依存なしでこれを実現できます。JDKにはJNDI用のDNSプロバイダーが同梱されており、DirContextを通じてMX属性を解決します。

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;
        }
    }
}

これを独自のConstraintValidatorでラップし、2つ目のアノテーションとして重ねることもできます。ただしSMTPの層でも、同じDNS処理に加えてメールホストとの実際のやり取りが必要です。MXの検証とメールボックスの確認を別々に保守するより、1回のAPI呼び出しで両方のネットワーク層をまとめて任せるほうがすっきりします。ローカルのMXチェックは、任意の高速な事前判定として引き続き役立ちます。いずれにせよ、検証サービス側でもDNSの手順は内部で実行されます。

3層目:メールボックスは実際に存在するか#

1層目と2層目でできるのは、アドレスの除外だけです。ドメインに正しいMXレコードがあっても、手元のアドレスにメールボックスがあるとは限りません。noreply-9f2x@gmail.comは、構文として正しく、稼働中のメール経路もありますが、作成されたことのないメールボックスです。特定のメールボックスを確認するには、SMTPの配信のやり取りが必要です。メールホストに接続してRCPT TOを発行し、応答を読み、メールを送る前に切断します。実際にはもう少し複雑で、キャッチオールドメインはすべてのアドレスを受け付けるため、単純な確認では見抜けません。パイプライン全体はメールアドレス検証の仕組みで解説しています。

JVMから生のSocketでSMTPを実行するスクリプトは書けますが、アプリケーションから実行すべきではありません。ほとんどのクラウドプロバイダーはアウトバウンドのポート25をブロックしています。また、結果は接続元のIPのレピュテーションに左右され、受信側サーバーは見慣れない送信元をグレーリスティングしたりレート制限したりします。ローカルのテストでは成功する確認が、本番では気づかないうちに失敗したり、接続元のIPがブラックリストに載る原因になったりするのです。この層こそ、外部に任せる価値があります。

Springで定石となる拡張方法は、カスタム制約です。@Deliverableアノテーションと、検証APIを呼び出すバリデーターを定義します。まずアノテーションです。

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 {};
}

spring-boot-starter-validationはHibernate ValidatorをSpringConstraintValidatorFactory経由で組み込むため、バリデーターは管理対象のBeanになります。そのため、内部で@Valueによるインジェクションやコンストラクターの自動注入が使えます。Java 11以降にはjava.net.http.HttpClientが含まれているので、HTTP用の依存関係は不要です。Spring Bootがすでにクラスパスに置いているJacksonと組み合わせます。キーは環境変数に保存し、コードに直接書かないでください。

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> {

    // Base URL https://app.qualisend.com/api/v1; see /developers for the full reference.
    private static final String ENDPOINT = "https://app.qualisend.com/api/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
            // smtp "pending": the mailbox probe is still running and `status` is provisional.
            // Don't wait here; fetch the final verdict later from GET /jobs/{job_id}.
            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;
        }
    }
}

application.propertiesで、このプロパティを環境変数に対応させます。

qualisend.api-key=${QUALISEND_API_KEY}

上記のエンドポイントは実際に稼働しています。ベースURLはhttps://app.qualisend.com/api/v1で、リクエストの詳しい形式はAPIリファレンスにあります。レスポンスは、job_idと{ "result": { ... } }というエンベロープで返ります。

{
  "job_id": "8b6f2c1e-4d3a-4f5b-9c7e-2a1d0e9f6b3c",
  "result": {
    "status": "deliverable",
    "score": 95,
    "reason": "accepted_email",
    "smtp": "cached",
    "sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
  }
}

statusはdeliverable、risky、undeliverable、unknownのいずれかです。上記のバリデーターがエラーにするのはundeliverableだけで、riskyとunknownは通します。これらをどう扱うかは後段で決められます。たとえばscoreで判定したり、disposableなどのsub_flagsの項目で分岐したりできるので、判断の難しいアドレスをフォームエラーにしてしまうことはありません。上記のJSONはあくまで形の例であり、仕様の保証ではありません。項目の全一覧は開発者向けドキュメントにあります。

statusを信頼する前に、smtpを確認してください。cachedとskippedは、resultがすでに確定していることを示します。pendingはメールボックスの確認がまだ実行中であることを示し、statusはローカルのチェックだけに基づく暫定の判定です。このときバリデーターは、サインアップを通します。確認が終わるまでサインアップのリクエストを開いたままにしないでください。job_idを保持し、バックグラウンドジョブから数秒おきにGET /jobs/{job_id}をポーリングして、completedになった時点で最終的な判定に基づいて対応します。結果を更新するために、同じアドレスで/verifyを再度呼び出さないでください。

検証グループで@Emailの後に到達可能性チェックを実行する#

ここには落とし穴があります。Bean Validationは既定で、フィールド上のすべての制約を互いに関係なく実行します。そのため、@Emailがすでに文字列を不正と判断していても@Deliverableが動作し、APIクレジットを消費してしまいます。必要なのは順序制御です。まず形式を確認し、通ったときだけネットワークへの問い合わせを行います。

Bean Validationでは、グループと@GroupSequenceで順序を指定します。ネットワーク層用のマーカーインターフェースを定義し、@Deliverableをそのグループに入れます。@NotBlankと@Emailはデフォルトのグループに残します。

public interface NetworkChecks {}

次に、DTO自体でデフォルトのグループシーケンスを再定義します。クラスを先頭に置き(自身のデフォルト制約を表します)、NetworkChecksを2番目に置くと、Hibernate Validatorは形式を先に検証し、そこに問題がない場合にだけ到達可能性チェックへ進みます。

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
) {}

これが機能する理由は2つあります。1つは、シーケンスがデフォルトのグループを再定義するため、コントローラーの@Validだけで起動できることです。@Validated(...)でグループを指定する必要はありません。もう1つは、シーケンスはいずれかのステップで失敗するとそこで打ち切られるため、不正な形式のアドレスは@Emailのステップで失敗し、APIが呼ばれないことです。この順序こそが要点です。手軽なローカルのチェックが、コストのかかるネットワークのチェックの手前で関門の役割を果たします。

3つの層を組み合わせてSpring Bootでメールアドレスを検証する#

上記のDTOはすでに3つの層をすべて組み合わせており、コントローラーは1層目の@Valid @RequestBodyの1行のままです。残る作業は、エラーレスポンスの整形だけです。Springは制約違反のたびにMethodArgumentNotValidExceptionを発生させるので、小さな@RestControllerAdviceを用意すると、フィールド名をキーにしたすっきりした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;
    }
}

ローカルの2つの層はコストがかからず、不要なアドレスの大半をすぐに除外できます。APIが実行されるのは、ネットワークへの問い合わせに見合うアドレスだけです。これは、このガイドのJava版とまったく同じ構造です。検証を成り立たせているのは、フレームワークではなく層構造だからです。この高速な同期パイプラインはサインアップ時に実行し、より重い処理はバックオフィスの作業に取っておきましょう。リアルタイムのパターンはサーバーレスのサインアップ検証ガイドで、バッチ側はメールリストのクリーニング方法で解説しています。

よくある質問

01Spring Bootでのメール検証は@Emailだけで十分ですか?

構文チェックとしては、1層目に適したツールです。jakarta.validation.constraints.Emailは自作の正規表現よりよくテストされており、@ValidやSpringのエラー処理と標準で連携します。ただし検証するのは形式だけです。DNSの解決もメールサーバーへの接続も行わず、nullを有効とみなすため、@NotBlankと併用してください。成功しても意味するのは「メールアドレスの形をしている」ことで、「配信できる」ことではありません。アドレスを信頼する前に、MX検索とSMTPによるメールボックスの確認を追加してください。

02Spring Bootでカスタムのメール検証アノテーションを作るにはどうすればよいですか?

@Constraint(validatedBy = ...)を付けたアノテーションと、ConstraintValidator<YourAnnotation, String>を実装したクラスを作成します。ロジックはisValidに書き、失敗させるときはfalseを返します。spring-boot-starter-validationを使うとバリデーターはSpringのBeanになるため、設定やHTTPクライアントを注入できます。nullや空白のときはtrueを返し、これらのケースは@NotBlankと@Emailに任せます。最後に、DTOのフィールドにアノテーションを付ければ適用されます。

03@Emailに成功した後にだけ到達可能性チェックを実行するにはどうすればよいですか?

検証グループを使います。コストのかかる@Deliverable制約をNetworkChecksのようなマーカーグループに入れ、DTOに@GroupSequence({ SignupRequest.class, NetworkChecks.class })を追加して、デフォルトのグループシーケンスを再定義します。シーケンスはいずれかのステップで失敗するとそこで打ち切られるため、先に@Emailが実行され、APIを使う制約は形式がすでに有効な場合にだけ動作します。シーケンスがデフォルトのグループを再定義するので、通常の@Validでもそのまま起動します。

04メール検証はサインアップ時とリストのクリーニング時のどちらで行うべきですか?

どちらも行いますが、深さが異なります。形式と到達可能性の層はサインアップ時に同期で実行します。リクエストを止めても支障がない速さで、すぐにフィードバックを返せるためです。APIのstatusに基づく対応もその場で行います。より深いバッチ検証は、既存リストのクリーニング用に取っておきます。こちらでは、待ち時間よりも徹底度が重要です。


メールボックスの層を追加する準備はできましたか。まず、構文として完璧なアドレスを無料メールチェッカーに入力し、@Emailが承認した文字列がundeliverableと判定される様子を確認してください。その後、/verifyエンドポイントとresultエンベロープ全体を使って、同じ判定をConstraintValidatorに組み込みましょう。詳細はAPIリファレンスにあります。

送信者の信頼を、守る。

最初のリストを数分でクリーニング。100クレジット無料、クレジットカード登録は不要です。

今すぐ始める