ASP.NET Coreでメールアドレスを検証するとき、ほとんどのアプリは組み込みの[EmailAddress]データ注釈を使い、モデルバインディングに実行させ、ModelState.IsValidを確認します。これは確かな第一歩です。この属性はフレームワークに付属しており、自前の正規表現なしで不正な値を弾けます。ただし、実際の検証で問うべき3つの質問のうち、答えられるのは最初の1つだけです。アドレスの形式は正しいか。ドメインはメールを受信できるか。メールボックスは実在するか。ASP.NET Coreは最初の質問にはそのまま答え、2番目には有名なNuGetパッケージが1つ必要で、3番目はネットワークの問題なので外部に任せる価値があります。このガイドでは、フレームワーク標準の検証パイプラインに組み込む3層のチェックを構築し、[EmailAddress]がどこまでしか役に立たないのかを具体的に示します。リクエストパイプラインの外側で同じ基本要素を扱うC#メールアドレス検証ガイドの、Webフレームワーク版にあたります。
結論#
構文には[EmailAddress](またはFluentValidationの.EmailAddress())、MXルックアップにはDnsClient.NET、SMTPによるメールボックスの確認には検証APIを使います。コストの低い順に実行し、結果が確定した時点で処理を打ち切ります。DNSとAPIの層は非同期I/Oのため、同期的なValidationAttributeには収まりません。これらを置く場所として定石なのは、DIでサービスを注入したFluentValidationのバリデーターです。メールボックスを調べるために、アプリから生のSMTPソケットを開くのはやめてください。送信用のポート25はほとんどのホストでブロックされており、結果は送信元IPのレピュテーションとグレーリスティングに左右されます。これらを自前で再実装するべきではありません。各層は、次の層よりも低コストでアドレスを除外しますが、アドレスを有効と判断できるのは最後の層だけです。パイプラインに不慣れな場合は、先にメールアドレス検証とはで用語を確認してください。
第1層:DataAnnotationsによる形式の検証#
組み込みの第1層のチェックは、System.ComponentModel.DataAnnotationsの[EmailAddress]属性です。リクエストモデルの[Required]の隣に付けると、モデルバインディングのたびに実行されます。
using System.ComponentModel.DataAnnotations;
public class SignupRequest
{
[Required]
[EmailAddress]
public string Email { get; init; } = string.Empty;
}
[ApiController]が付いたMVCまたはAPIコントローラーでは、属性による検証に失敗すると、アクション本体が実行される前に400のValidationProblemDetailsレスポンスが返ります。ModelStateを手作業で調べる必要はありません。
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("signup")]
public class SignupController : ControllerBase
{
[HttpPost]
public IActionResult Register(SignupRequest request)
{
// With [ApiController], an invalid [EmailAddress] already returned 400.
// Reaching here means the string is well-formed.
return Ok();
}
}
この検証で得られるものを正確に把握しておきましょう。EmailAddressAttributeが確認するのは形式だけです。実際には、@が1つあり、その前後に何かがあることしか求めません。DNSの解決もソケットの接続も行わず、ドメインが存在するかどうかもわかりません。definitely-fake@gmail.comは通ります。info@company-that-folded.comも通ります。typo@gmial.comも通ります。3つとも配信不能であり、文字列を読むだけの属性では、それを知るすべがありません。正規表現によるメール検証が失敗するのと同じ理由です。形式と到達性は別の問題であり、前者は文字列に関する事実、後者はインターネットに関する事実だからです。
次の2層を属性にできない理由#
このガイドの残りを方向づける、重要な注意点があります。DataAnnotationsの検証は同期処理です。ValidationAttribute.IsValidが返すのはTaskではなくValidationResultなので、属性の中でDNSクエリやHTTP呼び出しをawaitする自然な方法がありません。非同期呼び出しを同期の属性に押し込むために.Resultや.GetAwaiter().GetResult()でブロックすると、負荷時にスレッドプールの枯渇やデッドロックを招きます。登録処理で最も避けたい事態です。
これが、非同期の層に対するASP.NET Coreの定石がFluentValidationである理由です。MustAsyncルールはTaskを返すバリデーターとして標準で用意されており、アプリの他の部分と同じDIコンテナーで依存関係を解決します。そのため、バリデーターのコンストラクターでHttpClientやDNSサービスを受け取れます。第1層には[EmailAddress]を残し、第2層と第3層にFluentValidationを追加する方法もあります。あるいは、以下のようにFluentValidationの.EmailAddress()で構文もカバーし、3層すべてを1か所にまとめる方法もあります。
第2層:ドメインはメールを受信できるか#
メール経路のないドメインは、誰宛てのメールも受け付けられません。そのため、ルックアップ1回で、存在しないドメイン、会社名のスペルミス、架空のTLDを除外できます。注意点があります。ASP.NET Coreには組み込みのMXリゾルバーがありません。System.Net.DnsはAレコードとAAAAレコードを解決しますが、MXレコードは解決しません。そのため、標準的な方法は、広く使われているNuGetパッケージのDnsClient.NETです。小さなインターフェースで包み、バリデーターがライブラリではなく抽象に依存するようにします。
using DnsClient; // dotnet add package DnsClient
using System.Linq;
public interface IMailRouteChecker
{
Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default);
}
public sealed class DnsMailRouteChecker : IMailRouteChecker
{
private readonly ILookupClient _dns;
public DnsMailRouteChecker(ILookupClient dns) => _dns = dns;
public async Task<bool> HasMailRouteAsync(string email, CancellationToken ct = default)
{
var domain = email[(email.LastIndexOf('@') + 1)..];
try
{
var response = await _dns.QueryAsync(domain, QueryType.MX, cancellationToken: ct);
return response.Answers.MxRecords().Any();
}
catch (DnsResponseException)
{
// No reachable resolver, SERVFAIL, and similar — treat as "check later".
return false;
}
}
}
ドメインは、範囲式を使って最後の@で切り出します。正当な形で@を複数含むアドレスを誤って解析しないようにするためです。LookupClientはシングルトンとして1つだけ登録してください。スレッドセーフで、レスポンスをキャッシュするため、リクエストごとにインスタンスを作ると、そのキャッシュを捨ててしまいます。MXレコードがなく、Aレコードでメールを受け付けるドメインもあります。そのケースにも対応したい場合は、MXの結果が空のときにQueryType.Aにフォールバックしてください。
第3層:メールボックスは実在するか#
第1層と第2層でできるのは、アドレスを除外することだけです。ドメインが完全なMXレコードを公開していても、手元のアドレスにメールボックスが存在しないことはあります。noreply-9f2x@gmail.comは、有効なメール経路上の有効な構文ですが、作成されたことのないメールボックスです。特定のメールボックスを確認するには、SMTPの配信のやり取りが必要です。メールホストに接続し、RCPT TOを発行して応答を読み、何も送信しないうちに切断します。キャッチオールドメインを含むパイプライン全体は、メールアドレス検証の仕組みで説明しています。
原理上は、C#でTcpClientと生のSMTPコマンドを使って自作できます。しかし実際には、アプリサーバーから実行すべきではありません。ほとんどのクラウドプロバイダーは送信用のポート25をブロックしており、結果は接続元のIPのレピュテーションに左右されます。しかも、受信サーバーは見慣れない送信者をグレーリスティングしたりレート制限したりします。この層は、そのために作られたインフラに任せる価値があります。
Qualisendの検証エンドポイントは、レピュテーションを管理したインフラでパイプライン全体を実行し、リアルタイムで結果を返します。型付きHttpClientとしてモデル化すると、ベースURL、タイムアウト、キーを1か所にまとめられます。レスポンスのエンベロープはSystem.Text.Jsonでデシリアライズします。
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public interface IEmailVerifier
{
Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default);
}
public sealed class QualisendVerifier : IEmailVerifier
{
private readonly HttpClient _http;
public QualisendVerifier(HttpClient http) => _http = http;
public async Task<bool> IsDeliverableAsync(string email, CancellationToken ct = default)
{
try
{
using var response = await _http.PostAsJsonAsync("verify", new { email }, ct);
// On our own outage or a rate limit, don't block a real signup.
if (!response.IsSuccessStatusCode)
return true;
var body = await response.Content.ReadFromJsonAsync<VerifyResponse>(ct);
return body?.Result.Status != "undeliverable";
}
catch (HttpRequestException)
{
return true; // network failure: fail open too
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return true; // the 10-second client timeout fired: fail open too
}
}
}
public record VerifyResponse(
[property: JsonPropertyName("result")] VerifyResult Result);
public record VerifyResult(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("score")] int Score,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("sub_flags")] IReadOnlyDictionary<string, bool> SubFlags);
レスポンスはresultエンベロープに入って返ります。
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"result": {
"status": "unknown",
"score": 61,
"reason": null,
"smtp": "pending",
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
}
}
statusは総合的な判定で、deliverable、risky、undeliverable、unknownのいずれかです。上のヘルパーはundeliverableだけを失敗として扱い、それ以外は通しますが、score、reason、sub_flags(role、disposable、free_mail、catch_all)も返されます。これらを使って独自のポリシーを適用できます。たとえば、riskyは保留して確認する、disposableは登録時にフラグを立てる、といった運用です。エンドポイントはPOST https://app.qualisend.com/api/v1/verifyです。スコープ付きキーの形式とすべてのフィールドは、/developersのAPIリファレンスで確認してください。上のサンプルからフィールドを推測せず、ドキュメントで確かめてください。
この最初のレスポンスは、即時の判定です。result.smtpが"pending"の間は、SMTPによるメールボックスのプローブがまだ実行中で、reasonはnullのままです。最終的な判定はGET https://app.qualisend.com/api/v1/jobs/{job_id}で取得できます。登録時には即時の判定に基づいて処理し、最終的な判定が必要な場合は、バックグラウンドタスクからそのジョブをポーリングしてください。リクエスト内でポーリングしたり、/verifyを再度呼び出したりしてはいけません。
3つの層を組み合わせてASP.NET Coreでメールアドレスを検証する#
3つの層すべてを、FluentValidationのバリデーターにまとめます。ルールはコストの低い順に並べ、CascadeMode.Stopを設定して、最初に失敗した層で打ち切ります。非同期のDNSルールとAPIルールは、構文を通過したアドレスに対してのみ実行されます。
using FluentValidation;
public class SignupRequestValidator : AbstractValidator<SignupRequest>
{
public SignupRequestValidator(IMailRouteChecker dns, IEmailVerifier verifier)
{
RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop) // bail at the first failing layer
.NotEmpty()
.EmailAddress() // layer 1: syntax
.MustAsync((email, ct) => dns.HasMailRouteAsync(email, ct))
.WithMessage("Enter an email on a domain that can receive mail.")
.MustAsync((email, ct) => verifier.IsDeliverableAsync(email, ct))
.WithMessage("We couldn't confirm a mailbox at this address.");
}
}
サービスはProgram.csで登録します。LookupClientはシングルトン、検証用クラスは設定からキーを読み取る型付きクライアント(本番では環境変数やユーザーシークレットを使い、ハードコードしない)、バリデーターはアセンブリのスキャンで登録します。
using DnsClient;
using FluentValidation;
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ILookupClient>(new LookupClient());
builder.Services.AddScoped<IMailRouteChecker, DnsMailRouteChecker>();
builder.Services.AddHttpClient<IEmailVerifier, QualisendVerifier>(client =>
{
client.BaseAddress = new Uri("https://app.qualisend.com/api/v1/");
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", builder.Configuration["Qualisend:ApiKey"]);
});
builder.Services.AddValidatorsFromAssemblyContaining<SignupRequestValidator>();
var app = builder.Build();
app.MapPost("/signup", async (
SignupRequest request,
IValidator<SignupRequest> validator,
CancellationToken ct) =>
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
// Every layer passed — safe to persist.
return Results.Ok();
});
app.Run();
エンドポイントでIValidator<SignupRequest>を注入してValidateAsyncを呼び出すのが、現在推奨されているパターンです。非同期ルールが正しく実行されます。従来のMVC自動統合では実行されませんでした。同じバリデーターは、MVCコントローラーにもそのまま組み込めます。コンストラクターで解決し、データベースに触れる前にValidateAsyncを呼び出してください。
この順序がすべての要です。構文のルールが不正な値をコストなしで弾き、ローカルのDNSチェックが存在しないドメインをコストなしで除外し、APIは両方を通過したアドレスにのみ呼び出されます。これはNode.jsとC#のガイドと同じ3層の構造です。検証を信頼できるものにするのはフレームワークではなく、層の組み立てです。この高速なパイプラインは登録時に同期的に実行し、より深いバッチ検証は、遅延が問題にならず徹底さが重視される既存リストのクリーニングに回してください。
よくある質問
01ASP.NET Coreでのメールアドレス検証は、EmailAddress属性だけで十分ですか?
構文については十分です。第1層のチェックとして適切で、自前の正規表現より確実です。ただし、EmailAddressAttributeが検証するのは形式(実際には、前後にテキストがある@が1つあるかどうか)だけで、DNSの解決もメールサーバーへの接続も行いません。そのため、ModelState.IsValidを通過しても、「メールアドレスらしい」という意味であり、「配信できる」という意味ではありません。アドレスを信頼する前に、MXルックアップとSMTPによるメールボックスの確認を組み合わせてください。
02ASP.NET Coreで非同期のメール検証を実行するにはどうすればよいですか?
DataAnnotationsの属性は同期処理のため、DNSクエリやAPI呼び出しを入れるべきではありません。非同期呼び出しをブロックすると、デッドロックを招くおそれがあります。代わりにFluentValidationを使い、MustAsyncルールを書き、DNSサービスと検証サービスをバリデーターに注入して、エンドポイントやコントローラーからawait validator.ValidateAsync(request)を呼び出します。従来のMVC自動統合は非同期バリデーターを実行しなかったため、これが現在推奨されている方法です。
03ASP.NET CoreにはMXレコードを調べる組み込みの方法はありますか?
ありません。System.Net.DnsはホストのAレコードとAAAAレコードを解決しますが、MXには対応していません。そのため、標準的な方法はNuGetパッケージのDnsClient.NETです。QueryType.MXでクエリし、LookupClientを1つだけシングルトンとして登録します。結果が空の場合やDnsResponseExceptionが発生した場合は、例外を投げずに「メール経路なし」として扱います。
04メールアドレスの検証は、登録時とリストのクリーニング時のどちらで行うべきですか?
両方で、深さを変えて行います。構文とMXルックアップは、リクエストをブロックしても問題ないほど高速でその場でフィードバックを返せるため、登録時に同期的に実行します。APIの即時の判定に基づく処理も、ここで行います。エンドツーエンドのパターンは、サーバーレスの登録時検証ガイドで紹介しています。より深いバッチ検証は、遅延が問題にならない既存リストのクリーニングに回してください。
SMTPの層を追加しますか?APIリファレンスに、/verifyの正確な形式、スコープ付きキー、コピーして使える例があります。あるいは、無料メールチェッカーに1つアドレスを入力して、[EmailAddress]を通過した文字列がundeliverableと判定される様子を確かめてください。



