Goでメールアドレスを検証する作業は、実は3つの仕事が1つの名前で呼ばれているだけです。正規表現を示して終わるチュートリアルが解決しているのは、そのうち1つにすぎず、しかも最も役に立たないものです。実際の検証は、低コストの構文チェック、DNSルックアップ、SMTPによるメールボックスの確認という、コストの低い順の層構造で行います。Goの標準ライブラリなら、最初の2つはnet/mailとnetだけで、依存パッケージなしにすっきり実装できます。3つ目はネットワークの問題であり、外部に任せる価値があります。このガイドでは、各層を実際に動くGoらしいコードで組み立て、検証APIにどこから任せるかを具体的に示します。
結論#
構文にはnet/mail.ParseAddress、MXルックアップにはnet.LookupMX、SMTPによるメールボックスの確認には検証APIを使います。この順序で、コストの低いものから実行し、どれかの層で結論が出た時点で処理を打ち切ります。メールボックスのプローブのために、自社サーバーからnet/smtpを使うのはやめてください。アウトバウンドのポート25はほとんどのホストでブロックされています。ブロックされていない環境でも、結果は送信元IPのレピュテーションやグレーリスティングの挙動に左右されるため、自前で再現するのは得策ではありません。各層は、前の層より低いコストでアドレスを除外できますが、アドレスを有効と確定できるのは最後の層だけです。
レイヤー1:net/mailによる構文チェック#
ここで手書きの正規表現は必要ありません。標準ライブラリのnet/mailパッケージはRFC 5322のアドレスを解析し、ParseAddressは不正な入力に対してnilではないエラーを返します。これは構文チェックの関門に必要なyes/noの判定そのものです。
package main
import "net/mail"
func isValidSyntax(email string) bool {
if len(email) > 320 {
return false
}
addr, err := mail.ParseAddress(email)
return err == nil && addr.Address == email
}
addr.Address == emailの比較は、多くのコード例が見落とす部分です。ParseAddressはメールボックス行全体を読み取る設計のため、表示名もそのまま受け入れます。mail.ParseAddress("Jane Doe <jane@example.com>")はエラーを返さず、addr.Addressにはjane@example.comが入ります。サインアップのフィールドで必要なのは、素のアドレスだけです。そこで、解析したAddressを入力と照合し、表示名、山括弧、末尾の空白が付いたものは拒否します。
isValidSyntax("jane@example.com") // true
isValidSyntax("Jane Doe <jane@example.com>") // false — not a bare address
isValidSyntax("not-an-email") // false — ParseAddress errors
isValidSyntax("a@@b.com") // false
ここで成功しても、意味は「確認する価値がある」であり、「有効」ではありません。文字列がアドレスの形をしていることが分かるだけで、メールが届くかどうかは何も分かりません。正規表現によるメールチェックが突き当たるのと同じ壁があり、net/mailもその壁の手前にあります。これはパーサーであって、到達可否を判定するものではありません。以降の層はすべて、構文が正常であることを前提にしています。
レイヤー2:ドメインはメールを受信できるか#
ここでは標準ライブラリが、依存パッケージなしで力を発揮します。MXレコードを公開していないドメインには、メールの届け先がありません。そのため、ルックアップを1回行うだけで、存在しないドメイン、社名のスペルミス、架空のTLDを除外できます。net.LookupMXでMXレコードを解決します。
package main
import "net"
func hasMailRoute(domain string) bool {
mxs, err := net.LookupMX(domain)
if err != nil {
return false
}
return len(mxs) > 0
}
hasMailRoute("gmail.com") // true
hasMailRoute("company-that-folded.com") // false
LookupMXは、優先度順に並んだ*net.MXのスライスを返します。ドメインが解決できない場合は*net.DNSErrorを返します。バリデーターとしては、エラーや空のスライスを「メール経路なし」と扱うのが安全な解釈です。明示的なMXがなくAレコードでメールを受け付けるドメイン(いわゆる暗黙のMX)も有効としたい場合は、MXのスライスが空だったときにnet.LookupHost(domain)にフォールバックします。ただし、実在するアドレスの大多数については、MXチェックが適切なフィルターです。
運用上の注意点があります。LookupMXはDNSリゾルバーの応答を待ってブロックするため、ネームサーバーが遅い、または到達できない場合はゴルーチンが停止します。リクエストの処理経路では、コンテキストに対応したリゾルバー(&net.Resolver{}).LookupMX(ctx, domain)をデッドライン付きで使ってください。タイムアウトは即時の拒否ではなく「後で再確認」として扱います。DNSの一時的な不調で、実在する顧客を締め出さないためです。
レイヤー3:メールボックスは実在するか#
レイヤー1と2でできるのは、アドレスを除外することだけです。ドメインに正しいMXレコードがあっても、手元のアドレスにメールボックスがあるとは限りません。noreply-9f2x@gmail.comは構文が正しく、メール経路も有効ですが、実在するメールボックスだったことはありません。特定のメールボックスを確認するには、SMTPの配信時のやり取りが必要です。メールホストに接続し、RCPT TOを発行して応答を読み取り、何も送信せずに切断します。
Goにはそのための道具が揃っています。net/smtpにはsmtp.Dialと、Mail、Rcpt、Closeの各メソッドを持つClientがあり、原理的にはこのハンドシェイクを自分で実行できます。しかし実際には、アプリケーションサーバーから実行すべきではありません。ほとんどのクラウドプロバイダーはアウトバウンドのポート25を完全にブロックしているため、手元のノートPCでは動いても、本番ではダイヤルがタイムアウトするだけです。ポートが開いている環境でも、結果は接続元IPのレピュテーションに左右されます。さらに、受信サーバーは見慣れない送信者に対してグレーリスティングやレート制限を行うため、単純なプローブは保留されたり、スロットリングされたり、ブラックリストに載ったりします。1回のやり取りでは済まない事情もあります。キャッチオールドメインはすべてのアドレスを受け付けるため、1回のRCPT TOでは見分けられません。全体のパイプラインについては、メールアドレス検証の仕組みで解説しています。ここは外部に任せる価値のある層です。
APIで完全なチェックを行う#
QualisendのリアルタイムAPIは、構文、DNS、SMTPによるメールボックスの確認というパイプライン全体を、そのために構築されたレピュテーション管理済みのインフラから実行し、判定結果を返します。単純なJSONのPOSTなので、標準ライブラリのnet/httpとencoding/jsonだけで足ります。resultのエンベロープは、構造体へそのままデコードします。
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
)
type VerifyResult struct {
Status string `json:"status"` // deliverable | risky | undeliverable | unknown
Score int `json:"score"`
Reason string `json:"reason"`
SubFlags map[string]bool `json:"sub_flags"`
SMTP string `json:"smtp"` // pending | cached | skipped
JobID string `json:"-"` // from the top-level job_id
}
type verifyResponse struct {
JobID string `json:"job_id"`
Result VerifyResult `json:"result"`
}
func verify(ctx context.Context, email string) (VerifyResult, error) {
body, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return VerifyResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://app.qualisend.com/api/v1/verify", bytes.NewReader(body))
if err != nil {
return VerifyResult{}, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("QUALISEND_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return VerifyResult{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return VerifyResult{}, fmt.Errorf("qualisend: unexpected status %s", res.Status)
}
var payload verifyResponse
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return VerifyResult{}, fmt.Errorf("qualisend: decode response: %w", err)
}
payload.Result.JobID = payload.JobID
return payload.Result, nil
}
スコープを限定したキーは、ハードコードせず環境変数QUALISEND_API_KEYから読み込みます。リクエストとレスポンスの正確な形式は、開発者ページのAPIリファレンスで確認してください。resultオブジェクトには、deliverable、risky、undeliverable、unknownのいずれかであるstatus、数値のscore、reason、そしてsub_flagsのマップ(役割アドレス、使い捨て、フリーメールのプロバイダー、キャッチオールなど)が含まれます。実際に分岐に使うのは、これらのフィールドです。
レスポンスはメールボックスのプローブの完了を待たないため、SMTPを見れば、どちらの種類の回答かが分かります。cachedまたはskippedなら、結果はすでに確定しています。pendingは、statusがローカルチェックの結果を反映したもので、reasonはまだ空であり、SMTPの判定はジョブに届くことを意味します。取得先はGET https://app.qualisend.com/api/v1/jobs/{job_id}?include=resultsで、IDはJobIDに入っています。ジョブがcompleted、canceled、failedのいずれかになるまで、バックグラウンドのワーカーから数秒おきにポーリングしてください。サインアップのリクエストの中では行わず、同じアドレスを更新するために/verifyを再度呼び出すのもやめてください。
verifyはcontext.Contextを受け取るため、タイムアウトとキャンセルは呼び出し側が管理します。検証が遅れてもサインアップのリクエストが止まらないよう、デッドラインを渡します。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := verify(ctx, "jane@example.com")
if err != nil {
log.Printf("verify failed: %v", err)
}
層を組み合わせてGoでメールアドレスを検証する#
コストの低いものから実行し、答えが出た時点で止めます。ローカルの2つの層はコストがかからず、不正なアドレスの大半を即座に弾きます。APIの呼び出しは、それらを通過し、ネットワークの往復に見合うアドレスにだけ実行されます。
package main
import (
"context"
"strings"
)
func validateEmail(ctx context.Context, email string) (VerifyResult, error) {
if !isValidSyntax(email) {
return VerifyResult{Status: "undeliverable", Reason: "invalid_email"}, nil
}
domain := email[strings.LastIndex(email, "@")+1:]
if !hasMailRoute(domain) {
return VerifyResult{Status: "undeliverable", Reason: "invalid_domain"}, nil
}
return verify(ctx, email)
}
あとは、プロダクトの要件に合わせてstatusで分岐します。配信不能は拒否し、配信可能は通し、曖昧な判定についてはどう扱うかを機能ごとに決めます。
result, err := validateEmail(ctx, "jane@example.com")
if err != nil {
// network/API failure — fail open or retry, don't block a real user
}
switch result.Status {
case "deliverable":
// accept
case "undeliverable":
// reject at the form
default:
// risky | unknown — flag for review, or allow with a soft warning
}
この順序こそが肝心な点です。このガイドのNode.js版やPython版でも、同じ構成になっています。検証を確実にするのは、言語ではなく層構造です。各段階がその位置にある理由は、メールアドレス検証の仕組みを参照してください。
よくある質問
01net/mail.ParseAddressだけでGoのメールアドレス検証は足りますか?
構文チェックとしては十分です。net/mail.ParseAddress はレイヤー1のチェックとして適切で、RFC 5322に沿って解析し、不正な入力にはエラーを返すため、手書きの正規表現よりはるかに確実です。ただし、検証するのは到達可否ではなく形式です。DNSの解決もメールサーバーへの接続も行わないため、エラーがnilであることは「アドレスの形をしている」という意味であり、「配信できる」という意味ではありません。表示名を拒否するには addr.Address を入力と照合し、そのうえでMXルックアップとSMTPによるメールボックスのプローブを組み合わせてください。
02GoでMXレコードを調べるにはどうすればよいですか?
標準ライブラリの net.LookupMX(domain) を使います。優先度順に並んだ *net.MX レコードのスライスを返し、ドメインが解決できない場合は *net.DNSError を返します。エラーや空のスライスは「メール経路なし」として扱ってください。リクエストの処理経路では、コンテキストに対応した (*net.Resolver).LookupMX(ctx, domain) を使うと、ネームサーバーが遅くてもゴルーチンがデッドラインを超えて停止することを防げます。
03外部サービスなしで、Goでメールボックスを検証できますか?
一部は可能です。net.LookupMX なら、標準ライブラリだけでドメインがメールを受け付けることを確認でき、存在しないドメインを無料で除外できます。メールボックス自体の確認にはSMTPのやり取りが必要です。net/smtp でスクリプト化はできますが、アプリケーションサーバーからは実行しないでください。ポート25は広くブロックされており、結果は送信元IPのレピュテーションに左右されます。この層を担うために存在するのが検証サービスです。違いは、無料メールチェッカーでどのアドレスでも試せます。
04メールアドレスの検証は、サインアップ時とリストのクリーニング時のどちらで行うべきですか?
両方で行いますが、深さが異なります。構文チェックとMXルックアップは、サインアップ時に同期的に実行します。リクエストをブロックしても問題ないほど高速で、その場でフィードバックを返せます。APIから即座に返る判定結果もそこで使います。SMTPで確認する、より深い処理は、リストのクリーニングや管理業務のジョブに回してください。リアルタイムのパターンは、サーバーレスのサインアップガイドで最初から最後まで確認できます。
GoのサービスにSMTPの層を追加する準備はできましたか。QualisendのAPIリファレンスには、スコープ付きキーとコピー&ペーストで使えるコード例を備えた/verifyエンドポイントがあります。コードを書く前に、無料メールチェッカーで、構文が完璧なアドレスがundeliverableと判定される様子も確認できます。



