如何用 Python 驗證電子郵件地址
用 Python 驗證電子郵件地址,簡單的部分很直接,但最關鍵的部分卻出乎意料地困難。本指南會帶您完成可在本機執行的語法檢查與 DNS 查詢,詳細說明為什麼原始 SMTP 探測在實務上行不通,並示範如何透過 REST API 取得信箱層級的結果。
用 Python 拒絕格式錯誤的輸入,並可選擇快取 DNS/MX 檢查結果。信箱層級的結果則交給驗證 API,因為原始的smtplib探測很容易被限流,而且經常回傳模稜兩可的答案。
「驗證」電子郵件究竟是什麼意思
電子郵件驗證是在寄送任何內容之前,確認某個地址真實存在且能收信的過程。它可以拆解為一連串檢查,一關比一關難:地址格式是否正確、其網域是否能收信,以及該信箱是否確實存在。在 Python 中,前兩項很容易做好。第三項則是多數自行開發方案悄悄失敗的地方,而了解原因正是本文最有用的部分。
以下方案採分層設計。先在本機執行便宜又快速的檢查,剔除明顯有問題的輸入,再呼叫驗證 API 取得信箱層級的答案,判斷該地址能否使用。這樣不必呼叫 API 就能立即拒絕垃圾輸入,而通過檢查的地址也能得到更明確的結果。
步驟 1:語法驗證
第一項檢查是確認地址格式正確。很多人會想為此寫一個正規表示式,但電子郵件語法遠比看起來複雜,自行撰寫的正規表示式幾乎總是過於嚴格或過於寬鬆。這項工作的參考函式庫是email-validator,它正確實作了相關標準,並廣泛用於正式環境。
使用 pip 安裝:
pip install email-validator
接著驗證地址。對於任何格式錯誤的輸入,此函式庫會拋出EmailNotValidError,對於有效的輸入則回傳一個正規化後的物件:
from email_validator import validate_email, EmailNotValidError
def check_syntax(address):
try:
result = validate_email(address, check_deliverability=False)
return True, result.normalized
except EmailNotValidError as error:
return False, str(error)
ok, value = check_syntax("jane.doe@example.com")
print(ok, value) # True jane.doe@example.com
ok, value = check_syntax("jane.doe@@example")
print(ok, value) # False, with a description of the problem
設定check_deliverability=False能讓這個步驟維持純語法檢查、不發出任何網路請求,因此速度快到足以在每次按鍵或表單送出時執行。請務必儲存函式庫回傳的normalized值,而不是使用者原始輸入,因為它會將地址正規化,以便一致地儲存。
步驟 2:網域與 MX 檢查
格式正確的地址,若其網域無法收信,仍然毫無用處。下一項檢查會查詢該網域的 DNS 記錄並尋找 MX 記錄,MX 記錄會指出負責該網域的郵件伺服器。沒有 MX 記錄的網域無法收信,因此該網域上的任何地址都可以立即拒絕。
而email-validator函式庫可以替您完成這件事,只要傳入check_deliverability=True,它就會在驗證過程中一併執行 MX 查詢:
from email_validator import validate_email, EmailNotValidError
def check_domain(address):
try:
result = validate_email(address, check_deliverability=True)
return True, result.domain
except EmailNotValidError as error:
return False, str(error)
如果您想自行執行 DNS 查詢,例如為同一網域上的大量地址快取結果,可以直接使用dnspython函式庫:
pip install dnspython
import dns.exception
import dns.resolver
def has_mx_record(domain):
try:
records = dns.resolver.resolve(domain, "MX")
return len(records) > 0
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
return False
except dns.exception.DNSException:
return False # Treat resolver errors as inconclusive.
print(has_mx_record("gmail.com")) # True
print(has_mx_record("no-such-domain-xyz.invalid")) # False
這確實很有用。它能剔除網域已到期、從未存在或根本不處理郵件的地址。但請注意它仍然沒有告訴您的事:someone@gmail.com是否為真實存在的信箱。Gmail 有 MX 記錄,所以每個 Gmail 地址不論有效與否,都會通過這項檢查。
步驟 3:信箱檢查,以及原始 SMTP 為何力有未逮
要確認信箱本身,就必須與收件郵件伺服器對話。原則上,您透過 SMTP 連線、表明身分、以目標地址發出RCPT TO指令,再讀取回應碼:正面代碼表示信箱可能存在,拒絕則表示可能不存在。Python 內建的smtplib可以做到這件事,程式碼看起來簡單得令人誤以為容易:
import smtplib
import dns.resolver
def naive_smtp_check(address):
domain = address.split("@")[1]
mx_host = str(dns.resolver.resolve(domain, "MX")[0].exchange)
server = smtplib.SMTP(timeout=10)
server.connect(mx_host)
server.helo("example.com")
server.mail("probe@example.com")
code, message = server.rcpt(address)
server.quit()
# 250 and 251 generally mean the mailbox was accepted.
return code in (250, 251)
這在教學中可行,在正式環境中卻會失敗。原因值得詳細了解,因為它們解釋了為什麼沒有任何認真的驗證工具會依賴這樣的腳本。
IP 信譽與限流
信箱服務商會主動偵測驗證探測。只要從同一台伺服器執行幾次RCPT TO檢查,Gmail 和 Outlook 等服務商在幾次嘗試內就會限流或封鎖該 IP。一旦被限流,不論信箱是否存在,伺服器都會回傳模稜兩可的回應,您的結果也就失去意義。正因如此,真正的驗證會從大量經過妥善暖機、良好管理的 IP 位址池執行。
灰名單(Greylisting)
許多伺服器採用灰名單(greylisting),在陌生寄件者首次聯繫時刻意回傳暫時性的4xx失敗。正當的郵件伺服器之後會重試;垃圾郵件發送者通常不會。不會重試的簡單腳本會把這個暫時性失敗誤判為拒收,將完全正常的信箱標為無效。這純粹是因為缺乏耐心而造成的誤判(false negative)。
Catch-all 網域
Catch-all 網域會接受寄往任何可能地址的郵件,因此RCPT TO會對以下兩者一律回傳正面代碼:realperson@domain.com與random-string-9f3a@domain.com。在這類網域上,原始 SMTP 檢查無法區分真實信箱與不存在的信箱。要偵測 Catch-all,必須再對一個肯定不存在的地址發出第二次探測,看它是否也被接受,這屬於 Catch-all 偵測,而不是簡單的是或否。
大型服務商的模稜兩可回應
部分大型服務商刻意讓其RCPT TO行為變得模稜兩可。有些會在交握階段接受所有地址,之後才退信,因此 SMTP 回應根本不包含您想要的資訊。
這也是為什麼email-validator的維護者刻意不在函式庫中加入 SMTP 檢查。他們的文件明確指出,自行聯繫 SMTP 伺服器沒有任何好處,因為伺服器很擅長不透露地址是否可送達,而看似接受郵件的地址之後仍可能退信。實話是:語法與 MX 檢查可以在自己的程式碼中做好,但可靠的信箱檢查不行。
步驟 4:使用 VeriMails API 驗證
實際可行的解法,是讓驗證服務從專為此打造的基礎架構處理信箱檢查,並在 Python 中以單一 HTTP 請求呼叫該服務。VeriMails 提供一個 REST API ,執行完整流程:語法、MX、DNS、即時 SMTP 交握、 Catch-all 偵測、拋棄式偵測與職能信箱偵測,並回傳一個結構化的結果。
使用標準的requests函式庫,驗證呼叫看起來如下。端點細節請參閱 VeriMails API 文件:
import requests
def verify_email(address, api_key):
response = requests.get(
"https://api.verimails.com/v1/verify",
params={"email": address},
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
response.raise_for_status()
return response.json()
result = verify_email("jane.doe@example.com", api_key="YOUR_API_KEY")
print(result["status"]) # e.g. "deliverable", "undeliverable", "catch_all"
print(result["disposable"]) # True if a throwaway address
print(result["role_based"]) # True for addresses such as info@ or support@
接著您就可以依結果採取行動。註冊表單可以接受deliverable地址、拒絕undeliverable與disposable地址,並自行決定如何處理 catch_all:
def is_acceptable_for_signup(address, api_key):
result = verify_email(address, api_key)
if result["disposable"]:
return False, "Disposable addresses are not allowed."
if result["status"] == "undeliverable":
return False, "That address does not appear to exist."
return True, "Address verified."
由於每次呼叫都會回傳明確的結果,速度快到足以在表單送出時執行,不會讓使用者久等。若是資料庫中已有的地址名單,批次 CSV 驗證會更合適,因為上傳一個檔案遠比把單一地址端點迴圈呼叫數千次更有效率。
Python 應用程式的建議模式是結合兩層:保留本機的email-validator語法檢查,在花費點數前就立即拒絕垃圾輸入,再對所有通過的地址呼叫 API,取得權威的送達結果。明顯的錯誤能得到快速回饋,真實地址則能得到值得信賴的判定。
應用程式規則表
| 結果 | 註冊規則 | 匯入或 CRM 規則 |
|---|---|---|
deliverable | 接受並儲存正規化後的地址 | 保留在使用中的區隔 |
undeliverable | 拒絕並顯示清楚的更正提示 | 在任何活動前予以封鎖 |
catch_all | 依帳號風險接受或人工審查 | 另外分區並保守寄送 |
disposable | 對多數產品而言應拒絕 | 從開發名單中封鎖 |
role_based | 僅在可接受共用信箱時允許 | 陌生開發前先審查 |
API 呼叫與名單任務的比較
需要即時決定的場景就使用 API:註冊、結帳、潛在客戶表單、邀請與帳號變更。若是 CSV 匯出、CRM 清理,或有數千個地址需要同樣審查的活動受眾,則使用大量驗證。兩種工作流程都應套用同一套封鎖規則,避免在某條路徑被拒絕的地址,之後又從另一個工具寄出。
整合起來
乾淨的實作分為三層。第一,email-validator語法檢查,使用check_deliverability=False,在本機即時執行,拒絕格式錯誤的輸入。第二,選擇性的 MX 檢查,使用dnspython,適合想在呼叫 API 前先過濾失效網域時採用,不過 API 也涵蓋這一步。第三,呼叫 VeriMails API 取得信箱層級的結果,包括您無法自行可靠重現的 Catch-all、拋棄式與職能信箱偵測。
要記住的是分工。語法與 DNS 確實可以由您在 Python 中處理。即時信箱檢查則取決於 IP 信譽、灰名單行為與 Catch-all 偵測,這些都不是單一腳本能處理的,這也是標準驗證函式庫刻意不做 SMTP 探測的原因。本機檢查用來求快,API 用於信箱層級驗證,並參考價格方案頁面選擇符合預期用量的方案。