バウンス(宛先不明などで届かないメール)への対処というと、「送ったあとに返ってきたものを除外する」事後対応を思い浮かべがちです。しかし、そもそも無効なアドレスに送ること自体が送信者としての評価(レピュテーション)を下げ、到達率を悪化させます。だからこそ、送る前に弾く——事前検証(バリデーション) が効いてきます。
本記事では、メールアドレスの事前検証を「構文 → ドメイン/MX → 使い捨て・ロール → SMTP疎通」の4ステップに分け、Pythonで実装していきます。あわせて、やりすぎると逆効果になるポイントと、blastengineのバウンス対応との役割分担も整理します。
なぜ「送る前」に検証するのか
無効アドレスへの送信が増えると、バウンス率が上がります。受信側のメールプロバイダはバウンス率や苦情率を送信者の評価指標として見ているため、ここが悪化すると正当なメールまで迷惑メール判定されやすくなります。GmailやYahoo!の送信者ガイドラインでも、無効宛先への送信を減らすことが求められています。
無効アドレスが混入する最大の原因は、フォーム入力時のタイポ(gmail.con など)や、退会・異動による失効です。これらを送信前に検出できれば、バウンスの多くは未然に防げます。
事前検証は「予防」、バウンス処理は「事後対応」で、役割が分かれています。
| 段階 | 手段 | できること |
|---|---|---|
| 送信前 | 事前検証(本記事) | 明らかに無効なアドレスを弾く |
| 送信後 | バウンス処理・サプレッション | 実際に届かなかった宛先を除外 |
検証は次の4レイヤーで、上から順に「軽くて安全」→「重くて副作用あり」になります。
| レイヤー | 内容 | 確実性 | 副作用 |
|---|---|---|---|
| 1. 構文 | 形式が正しいか | 低 | なし |
| 2. ドメイン/MX | 受信できるドメインか | 中 | なし(DNS問い合わせのみ) |
| 3. 使い捨て・ロール | 捨てアド・代表アドレスか | 中 | 誤判定の余地 |
| 4. SMTP疎通 | 実際に受理されるか | 高 | ブロック・誤判定リスク大 |
バウンスには2種類ある
事前検証で何を防げるのかを理解するために、バウンスの種類を整理しておきます。
- ハードバウンス(恒久的エラー):宛先アドレスが存在しない、ドメインが無効など、何度送っても届かないもの。送信者評価を最も下げます
- ソフトバウンス(一時的エラー):受信箱が一杯、サーバーの一時障害など、時間をおけば届く可能性があるもの
事前検証で狙って減らせるのは、主にハードバウンスです。存在しないドメインや明らかなタイポは送信前に弾けます。一方、受信箱の容量超過のような一時的要因は送る前には分からないため、ソフトバウンスは送信後のリトライやサプレッションで対応します。つまり事前検証は「ハードバウンスの予防」、送信後のバウンス処理は「ソフトバウンスの吸収と恒久エラーの除外」という分担になります。
レイヤー1:構文チェック
まずは形式の確認です。注意したいのは、RFC 5321/5322に完全準拠した正規表現は現実的でないという点です。仕様上は "john doe"@example.com のような引用符付きアドレスも有効で、それらをすべて拾う正規表現は非現実的に長くなります。実務では「ほどほどに緩い正規表現」か、検証ライブラリを使うのが安全です。
import re
# 実務的な「緩め」の正規表現(@の前後に空白なし・ドメインにドットあり)
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def check_syntax_simple(email: str) -> bool:
return bool(EMAIL_RE.match(email))
より堅くやるなら、email-validator ライブラリが便利です。正規化(小文字化や国際化ドメインの変換)まで面倒を見てくれます。
# pip install email-validator
from email_validator import validate_email, EmailNotValidError
def check_syntax(email: str):
try:
# check_deliverability=False で構文チェックのみ(DNSは引かない)
result = validate_email(email, check_deliverability=False)
return result.normalized # 正規化後のアドレスが返る
except EmailNotValidError as e:
print(f"構文エラー: {e}")
return None
注目点は次のとおりです。
- 正規表現を厳しくしすぎると、
+付きエイリアス(user+tag@example.com)など正当なアドレスを弾いてしまいます。構文チェックは入口の足切りと割り切り、過度に厳格にしないのがコツです -
email-validatorは正規化後のアドレスを返すので、保存する値はこの正規化版に揃えると重複も防げます
正規化と重複排除
構文チェックと同じタイミングで、アドレスを正規化しておくと後段が安定します。email-validator が返す normalized を保存値に使うと、大文字小文字や国際化ドメイン(IDN)の表記ゆれを吸収できます。さらに、ドメイン部は大文字小文字を区別しないため、小文字化したうえで重複を排除しておくと、同じ宛先への二重送信を防げます。
def normalize(email: str):
result = check_syntax(email) # email-validatorで正規化
if result is None:
return None
local, _, domain = result.partition("@")
# ドメイン部だけ小文字化する(ローカル部は仕様上は大小区別があるため変更しない)
return f"{local}@{domain.lower()}"
def dedupe(emails: list) -> list:
seen, cleaned = set(), []
for e in emails:
n = normalize(e)
if n and n not in seen:
seen.add(n)
cleaned.append(n)
return cleaned
注目点は次のとおりです。
- 上のコードではドメイン部だけを小文字化し、ローカル部(
@の左)はそのまま残しています。ローカル部は仕様上は大文字小文字を区別する(多くのプロバイダは実際には区別しませんが)ため、一律に小文字化すると別アドレスへ変えてしまう恐れがあるからです。大小を区別しないと確証があるドメインに限って、必要ならローカル部も正規化します - 正規化を保存時に統一しておくと、検証結果のキャッシュや突合もしやすくなります
レイヤー2:ドメインとMXレコードの存在確認
構文が正しくても、ドメインが実在しなかったり、そもそもメールを受信する設定(MXレコード)が無ければ届きません。dnspython でMXレコードを引いて確認します。
# pip install dnspython
import dns.resolver
def has_mx_record(domain: str) -> bool:
try:
answers = dns.resolver.resolve(domain, "MX")
return len(answers) > 0
except dns.resolver.NXDOMAIN:
return False # ドメイン自体が存在しない
except dns.resolver.NoAnswer:
return False # MXレコードが無い
except (dns.resolver.NoNameservers, dns.exception.Timeout):
return False # 一時的な名前解決の失敗
注目点は次のとおりです。
- 厳密にはMXが無くてもAレコード(またはAAAA)を暗黙のMXとして配送する仕様(RFC 5321)があるため、「MXなし=即無効」とは言い切れません。MX必須を運用ポリシーにする場合は、このフォールバックを許容しない前提だと明示しておくと安全です。機械的に弾きたくなければ、MXが無いドメインはAレコードの有無も確認する、あるいは
rejectではなくreview(要確認)に回す、といった調整をします - DNS問い合わせはネットワーク越しなので、タイムアウト設定と、一時的失敗を「無効」と断定しすぎない設計が大切です(リトライ・キャッシュの検討)
レイヤー3:使い捨て・ロールアカウントの判定
次に、使い捨てアドレス(disposable) と ロールアカウント を判定します。使い捨てアドレスは登録直後に消えることが多く、ロールアカウント(info@・support@・no-reply@ など個人ではない代表アドレス)は到達しても読まれにくかったり、配信停止の扱いが難しかったりします。
# pip install disposable-email-domains
from disposable_email_domains import blocklist
ROLE_LOCAL_PARTS = {
"info", "support", "admin", "sales", "contact",
"no-reply", "noreply", "postmaster", "webmaster", "office",
}
def is_disposable(domain: str) -> bool:
return domain.lower() in blocklist
def is_role_account(local_part: str) -> bool:
return local_part.lower() in ROLE_LOCAL_PARTS
注目点は次のとおりです。
- 使い捨てドメインのリストは日々増えるため、
disposable-email-domainsのようなメンテされているリストを使い、定期的に更新します - ロールアカウントを弾くべきかどうかは業務次第です。BtoBの問い合わせ窓口なら
info@は正当な宛先のこともあります。一律ブロックではなく「フラグを立てて運用判断」に回すのが無難です
レイヤー4:SMTP疎通確認(RCPT TO)
最も踏み込んだ確認は、宛先ドメインのメールサーバーに接続し、RCPT TO コマンドで受理されるかを確かめる方法です。一見もっとも確実そうですが、実際には後述のとおり誤判定が多く、副作用も大きいため、本番のブロッキング判定には使えません。実装としては次のようになります。
import smtplib
import dns.resolver
def smtp_check(email: str, helo_host: str = "example.com",
mail_from: str = "verify@example.com") -> bool:
domain = email.split("@")[1]
# 優先度が最も高いMXホストを取得
try:
mx_records = dns.resolver.resolve(domain, "MX")
mx_host = str(sorted(mx_records, key=lambda r: r.preference)[0].exchange).rstrip(".")
except Exception:
return False
try:
server = smtplib.SMTP(timeout=10)
server.connect(mx_host, 25)
server.helo(helo_host)
server.mail(mail_from)
code, _ = server.rcpt(email) # 250/251 なら受理
server.quit()
return code in (250, 251)
except Exception:
return False
ここでの重大な注意点は次のとおりです。
-
catch-all設定のサーバーは、存在しないアドレスでも
250を返すため、有効と誤判定します -
グレーリストを採用しているサーバーは初回を一時拒否するため、有効でも
450などを返すことがあります - 検証目的の接続を繰り返すと、こちらのIPがブロックされる可能性があります
- このため、SMTP疎通は「参考スコア」に留め、本番の登録フォームで即時に弾く判定には使わないのが鉄則です
まとめて使う:検証パイプライン
レイヤー1〜3を順に通し、SMTP疎通は任意のスコアとして扱う関数にまとめます。前段で落ちたら後段は実行しない(早期リターン)ことで、無駄なDNS/SMTP問い合わせを減らせます。
def validate(email: str) -> dict:
result = {
"email": email, "valid_syntax": False, "has_mx": False,
"is_disposable": None, "is_role": None,
}
if "@" not in email:
return result
local, _, domain = email.partition("@")
# レイヤー1
if check_syntax(email) is None:
return result
result["valid_syntax"] = True
# レイヤー2
result["has_mx"] = has_mx_record(domain)
# レイヤー3
result["is_disposable"] = is_disposable(domain)
result["is_role"] = is_role_account(local)
return result
# 使い方
print(validate("user+tag@gmail.com"))
# {'email': 'user+tag@gmail.com', 'valid_syntax': True, 'has_mx': True,
# 'is_disposable': False, 'is_role': False}
使い分けの目安は次のとおりです。
- フォーム入力時(リアルタイム):レイヤー1〜2まで。軽くて副作用がなく、その場で「ドメインが見つかりません」と返せます
- 配信前のリスト浄化(バッチ):レイヤー1〜3をまとめて通し、disposableやMXなしを除外。大量に処理するときはDNS問い合わせを並列化しつつ、同一ドメインの結果はキャッシュします
大量リストをバッチで浄化する
配信前にリストをまとめて検証するときは、DNS問い合わせがボトルネックになります。同一ドメインの結果を使い回すキャッシュと、concurrent.futures での並列化を入れると、現実的な速度で処理できます。あわせて、判定結果を「送ってよい/要確認/送らない」の3分類にしておくと、運用しやすくなります。
import csv
from functools import lru_cache
from concurrent.futures import ThreadPoolExecutor
# 同一ドメインのDNS結果は使い回す
@lru_cache(maxsize=10000)
def has_mx_cached(domain: str) -> bool:
return has_mx_record(domain)
def classify(email: str) -> str:
if check_syntax(email) is None:
return "reject" # 構文NG → 送らない
local, _, domain = email.partition("@")
if not has_mx_cached(domain) or is_disposable(domain):
return "reject" # MXなし/使い捨て → 送らない
if is_role_account(local):
return "review" # 代表アドレス → 要確認
return "accept" # 送ってよい
def clean_list(in_path: str, out_path: str):
with open(in_path, encoding="utf-8") as f:
emails = [row[0].strip() for row in csv.reader(f) if row]
# DNS/構文チェックはI/O待ちが中心なのでスレッド並列が効く
with ThreadPoolExecutor(max_workers=20) as ex:
results = list(ex.map(lambda e: (e, classify(e)), emails))
with open(out_path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["email", "judgement"])
w.writerows(results)
accepted = sum(1 for _, j in results if j == "accept")
print(f"{len(results)}件中 accept={accepted}件")
注目点は次のとおりです。
-
lru_cacheで同一ドメインのDNS問い合わせを1回に抑えます。同じドメインのアドレスが多いリストほど効果が大きくなります - DNS問い合わせはネットワークの待ち時間が支配的なので、
ThreadPoolExecutorのスレッド並列で十分に速くなります(CPUバウンドではないためProcessPoolは不要) -
review(代表アドレスなど)は機械的に削除せず、人手の判断に回せるよう分けておきます
タイポ補正で取りこぼしを救う
無効と判定して終わりにせず、よくあるタイポを補正候補として提示すると、ユーザーの取りこぼしを減らせます。gmail.con → gmail.com のような誤りは、標準ライブラリの difflib で近いドメインを探すだけでも拾えます。
import difflib
COMMON_DOMAINS = ["gmail.com", "yahoo.co.jp", "outlook.com", "icloud.com", "hotmail.com", "docomo.ne.jp"]
def suggest_domain(domain: str):
matches = difflib.get_close_matches(domain, COMMON_DOMAINS, n=1, cutoff=0.8)
if matches and matches[0] != domain:
return matches[0]
return None
print(suggest_domain("gmail.con")) # -> 'gmail.com'
フォームで「gmail.com の間違いではありませんか?」と確認を出すだけで、入力ミス由来のバウンスはかなり減らせます。
バウンスとの役割分担とblastengine運用
事前検証は万能ではありません。catch-allや一時的な受信障害は送る前には分かりません。そこで、事前検証で減らし、それでも出るバウンスは事後に除外するという二段構えにします。
- 送信前:本記事の4ステップでリストを浄化し、明らかな無効アドレスを除外
- 送信後:blastengineが返すバウンス情報・レスポンスコードを見て、恒久的に届かない宛先をサプレッション(配信停止リスト)に登録
送信後のエラーの読み解き方はblastengineの配信レスポンスコード解読ガイド、宛先を一括登録する際の事故防止は宛先一括登録ジョブを"事故らせない"小技3つにまとめています。配信前にリストをきれいにしておくと、バウンス率が下がり、結果として到達率と送信者評価を守れます。
検証だけに頼らない:ダブルオプトイン
事前検証は無効アドレスを大きく減らせますが、「実在するが本人のものではないアドレス」(打ち間違いで他人のアドレスになっている、いたずら登録など)は構文・MX・SMTPのいずれもパスしてしまいます。これを根本的に防ぐのがダブルオプトインです。
ダブルオプトインは、登録時に確認メールを送り、本人がリンクをクリックして初めて「有効な宛先」として確定する仕組みです。
- 入力されたアドレスが実在し、かつ本人が受信できることを確証できる
- いたずら登録や他人のアドレスの混入を防げる
- 結果として、配信リストの質が長期的に保たれる
事前検証(送信前の足切り)とダブルオプトイン(本人確認)は役割が異なり、両方を組み合わせるのが理想です。確認メールそのものはトランザクションメールとして送るため、到達率が重要になります。
注意事項
- SMTP疎通は本番の即時判定に使わない:catch-all・グレーリスト・IPブロックのリスクがあるため、参考スコア止まりにします
-
構文チェックを厳しくしすぎない:
+エイリアスや国際化ドメインなど、正当なアドレスを弾かないよう緩めに - DNS/SMTPはネットワーク依存:タイムアウト・リトライ・並列数・キャッシュを設計に入れます
- プライバシーへの配慮:疎通確認のために実際にメールを送って確かめる、といった方法は相手に迷惑がかかるため避けます
まとめ
- バウンスは「送ったあと」だけでなく「送る前」に減らせます。無効アドレスへの送信は送信者評価を下げ、到達率に跳ね返ります
- 検証は構文 → MX → 使い捨て・ロール → SMTP疎通の4レイヤー。上から順に軽くて安全、下ほど確実だが副作用が大きくなります
- フォームではレイヤー1〜2、配信前のバッチではレイヤー1〜3が実用的。SMTP疎通は参考スコアに留めます
- タイポ補正でユーザーの入力ミスを救い、送信後はblastengineのバウンス情報でサプレッションを更新する二段構えが効果的です
まずは構文とMX確認だけでも導入すると、フォーム由来の無効アドレスをその場で大きく減らせます。