E-Mail-Validierung in Python meint meist eine von drei Prüfungen: Ist die Adresse korrekt aufgebaut, empfängt ihre Domain E-Mails, und existiert das Postfach? Für die erste hat die Standardbibliothek keinen fertigen Validator, und die beiden anderen kann keine Formatprüfung beantworten. Diese Anleitung baut alle drei in der Reihenfolge, in der sie laufen, mit Code, den wir unter Python 3.14 gegen einen lokalen DNS-Server und eine lokale Nachbildung der API ausgeführt haben.
1. Das Format mit einer Regex prüfen
Ohne zusätzliche Pakete nutzen Sie das getestete Muster aus unserem englischen Leitfaden zur E-Mail-Regex mit re.fullmatch:
# email_regex.py
import re
EMAIL_REGEX = re.compile(
r"(?=.{1,254}\Z)(?=.{1,64}@)"
r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*"
r"@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+"
r"(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})"
)
def is_valid_email(value: str) -> bool:
return EMAIL_REGEX.fullmatch(value) is not None
Die Lookaheads prüfen die Längengrenzen (254 Zeichen insgesamt, 64 vor dem @). Der Rest erlaubt Plus-Tags und Apostrophe und lehnt Punkte am Rand des lokalen Teils, doppelte Punkte und Domains ohne Top-Level-Domain ab. Verwenden Sie fullmatch(): Mit re.match() und einem Muster, das auf $ endet, akzeptiert Python "jane@example.com\n", weil $ auch vor einem abschließenden Zeilenumbruch passt.
Die Regex kennt internationale Domains nur in Punycode (xn--…) und entfernt keine Leerzeichen. Beides erledigt die Bibliothek im nächsten Schritt.
2. Mit email-validator prüfen und normalisieren
Das Paket email-validator prüft die Syntax Regel für Regel, nennt einen verständlichen Grund, wenn eine Adresse durchfällt, und liefert eine normalisierte Form:
# normalize.py: pip install email-validator
from email_validator import EmailNotValidError, validate_email
def normalize_email(value: str) -> str:
"""Liefert die Adresse in ASCII-Form oder wirft EmailNotValidError mit verständlicher Meldung."""
info = validate_email(
value.strip(),
check_deliverability=False, # hier keine DNS-Abfragen
allow_smtputf8=False, # lokale Teile mit Nicht-ASCII-Zeichen wie josé@ ablehnen
strict=True, # Grenze von 64 Zeichen vor dem @ prüfen
)
return info.ascii_email # internationale Domains in Punycode
Die drei Schlüsselwortargumente sind wichtig. allow_smtputf8=False lehnt Adressen wie josé@example.com ab, die nur Mailserver mit SMTPUTF8-Unterstützung erreichen. strict=True schaltet die Grenze von 64 Zeichen für den lokalen Teil ein, die Version 2.3.0 standardmäßig nicht prüft. Und ascii_email liefert die Domain in Punycode, normalized dagegen in Unicode.
Jede Adresse unten lief durch die Regex, durch normalize_email und durch EmailStr von Pydantic, das email-validator mit den Standardeinstellungen aufruft. ✓ heißt, die Prüfung nimmt die Adresse an, ✗, dass sie sie ablehnt.
| Adresse | Soll bestehen? | Regex | normalize_email | EmailStr |
|---|---|---|---|---|
jane@example.com | Ja | ✓ | ✓ | ✓ |
jane.doe+news@ | Ja | ✓ | ✓ | ✓ |
o'brien@ | Ja | ✓ | ✓ | ✓ |
Anna@Example.COM | Ja | ✓ | ✓ Anna@example.com | ✓ Anna@example.com |
jane@ | Ja | ✓ | ✓ | ✓ jane@ |
jane@ | Nach IDN-Umwandlung | ✗ | ✓ jane@ | ✓ |
jane | Nein | ✗ | ✗ | ✗ |
jane..doe@ | Nein | ✗ | ✗ | ✗ |
jane@example | Nein | ✗ | ✗ | ✗ |
jane@example.c | Nein | ✗ | ✓ | ✓ |
jane@-example.com | Nein | ✗ | ✗ | ✗ |
jane@ | Nein | ✗ | ✗ | ✗ |
jane doe@example.com | Nein | ✗ | ✗ | ✗ |
"jane doe"@example.com | Nein (laut RFC gültig) | ✗ | ✗ | ✗ |
jane@[192 | Nein (laut RFC gültig) | ✗ | ✗ | ✗ |
josé@example.com | Nein (braucht SMTPUTF8) | ✗ | ✗ | ✓ |
aaa…a@ (65 a) | Nein | ✗ | ✗ | ✓ |
jane@example.com mit Leerzeichen am Rand | Nach dem Trimmen | ✗ | ✓ | ✓ |
jane@mail.local | Nein (reservierter Name) | ✓ | ✗ | ✗ |
Getestet mit Python 3.14, email-validator 2.3.0 und Pydantic 2.14. Die Fehlermeldungen sind für Menschen geschrieben, allerdings auf Englisch: Für jane..doe@example.com lautet die Meldung “An email address cannot have two periods in a row.” email-validator lehnt außerdem reservierte Domains wie .local, .test und localhost ab, die die Regex durchlässt. Beachten Sie, was EmailStr mit der Domain macht: Es gibt jane@xn--bcher-kva.example als jane@bücher.example zurück. Wandeln Sie die Adresse also zurück, bevor Sie sie an eine API oder einen Mailserver schicken.
3. Die MX-Einträge der Domain prüfen
Eine korrekt aufgebaute Adresse auf einer Domain ohne Mailserver kommt trotzdem zurück. Auch das prüft email-validator: validate_email(email, check_deliverability=True) fragt die MX-Einträge der Domain ab. Gegen unseren lokalen Test-DNS-Server lehnte es einen Null-MX („does not accept email“) und eine nicht existierende Domain ab, griff bei einer Domain ohne MX-Eintrag auf den A- oder AAAA-Eintrag zurück und nahm die Adresse an, wenn der DNS-Server einen Fehler meldete oder nicht antwortete. Es prüft außerdem, ob eine solche Ersatzadresse öffentlich ist: Ein A-Eintrag aus einem Dokumentationsbereich wie 192.0.2.0/24 wurde abgelehnt.
Wenn Sie die Antwort als Wert statt als Ausnahme brauchen und „unbekannt“ von „nein“ unterscheiden wollen, liefert dnspython die Bausteine:
# mx.py: pip install dnspython
import dns.exception
import dns.resolver
resolver = dns.resolver.Resolver()
resolver.lifetime = 3.0 # Sekunden pro Abfrage, Wiederholungen eingeschlossen
def accepts_mail(domain: str) -> bool | None:
"""True: die Domain nimmt E-Mails an. False: nicht. None: DNS gab keine klare Antwort."""
try:
answer = resolver.resolve(domain, "MX")
hosts = [r.exchange.to_text() for r in answer]
return hosts != ["."] # ein einzelnes "." ist ein Null-MX (RFC 7505): keine E-Mails
except dns.resolver.NXDOMAIN:
return False # die Domain existiert nicht
except dns.resolver.NoAnswer:
pass # kein MX-Eintrag: unten auf A/AAAA zurückgreifen
except dns.exception.DNSException:
return None # Zeitüberschreitung, SERVFAIL: unbekannt, nicht ablehnen
# Kein MX-Eintrag: Absender stellen an den A- oder AAAA-Eintrag der Domain zu (impliziter MX).
unknown = False
for rdtype in ("A", "AAAA"):
try:
resolver.resolve(domain, rdtype)
return True
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
pass
except dns.exception.DNSException:
unknown = True
return None if unknown else False
Mit dnspython 2.9.1 gegen den Test-Server:
| Domain eingerichtet mit | accepts_mail |
|---|---|
| Zwei MX-Einträgen | True |
Null-MX (0 .) | False |
| Nur A-Eintrag | True |
| Nur AAAA-Eintrag | True |
| Domain existiert nicht (NXDOMAIN) | False |
| SERVFAIL | None |
| Keine Antwort | None, nach etwa 3 Sekunden |
NoNameservers, das dnspython wirft, wenn alle Server versagen, und LifetimeTimeout sind Unterklassen von DNSException und landen deshalb bei None. Behandeln Sie None als „unbekannt“ und lehnen Sie die Adresse deswegen nicht ab. Ein einzelner MX-Eintrag mit dem Host . ist die Art, wie eine Domain sagt, dass sie keine E-Mails annimmt (RFC 7505); example.com veröffentlicht so einen. Mit dem MX-Lookup prüfen Sie eine Domain von Hand.
4. Prüfen, ob das Postfach existiert
Eine Domain mit funktionierenden MX-Einträgen nimmt E-Mails an, aber das heißt nicht, dass anna@example.org existiert. Das weiß nur der empfangende Mailserver, und ihn mit smtplib von Ihrem eigenen Server aus zu fragen, ist unzuverlässig: Ausgehender Port 25 ist bei den meisten Cloud-Anbietern gesperrt, Catch-all-Domains nehmen jede Adresse an, und wiederholte Anfragen können Ihre IP sperren lassen. Die E-Mail-Validierungs-API führt Syntax-, DNS-, MX- und Postfachprüfung in einer Anfrage aus, ohne eine E-Mail zu senden:
# check_mailbox.py: pip install requests
import os
import requests
API_URL = "https://api.emailvalidation.io/v1/info"
def check_mailbox(email: str) -> dict:
"""Liefert das Ergebnis der API. Wirft requests.RequestException bei Fehlern und Zeitüberschreitung."""
response = requests.get(
API_URL,
params={"email": email, "timeout": 8}, # die API antwortet innerhalb von 8 Sekunden ...
headers={"apikey": os.environ["EMAILVALIDATION_KEY"]},
timeout=10, # ... und der Client wartet etwas länger
)
response.raise_for_status() # 401 falscher Schlüssel, 429 Kontingent oder Parallelitätslimit
return response.json()
Der Schlüssel kommt aus der Umgebung und steht im Header apikey, er bleibt also aus Ihrem Code und aus URLs heraus. requests kodiert das + in jane+news@example.org als %2B, damit der Tag unverändert ankommt. Der API-Parameter timeout (3 bis 60 Sekunden, Standard 30) begrenzt, wie lange die API auf einen langsamen Mailserver wartet; laut Dokumentation (Englisch) sollte der Client ein paar Sekunden länger warten. Übergeben Sie requests immer ein timeout: Ohne kann eine Anfrage ewig warten. In unserem Test führte ein falscher Schlüssel zu HTTPError mit 401, ein aufgebrauchtes Kontingent zu HTTPError mit 429 und eine Antwort nach mehr als 10 Sekunden zu ReadTimeout; alle drei sind Unterklassen von requests.RequestException.
Die Antwort hat 21 Felder. Diese brauchen Sie für Entscheidungen:
status | Beispiele für sub_status | recommended_action | Was tun |
|---|---|---|---|
valid | null, role_based, possible_typo | send, send_ | Annehmen; did_you_mean anzeigen, wenn es nicht leer ist. |
invalid | mailbox_, no_dns_entries | suppress | Ablehnen. |
catch_all | accept_all | send_ | Annehmen oder markieren: Die Domain nimmt jede Adresse an. |
risky | mailbox_full | retry_later | Annehmen; das Postfach ist gerade voll. |
do_not_mail | disposable | suppress | Nach einer dauerhaften Adresse fragen. |
unknown | timeout_exceeded, greylisted | retry_later | Annehmen und später erneut prüfen. |
Die älteren Felder state (deliverable, undeliverable, unknown) und reason liefern dasselbe Urteil weniger detailliert; die Seite Result Statuses (Englisch) ordnet sie zu. Warum sich Catch-all-Domains nicht Postfach für Postfach prüfen lassen, erklärt Catch-all-E-Mail-Adressen.
Es gibt auch ein offizielles SDK, pip install emailvalidationio: emailvalidationio sendet dieselbe Anfrage. In Version 0.2 setzt es keine Zeitbegrenzung, für ein Anmeldeformular sind die paar Zeilen mit requests oben deshalb die sicherere Wahl.
5. E-Mail-Adressen in einem FastAPI-Endpunkt validieren
In einer Webanwendung antwortet die Formatprüfung sofort, und die API-Prüfung läuft mit ihrer Zeitbegrenzung. Ist die API langsam, nicht erreichbar oder das Kontingent aufgebraucht, geht die Anmeldung trotzdem durch:
# app.py: pip install fastapi uvicorn email-validator requests
import logging
import requests
from email_validator import EmailNotValidError
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from check_mailbox import check_mailbox
from normalize import normalize_email
app = FastAPI()
log = logging.getLogger("signup")
class Signup(BaseModel):
email: str
@app.post("/signup", status_code=201)
def signup(body: Signup): # ein normales def: FastAPI führt es im Threadpool aus, blockierende Aufrufe sind also in Ordnung
try:
email = normalize_email(body.email)
except EmailNotValidError as err:
raise HTTPException(422, f"Bitte geben Sie eine gültige E-Mail-Adresse ein. {err}")
result = None
try:
result = check_mailbox(email)
except requests.RequestException as err:
log.warning("email check skipped: %s", err) # API ausgefallen oder langsam: Anmeldung nicht blockieren
if result and result["status"] == "invalid":
raise HTTPException(422, "Diese E-Mail-Adresse existiert nicht. Bitte prüfen Sie sie.")
if result and result["status"] == "do_not_mail":
raise HTTPException(422, "Bitte verwenden Sie eine dauerhafte E-Mail-Adresse.")
# Konto anlegen ...
return {"email": email, "did_you_mean": (result or {}).get("did_you_mean") or None}
email ist absichtlich ein einfacher str: Mit EmailStr würde Pydantic fehlerhafte Adressen mit seinem eigenen Fehlerformat ablehnen und josé@ sowie lokale Teile mit 65 Zeichen annehmen (siehe Tabelle oben). Wir haben den Endpunkt mit FastAPI 0.162 und dessen TestClient gegen eine lokale Nachbildung der API ausgeführt, die das dokumentierte Antwortformat liefert:
Gesendetes email | Antwort |
|---|---|
anna@example.org | 201 |
anna@example.org mit Leerzeichen am Rand | 201, gespeichert als anna@example.org |
jane@ | 201, an die API gesendet als jane@ |
jane@example | 422, „Bitte geben Sie eine gültige E-Mail-Adresse ein.“ mit der englischen Begründung von email-validator |
josé@example.com | 422, mit der Begründung von email-validator |
Eine Adresse, die die API als invalid meldet | 422, „Diese E-Mail-Adresse existiert nicht. Bitte prüfen Sie sie.“ |
Eine Adresse, die die API als do_not_mail meldet | 422, „Bitte verwenden Sie eine dauerhafte E-Mail-Adresse.“ |
| Eine Adresse auf einer Catch-all-Domain | 201 |
anna@gmial.com | 201 mit "did_you_mean": "anna@gmail.com" |
| Beliebige Adresse, während die API 429 antwortet | 201, Warnung protokolliert |
In Django passt dieselbe Logik in die Methode clean_email eines Formulars, in Flask in die View-Funktion.
6. Eine CSV-Datei mit der Bulk-API prüfen
/v1/info einmal pro Zeile aufzurufen, funktioniert für kleine Listen, aber Ihr Tarif begrenzt, wie viele Prüfungen gleichzeitig laufen (5 im kostenlosen Tarif, 25 in bezahlten Tarifen), und der kostenlose Tarif erlaubt 10 Anfragen pro Minute. Eine ganze Datei schicken Sie an den Bulk-Endpunkt: Eine Anfrage lädt bis zu 100.000 Zeilen hoch, und die API liefert Ihre Spalten plus 18 Ergebnisspalten zurück.
# bulk.py: python bulk.py contacts.csv
import os
import sys
import time
import requests
API = "https://api.emailvalidation.io/v1/bulk"
HEADERS = {"apikey": os.environ["EMAILVALIDATION_KEY"]}
path = sys.argv[1]
# 1. CSV-Datei hochladen; die API erkennt die Spalte mit den Adressen.
with open(path, "rb") as f:
response = requests.post(API, headers=HEADERS, files={"file": (os.path.basename(path), f)}, timeout=120)
response.raise_for_status()
job = response.json()
print(f"job {job['job_id']}: {job['counts']['unique']} unique addresses, {job['quota']['charged']} charged")
# 2. Etwa einmal pro Minute abfragen, bis der Job fertig ist (große Listen können Stunden dauern).
while job["status"] in ("queued", "processing"):
time.sleep(60)
response = requests.get(f"{API}/{job['job_id']}", headers=HEADERS, timeout=30)
response.raise_for_status()
job = response.json()
print(f"{job['status']} {job['progress']}%")
if job["status"] == "failed":
sys.exit(f"job failed: {job['error']}")
# 3. Ergebnisse herunterladen: Ihre Spalten plus die Spalten emailvalidation_*.
response = requests.get(f"{API}/{job['job_id']}/results", headers=HEADERS, params={"format": "csv"}, timeout=120)
response.raise_for_status()
out = path.removesuffix(".csv") + ".verified.csv"
with open(out, "wb") as f:
f.write(response.content)
print("saved", out)
# 4. Job und gespeicherte Liste löschen (sonst nach 30 Tagen automatisch).
requests.delete(f"{API}/{job['job_id']}", headers=HEADERS, timeout=30).raise_for_status()
Duplikate und Zeilen ohne gültige Adresse sind kostenlos; berechnet werden beim Hochladen nur eindeutige, gültige Adressen. Die Ergebnisdatei behält Ihre Zeilen in ihrer Reihenfolge und ergänzt Spalten wie emailvalidation_state, emailvalidation_status und emailvalidation_; filtern Sie nach der letzten, um Ihre Versandliste zu erhalten. Statt abzufragen, können Sie eine webhook_url übergeben und erhalten eine signierte Anfrage, sobald der Job fertig ist. Die Details stehen in der Bulk-Dokumentation (Englisch). Wir haben das Skript gegen eine lokale Nachbildung der vier Bulk-Endpunkte mit einer Datei aus fünf Zeilen ausgeführt, darunter ein Duplikat und eine fehlerhafte Adresse.
Ohne Code geht das auch im Browser: Der Bulk-E-Mail-Validator prüft eine kurze Liste.
Empfohlener Aufbau
- Bei der Eingabe:
normalize_emailfür das Format, mit verständlichen Fehlermeldungen. - Optional
accepts_mail, wenn Sie tote Domains ohne API-Aufruf abfangen wollen. - Beim Absenden:
check_mailbox. Blockieren Sieinvalidunddo_not_mail, nehmen Sie den Rest an, und nehmen Sie alles an, wenn die API nicht erreichbar ist. - Für Listen:
bulk.pystatt einer Schleife über/v1/info.
Der kostenlose Tarif enthält 100 Prüfungen im Monat, und nur eindeutige Ergebnisse (zustellbar oder nicht zustellbar) zählen dagegen; die bezahlten Tarife finden Sie unter Preise der E-Mail-Validierungs-API. Dieselben Prüfungen in anderen Sprachen: E-Mail-Validierung in JavaScript und E-Mail-Validierung in PHP.
Quellen
Geprüft am 29. Oktober 2026: Python-Dokumentation, re