Bounce-Mails automatisch auswerten per EML-Export
Nach einem Newsletter-Versand für einen Kunden lagen knapp 950 Bounce-Mails vor. Mit Thunderbird wurden die Mails als EML-Dateien exportiert und anschließend von einem Python-Script — erstellt mit Claude.ai — automatisch ausgewertet.
Ausgangssituation
Wir haben für einen Kunden einen Newsletter verschickt. Nach dem Versand trudelte eine größere Anzahl an Bounce-Mails ein — also automatische Rückmeldungen von Mailservern, die signalisieren, dass eine Zustellung nicht möglich war. In diesem Fall waren es knapp 950 solcher Mails. Das kann verschiedene Ursachen haben: ungültige Adressen, volle Postfächer oder nicht mehr existierende Konten.
Beobachtung
Das manuelle Durchsehen von fast 1.000 Bounce-Mails, um daraus eine saubere Liste ungültiger Adressen zu gewinnen, wäre ein erheblicher Zeitaufwand gewesen. Es musste eine automatisierte Lösung her.
Vorgehen
Die Bounce-Mails wurden über Mozilla Thunderbird abgerufen. Thunderbird bietet die Möglichkeit, mehrere Mails gleichzeitig als .eml-Dateien zu exportieren — das ist ein standardisiertes Format, das den vollständigen Mailinhalt inklusive aller Header speichert. Alle Bounce-Mails wurden so in einen lokalen Ordner exportiert.
Anschließend habe ich CLAUDE.AI gebeten, ein Python-Script zu schreiben, das diesen Ordner durchsucht und aus jeder .eml-Datei die gebouncte E-Mail-Adresse extrahiert. Das Script nutzt dabei mehrere Erkennungsmethoden, da verschiedene Mailserver ihre Bounce-Meldungen unterschiedlich formatieren:
- Auswertung des
X-Failed-Recipients-Headers - Auswertung des
message/delivery-status-Parts nach RFC 3464 (Final-Recipient-Feld) - Regex-Suche im Textbody nach typischen Fehlermustern (
5xx-Codes, „failed", „unknown user" etc.) - Auswertung des
To-Headers der eingebetteten Original-Mail
Das Script wird einfach in denselben Ordner wie die .eml-Dateien gelegt und gestartet. Keine Installation, keine externen Bibliotheken — nur Python 3.
Das Script
#!/usr/bin/env python3
import os, re, email
from email import policy
FOLDER = os.path.dirname(os.path.abspath(__file__))
OUTPUT_FILE = os.path.join(FOLDER, "bounced_addresses.txt")
def extract_bounced_address(msg):
found = set()
x_failed = msg.get("X-Failed-Recipients", "")
if x_failed:
for addr in x_failed.split(","):
addr = addr.strip()
if "@" in addr:
found.add(addr.lower())
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "message/delivery-status":
try:
ds_text = part.get_payload(decode=True)
if ds_text is None:
sub = part.get_payload()
ds_text = b"\n".join(p.as_bytes() if hasattr(p, "as_bytes") else str(p).encode() for p in sub) if isinstance(sub, list) else str(sub).encode()
ds_str = ds_text.decode("utf-8", errors="replace")
except:
ds_str = str(part.get_payload())
for match in re.finditer(r"(?:Final-Recipient|Original-Recipient)\s*:\s*(?:rfc822\s*;\s*)?([^\s;,\r\n]+)", ds_str, re.IGNORECASE):
addr = match.group(1).strip().strip("<>")
if "@" in addr:
found.add(addr.lower())
body_text = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() in ("text/plain", "text/html"):
try: body_text += part.get_payload(decode=True).decode("utf-8", errors="replace")
except: body_text += str(part.get_payload())
else:
try: body_text = msg.get_payload(decode=True).decode("utf-8", errors="replace")
except: body_text = str(msg.get_payload())
for pattern in [
r"(?:failed|undeliverable|undelivered|unknown user|no such user|does not exist)[^\n]*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})",
r"(?:to|recipient|address)\s*[:<]\s*([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})",
r"5\d\d[^\n]*?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})",
]:
for match in re.finditer(pattern, body_text, re.IGNORECASE):
addr = match.group(1).strip().strip("<>")
if "@" in addr:
found.add(addr.lower())
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "message/rfc822":
try:
orig = part.get_payload(0)
for match in re.finditer(r"([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})", orig.get("To", "")):
found.add(match.group(1).lower())
except: pass
return found
eml_files = [f for f in os.listdir(FOLDER) if f.lower().endswith(".eml")]
print(f"{len(eml_files)} .eml-Dateien gefunden. Verarbeite...")
all_bounced, processed, skipped = set(), 0, 0
for filename in eml_files:
try:
with open(os.path.join(FOLDER, filename), "rb") as f:
msg = email.message_from_binary_file(f, policy=policy.compat32)
addrs = extract_bounced_address(msg)
if addrs: all_bounced.update(addrs); processed += 1
else: skipped += 1
except Exception as e:
print(f" FEHLER bei {filename}: {e}"); skipped += 1
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(sorted(all_bounced)))
print(f"Ergebnis: {processed} erkannt | {skipped} ohne Adresse | {len(all_bounced)} eindeutige Adressen")
input("Enter drücken zum Beenden...")
Ergebnis
Von 949 Bounce-Mails wurden 938 erfolgreich ausgewertet — das entspricht einer Trefferquote von über 98 %. Das Script hat dabei 902 eindeutige E-Mail-Adressen identifiziert und in eine einfache Textdatei geschrieben, eine Adresse pro Zeile. Die Differenz entsteht dadurch, dass mehrere Bounce-Mails dieselbe Empfängeradresse betreffen können oder einzelne Rückmeldungen keine eindeutig extrahierbare Adresse enthalten.
Die Liste konnte anschließend direkt an den Kunden weitergegeben werden, der damit seine Empfängerliste bereinigen kann.
Wer Python installiert hat, kann das Script einfach in den Ordner mit den exportierten .eml-Dateien legen und starten — ohne weitere Abhängigkeiten oder Konfiguration.
Bonus: Datenbank bereinigen mit dem Newsletter-Plugin
Wer auf WordPress das Newsletter-Plugin von Stefano Lissa einsetzt (getestet mit Version 9.2.5), kann die gewonnene Adressliste direkt nutzen, um die Datenbank zu bereinigen. Dafür gibt es ein zweites Script, das aus der bounced_addresses.txt ein fertiges SQL-Statement generiert.
Das Script erzeugt eine Datei bounced_update.sql, die per phpMyAdmin als Import hochgeladen werden kann — das ist bei größeren Listen die zuverlässigere Methode, da der SQL-Editor von phpMyAdmin bei sehr langen Abfragen an Grenzen stößt.
Schritt 1 — Alle Bouncer auf "Unsubscribed" setzen:
UPDATE `wp_newsletter`
SET `status` = 'U'
WHERE `email` IN (
'adresse1@example.com',
'adresse2@example.com',
...
);
Damit werden alle gebouncten Adressen auf den Status U (Unsubscribed) gesetzt — sie erhalten keine weiteren Mailings mehr, bleiben aber in der Datenbank sichtbar und können über die Plugin-Oberfläche kontrolliert und endgültig gelöscht werden.
Schritt 2 — Nach Kontrolle endgültig löschen:
DELETE FROM `wp_newsletter` WHERE status = 'U';
Ein einziges Statement räumt alle auf U gesetzten Einträge raus. Wer sichergehen will, führt vorher ein SELECT * FROM wp_newsletter WHERE status = 'U' aus — das zeigt genau welche Datensätze betroffen sind, bevor wirklich gelöscht wird.
Hier das Python-Script, das aus den E-Mails in der TXT-Datei gleich den fertigen UPDATE-Befehl macht:
#!/usr/bin/env python3
import os
FOLDER = os.path.dirname(os.path.abspath(__file__))
INPUT_FILE = os.path.join(FOLDER, "bounced_addresses.txt")
OUTPUT_FILE = os.path.join(FOLDER, "bounced_update.sql")
# ── Konfiguration ──────────────────────────────────────────────────────────────
TABLE = "wp_newsletter" # Tabellenpräfix ggf. anpassen
# ──────────────────────────────────────────────────────────────────────────────
def main():
if not os.path.exists(INPUT_FILE):
print(f"Datei nicht gefunden: {INPUT_FILE}")
input("Enter drücken zum Beenden...")
return
with open(INPUT_FILE, "r", encoding="utf-8") as f:
addresses = [line.strip().lower() for line in f if line.strip() and "@" in line]
if not addresses:
print("Keine Adressen in bounced_addresses.txt gefunden.")
input("Enter drücken zum Beenden...")
return
# IN-Liste bauen mit einfachen Anführungszeichen
in_list = ",\n ".join(f"'{addr}'" for addr in sorted(addresses))
sql = f"""-- Bounce-Mails auf Status 'U' (Unsubscribed) setzen
-- Tabelle: {TABLE}
-- Adressen: {len(addresses)}
-- Generiert von: bounce_to_sql.py
UPDATE `{TABLE}`
SET
`status` = 'U'
WHERE `email` IN (
{in_list}
);
-- Zur Kontrolle: betroffene Zeilen anzeigen
SELECT id, email, status
FROM `{TABLE}`
WHERE `email` IN (
{in_list}
);
"""
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write(sql)
print(f"--- Ergebnis ---")
print(f"Adressen verarbeitet: {len(addresses)}")
print(f"Output: bounced_update.sql")
print(f"\nEinfach in phpMyAdmin unter 'SQL' einfügen und ausführen.")
input("\nEnter drücken zum Beenden...")
if __name__ == "__main__":
main()
P.S. Natürlich kann man für das Newsletter-Plugin auch das passende Bounce-Addon nutzen. Das erledigt diesen Teil komfortabel automatisch. Dieser kleine Laborweg zeigt aber, wie man sich helfen kann, wenn das Addon gerade nicht im Einsatz ist — und plötzlich fast 1.000 Bounce-Mails im Postfach liegen.
P.P.S. Und weil's so schön war, haben wir gleich einen Song dazu gemacht: www.rainer-wittmann.de/klangwolke/ki-generierte-songs/i-got-the-mailer-daemon-blues