Javaでメールアドレスを検証する処理は、実は1つの名前の裏に3つのチェックが隠れています。多くのチュートリアルは正規表現を持ち出し、文字列がアドレスの形をしていることを確認して終わりにします。しかし、形は本当に知りたい3つの事柄のうち、最も役に立たないものです。実際の検証は層構造で行います。手軽な形式チェック、ドメインのメール経路を調べるDNS検索、そしてSMTPによるメールボックスの確認です。最初の2つはJavaの標準ライブラリとJakarta Mailで無理なく実現できますが、3つ目はネットワークの問題であり、外部に任せる価値があります。このガイドでは、各層を動作するコードで構築し、それぞれの限界がどこにあるのかを具体的に示します。
結論#
構文チェックにはjakarta.mail.internet.InternetAddress(またはApache Commons Validator)、MX検索にはJDK標準のJNDI DNSプロバイダー、SMTPによるメールボックス確認には検証APIを使います。コストの低い順に実行し、結果が確定した時点で処理を打ち切ります。アプリからSMTP接続を開いてメールボックスを自前で確認するのはおすすめしません。ほとんどのホスティング環境ではアウトバウンドのポート25がブロックされています。また、結果は送信元IPのレピュテーションやグレーリスティングに左右され、これを自前で再現するのは割に合いません。後の層ほど、アドレスを除外するコストは高くなりますが、アドレスを有効と断定できるのは最後の層だけです。
1層目:Jakarta Mailによる構文チェック#
Jakarta Mailは、Jakarta EEの名前空間への移行に伴ってjavax.mailから名称が変わったライブラリです。アドレスのパーサーが同梱されているので、正規表現を自作する必要はありません。InternetAddressを生成してvalidate()を呼び出します。コンストラクターがアドレスを解析し、validate()がRFC 822の構文を検証して、不正な形式ならAddressExceptionをスローします。
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
public static boolean isValidSyntax(String email) {
if (email == null || email.length() > 320) {
return false;
}
try {
InternetAddress address = new InternetAddress(email);
address.validate();
// Rejects "Jane <jane@example.com>": the parser keeps only the address part.
return email.equals(address.getAddress());
} catch (AddressException e) {
return false;
}
}
最後の比較は、多くのサンプルが見落とす抜け穴をふさぎます。InternetAddressはPersonal Name <user@host.domain>の形式を解析すると仕様に明記されており、validate()が確認するのはアドレス部分だけです。そのためJane <jane@example.com>はどちらも通ってしまいます。この比較がないと、2層目に渡るドメインはexample.com>になります。getAddress()が元の入力と等しいことを条件にすれば、名前や山括弧を含むものはすべて弾けます。
知っておきたい点が1つあります。validate()はドメインに対して寛容です。RFC 822はドットのないドメインを認めているため、jane@localhostやjane@exampleのようにドットがなくても通ります。ここではそれで問題ありません。1層目は形式のゲートであって到達可能性のチェックではなく、ドメインがメールを受信できるかどうかを実際に判定するのは2層目のDNS検索だからです。このチェックは寛容なままにして、除外はDNSに任せましょう。巨大な正規表現で形式の完璧さを追い求めるのは、そもそも勝ち目のない戦いです。
古いjavax.mailのjarを使っている場合も、パッケージ名以外のコードは同じです。すでにApache Commons Validatorに依存しているなら、EmailValidatorも1層目の代替として有力です。こちらはやや厳しく、ドットのないドメインをデフォルトで拒否します。
import org.apache.commons.validator.routines.EmailValidator;
boolean valid = EmailValidator.getInstance().isValid(email);
すでにメール送信にJakarta Mailを使っているなら、依存関係が1つ少なくなるのでそちらがおすすめです。どちらのライブラリも、答えられるのは構文の問題だけです。Bean Validationの@Emailも同様で、こちらはSpring Bootのガイドで解説しています。
Javaのメール検証用正規表現(どうしても必要な場合)#
ライブラリを追加できない場合や、フロントエンドと1つのルールを共有したい場合は、正規表現を寛容に保ち、1回だけコンパイルします。
import java.util.regex.Pattern;
// Dot-separated runs with no whitespace, @ or quotes, one @,
// then a domain with at least one dot.
private static final Pattern EMAIL = Pattern.compile(
"[^\\s@.\"]+(?:\\.[^\\s@.\"]+)*@[^\\s@.]+(?:\\.[^\\s@.]+)+",
Pattern.UNICODE_CHARACTER_CLASS);
public static boolean looksLikeEmail(String email) {
return email != null && email.length() <= 320 && EMAIL.matcher(email).matches();
}
これは正規表現によるメール検証が失敗する理由で推奨している、寛容なパターンと同じものです。ローカル部の各部分にはドットを含めないため、a..b@example.com、.a@example.com、a.@example.comはいずれも失敗します。matches()は入力全体に一致する必要があるので、^や$は不要です。find()は一致する部分文字列を探すため、junk jane@example.com junkも通してしまいます。UNICODE_CHARACTER_CLASSを指定すると、JavaScriptと同様に、\sがUnicodeの空白にも一致します。
よくある落とし穴が2つあります。1つは[a-zA-Z]{2,7}のようなTLDの長さ制限で、.photographyなどの実在のTLDを拒否してしまいます。もう1つは、サインアップフォームが求めない形式まで通してしまう、肥大化した「RFC 5322」のパターンです。一致が証明するのは形だけで、それ以上のことは何も証明しません。
2層目:ドメインはメールを受信できるか#
メール経路のないドメインは、誰宛てのメールも受け付けられません。そのため、DNS検索を素早く行うだけで(まずMX、次にフォールバックとしてドメイン自身のアドレス)、機能していないドメイン、社名のスペルミス、架空のTLDを除外できます。Javaなら外部依存なしでこれを実現できます。JDKにはJNDI用のDNSサービスプロバイダーが同梱されており、DirContextを通じてMX属性を解決します。
import java.util.Hashtable;
import javax.naming.NameNotFoundException;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
public static boolean hasMailRoute(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");
DirContext ctx = null;
try {
ctx = new InitialDirContext(env);
Attribute mx = ctx.getAttributes(domain, new String[] { "MX" }).get("MX");
if (mx != null && mx.size() > 0) {
return true;
}
// No MX: mail goes to the domain's own address (A or AAAA), the implicit MX.
// One type per call: asking for several at once sends an ANY query.
for (String type : new String[] { "A", "AAAA" }) {
Attribute addr = ctx.getAttributes(domain, new String[] { type }).get(type);
if (addr != null && addr.size() > 0) {
return true;
}
}
return false;
} catch (NameNotFoundException e) {
return false; // NXDOMAIN: the domain doesn't exist
} catch (NamingException e) {
return true; // timeout or resolver failure: can't tell, so don't reject
} finally {
if (ctx != null) {
try { ctx.close(); } catch (NamingException ignored) { }
}
}
}
アドレスからドメインを切り出すときは、引用符で囲まれたローカル部に惑わされないよう、最後の@で分割します。
String domain = email.substring(email.lastIndexOf('@') + 1);
hasMailRoute("gmail.com"); // true
hasMailRoute("company-that-folded.com"); // false
MX属性がない、または空の場合は、そのドメインがMXレコードを公開していないことを意味します。NameNotFoundException(NamingExceptionのサブクラス)は、ドメインがまったく解決されないことを意味します。ドメインによっては、暗黙のMXとしてAレコードまたはAAAAレコードでメールを受け付けます。そのためこのコードでは、MXが空で返ってきたとき"A"と"AAAA"の検索にフォールバックし、1回の呼び出しにつき1つのタイプだけを問い合わせます。JNDIは複数タイプのリクエストをANYクエリに変換し、多くのDNSサーバーは最小限のプレースホルダーしか返さないためです。実在するアドレスの圧倒的多数には、MXチェックが適切なフィルターです。検索はDNSリゾルバーの応答を待ってブロックされるため、サインアップの処理では上記のタイムアウトを維持してください。一時的な失敗は即座に拒否せず「後で確認」として扱います。DNSの一時的な不調で、本物の顧客を締め出すべきではありません。そこで、タイムアウトなどそれ以外のNamingExceptionはtrueを返し、判断を3層目に委ねます。ドメインを手動で確認するには、MXレコード確認を使ってください。
3層目:メールボックスは実際に存在するか#
1層目と2層目でできるのは、アドレスの除外だけです。ドメインに正しいMXレコードがあっても、手元のアドレスにメールボックスがあるとは限りません。noreply-9f2x@gmail.comは、構文として正しく、メール経路も稼働しているドメイン上にありますが、作成されたことのないメールボックスです。特定のメールボックスが存在することを確認するには、SMTPの配信のやり取りが必要です。メールホストに接続してRCPT TOを発行し、応答コードを読み、メールを送る前に切断します。実際にはもう少し複雑で、キャッチオールドメインはすべてのアドレスを受け付けるため、単純な確認では見抜けません。パイプライン全体はメールアドレス検証の仕組みで解説しています。
JavaでSMTPを実行するスクリプトは生のSocketで書けますが、アプリケーションから実行すべきではありません。ほとんどのクラウドプロバイダーはアウトバウンドのポート25をブロックしています。また、結果は接続元のIPのレピュテーションに左右され、受信側サーバーは見慣れない送信元をグレーリスティングしたりレート制限したりします。ローカルのテストでは成功する確認が、本番では気づかないうちに失敗したり、接続元のIPがブラックリストに載る原因になったりするのです。この層こそ、外部に任せる価値があります。
Qualisendの検証APIは、このパイプラインを、そのために構築されたレピュテーション管理済みのインフラから実行します。POST /verifyは、ローカルのチェック(構文、DNS、使い捨てアドレスと入力ミスの検出)による即時の判定とjob_idを返します。メールボックスの確認がまだ保留中なら、その判定はこのジョブに反映されます。Java 11以降にはjava.net.http.HttpClientが含まれているので、HTTP用の依存関係は不要です。JSONの読み取りにはJackson(またはGson)を組み合わせます。キーは環境変数に保存し、コードに直接書かないでください。
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;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class QualisendClient {
private static final String BASE_URL = "https://app.qualisend.com/api/v1";
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
private final String apiKey = System.getenv("QUALISEND_API_KEY"); // holds YOUR_API_KEY
/** POST /verify: a job_id plus the instant verdict under "result". */
public JsonNode verify(String email) throws IOException, InterruptedException {
String payload = mapper.writeValueAsString(Map.of("email", email));
return send(HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/verify"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload)));
}
/** GET /jobs/{id}: the job's status and, once completed, its results. */
public JsonNode job(String jobId) throws IOException, InterruptedException {
return send(HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/jobs/" + jobId + "?include=results"))
.GET());
}
private JsonNode send(HttpRequest.Builder builder) throws IOException, InterruptedException {
HttpRequest request = builder
.header("Authorization", "Bearer " + apiKey)
.timeout(Duration.ofSeconds(10)) // throws HttpTimeoutException past this
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
throw new ApiException(response.statusCode(),
response.headers().firstValue("Retry-After").orElse(null));
}
return mapper.readTree(response.body());
}
/** A non-2xx reply. It extends IOException, like HttpTimeoutException. */
public static class ApiException extends IOException {
public final int status;
public final String retryAfter; // seconds, sent with a 429
ApiException(int status, String retryAfter) {
super("Qualisend responded " + status);
this.status = status;
this.retryAfter = retryAfter;
}
}
}
必要なフィールドは、resultノードから読み取ります。
JsonNode body = client.verify("jane@example.com");
JsonNode result = body.get("result");
String status = result.get("status").asText(); // deliverable | risky | undeliverable | unknown
int score = result.path("score").asInt(); // 0–100 confidence
String smtp = result.path("smtp").asText(); // pending | cached | skipped
String reason = result.hasNonNull("reason")
? result.get("reason").asText() // machine-readable reason, null while pending
: null;
boolean disposable = result.path("sub_flags").path("disposable").asBoolean();
String jobId = body.path("job_id").asText(); // where the mailbox verdict lands
レスポンスの正確な形式(すべてのsub_flagsのキーとreasonコード)は、APIリファレンスにあります。リアルタイムのサインアップ検証では、多くの場合statusだけで対応を決められます。undeliverableは拒否し、riskyとunknownはポリシーに従って扱い、disposableのアドレスは入力欄でフラグを立てます。
SMTPの判定を得る:ジョブをポーリングする#
statusを最終結果として扱う前に、smtpを確認してください。cached(最近の結果を再利用した)またはskipped(ローカルのチェックで結論が出た)の場合、resultは確定しており、reasonも入っています。pendingの場合は、メールボックスの確認がキューに入っており、レスポンスの返却後に実行されます。そのためstatusは暫定のunknownで、reasonはnullです。この時点で拒否してはいけません。グレーリスティングを行うサーバーは、見慣れない送信元にはリトライ時にしか応答しないため、メールボックスの判定には数秒ではなく数分かかることがあります。
サインアップ時には、即時の判定ですでに不正な構文、機能していないドメイン、使い捨てアドレスは検出されています。そのため、保留中のunknownは通してかまいません。メールボックスの結果が必要な場合は、リクエストスレッドではなくバックグラウンドワーカーからジョブをポーリングし、決められた回数で打ち切ってください。
// Runs in a background worker, never on the request thread.
public JsonNode awaitMailboxVerdict(String jobId, int maxAttempts)
throws InterruptedException {
for (int attempt = 0; attempt < maxAttempts; attempt++) {
try {
JsonNode job = client.job(jobId);
String status = job.path("status").asText();
if (status.equals("completed")) {
return job.path("results").path(0); // the post-SMTP verdict
}
if (status.equals("canceled") || status.equals("failed")) {
break;
}
} catch (IOException e) {
// 429, 5xx or a timeout: try again on the next pass
}
// queued or processing: back off (5, 10, 20, 40, then 60 s), then ask again
Thread.sleep(Math.min(60_000L, 5_000L << Math.min(attempt, 4)));
}
return null; // gave up: treat the address as unknown and re-check later
}
返された結果の行には、即時の結果と同じstatusとreasonのフィールドが含まれます。ポーリングのたびにAPIを1回呼び出すことになり、/verifyと同じワークスペース単位のレート制限に計上されます。そのため、一定の短い間隔でポーリングせず、試行の間隔を空けてください。保留中の結果を更新するために/verifyを再度呼び出さないでください。そうすると、この確認が完了するのではなく新しい確認が始まり、クレジットももう1つ消費されます。
タイムアウト、レート制限、フェイルオープン#
verify()は、2xx以外の応答ではすべて例外をスローします。これはバッチジョブには向いていますが、サインアップフォームには不向きです。フォームでは失敗の種類で分岐し、問題がAPI側にある場合は顧客を通し、後で再検証します。サーバーレスのサインアップ検証ガイドでも、同じフェイルオープンのパターンを使っています。
private final QualisendClient client = new QualisendClient();
private static final JsonNode UNKNOWN =
new ObjectMapper().createObjectNode().put("status", "unknown");
public JsonNode verifyForSignup(String email) throws InterruptedException {
try {
JsonNode body = client.verify(email);
JsonNode result = body.get("result");
if ("pending".equals(result.path("smtp").asText())) {
queueMailboxCheck(body.path("job_id").asText()); // a worker runs awaitMailboxVerdict
}
return result;
} catch (QualisendClient.ApiException e) {
if (e.status == 429) {
recheckLater(email, e.retryAfter); // rate limited: retry after Retry-After seconds
} else if (e.status == 402) {
alertOps("Qualisend: out of credits"); // insufficient_credits
recheckLater(email, null);
} else if (e.status >= 500) {
recheckLater(email, null); // an outage, not the customer's problem
} else {
alertOps("Qualisend rejected the request: " + e.status); // 400/401/403: fix the call or key
}
} catch (IOException e) {
recheckLater(email, null); // HttpTimeoutException or a dropped connection
}
return UNKNOWN;
}
queueMailboxCheck、recheckLater、alertOpsは、ジョブキューとアラートの仕組みの代わりとなるメソッドです。ApiExceptionは先にキャッチしてください。これはIOExceptionであり、HttpTimeoutExceptionも同様なので、最後のcatchでタイムアウトと接続の切断をまとめて処理できます。レート制限はキー単位ではなくワークスペース単位で適用されるため、キーを増やしても429は解消しません。
3つの層を組み合わせてJavaでメールアドレスを検証する#
コストの低い順に実行し、答えが出た時点で打ち切ります。判定は小さなrecordで保持します(recordはJava 16以降で、11~15では通常のクラスを使います)。
// mailboxPending: act on the local verdict now; the worker reports the mailbox result later.
public record Verdict(String status, String reason, boolean mailboxPending) {}
public Verdict validate(String email) throws InterruptedException {
if (!isValidSyntax(email)) {
return new Verdict("undeliverable", "invalid_email", false);
}
String domain = email.substring(email.lastIndexOf('@') + 1);
if (!hasMailRoute(domain)) {
return new Verdict("undeliverable", "invalid_domain", false);
}
JsonNode result = verifyForSignup(email); // fails open to "unknown"
return new Verdict(
result.path("status").asText("unknown"),
result.hasNonNull("reason") ? result.get("reason").asText() : null,
"pending".equals(result.path("smtp").asText()));
}
ローカルの2つの層はコストがかからず、不要なアドレスの大半をすぐに除外できます。APIが実行されるのは、ネットワークへの問い合わせに見合うアドレスだけです。この順序こそが要点です。このガイドのNode.js版、Python版、PHP版、Go版、C#版も同じ構造です。検証を成り立たせているのは、言語ではなく層構造だからです。
リスト全体を対象にする場合は、CSVに対して/verifyをループさせず、POST /verify/bulk(最大1,000,000行)に送るか、アプリにアップロードしてください。手順はリストクリーニングのガイドで解説しています。
よくある質問
01JavaのメールアドレスはInternetAddress.validate()だけで十分に検証できますか?
構文チェックとしては、1層目に適したツールです。jakarta.mail.internet.InternetAddressとvalidate()は自作の正規表現よりよくテストされており、RFC 822を自前で実装せずに済みます。ただし検証するのは形式だけです。DNSの解決もメールサーバーへの接続も行わず、jane@localhostのようなドットのないドメインも通すほど寛容です。また、表示名つきのJane <jane@example.com>の形式も解析するため、getAddress()が入力と等しいかどうかを確認してください。そのうえで、アドレスを信頼する前に、MX検索とSMTPによるメールボックスの確認を組み合わせます。
02Javaでメールアドレスを検証するには、どの正規表現を使えばよいですか?
短くて寛容なものを、static final Patternとして1回だけコンパイルし、matcher(email).matches()で確認します。matches()は文字列全体に一致する必要があります(find()は内部のどこかに一致すれば通ります)。このガイドのパターンは、@が1つあること、空白がないこと、ドットで区切られた部分が空でないこと、ドメインに少なくとも1つのドットがあることを条件にしています。{2,7}のようなTLDの長さ制限は付けないでください。.photographyのような実在のTLDはもっと長いためです。正規表現が確認するのは形だけで、ドメインやメールボックスが存在するかどうかは分かりません。限界については正規表現によるメール検証が失敗する理由で解説しています。
03外部ライブラリなしでJavaからMXレコードを確認するにはどうすればよいですか?
JDK標準のJNDI DNSプロバイダーを使います。java.naming.factory.initialにcom.sun.jndi.dns.DnsContextFactoryを設定したInitialDirContextを作成し、getAttributes(domain, new String[] {"MX"})を呼び出して、返されたMX属性を確認します。MXがない場合は、メールがドメイン自身のアドレスレコードにフォールバックすることがあるため、"A"と"AAAA"も(1回の呼び出しにつき1タイプずつ)問い合わせます。NameNotFoundExceptionはドメインが存在しないものとして扱いますが、タイムアウトやそれ以外のNamingExceptionでは拒否しないでください。これはリゾルバーの問題であり、アドレスが不正なわけではないからです。
04APIなしでJavaからメールボックスを検証できますか?
一部は可能です。Jakarta MailとJNDIを使えば、アドレスの形式が正しいことと、ドメインがメールを受け付けることを、無料で依存関係も少なく確認できます。メールボックスそのものの確認にはSMTPのやり取りが必要です。生のSocketで試すことはできますが、アプリケーションサーバーから実行すべきではありません。ポート25は広範にブロックされており、結果は送信元IPのレピュテーションに左右されるためです。この最後の層を担うのが検証サービスです。
05メール検証はサインアップ時とリストのクリーニング時のどちらで行うべきですか?
どちらも行いますが、深さが異なります。構文とMXの層はサインアップ時に同期で実行します。リクエストを止めても支障がない速さで、すぐにフィードバックを返せるためです。APIの即時の判定に基づく対応もその場で行い(undeliverableは拒否し、保留中のunknownは通します)、メールボックスの判定はバックグラウンドワーカーに任せます。より深いバッチ処理は、リストのクリーニング用に取っておきます。リアルタイムのパターンはサーバーレスのサインアップ検証ガイドで、バッチ側はリストクリーニングのガイドで解説しています。
SMTPの層を追加する準備はできましたか。まず、任意のアドレスを無料メールチェッカーに入力し、構文として完璧な文字列に対して実際の判定が返ってくる様子を確認してください。/verifyエンドポイント、resultエンベロープ全体、上記のすべての言語でそのままコピーして使えるサンプルは、APIリファレンスにあります。



