Hard ve Soft Bounce Ayrımı: SMTP Kodlarından Otomatik Liste Temizliğine
"5xx sil, 4xx tekrar dene" kuralı üretimde listenizi eritir ya da IP'nizi kara listeye taşır. Ham SMTP yanıtından gerçek karara giden bir boru hattı: DSN ayrıştırma, RFC 3463 enhanced status code sınıflandırması, adres başına durum makinesi ve otomatik suppression list.
EvilMail Team14 Temmuz 202611 dk okuma
500 aboneye kampanya attın, 40'ı geri döndü. Postfix log'unda hepsinin yanında 550 yazıyor. Üçü aslında geçici gri listeleme, biri dolu posta kutusu, biri gerçekten var olmayan hesap, kalanı ise alıcı MTA'nın reputation temelli policy reddi. Hepsini silersen listen erir ve asıl reputation sorununu hiç göremezsin; hiçbirini silmezsen bir sonraki gönderimde aynı 40 çöp adres IP'ni Spamhaus'a taşır. Sorun bounce kodlarını okumak değil — doğru katmandan okumak.
Yaygın "5xx = kalıcı, sil; 4xx = geçici, tekrar dene" kuralı tam bu yüzden tehlikeli. Gerçek karar SMTP yanıt satırının ilk hanesinde değil, RFC 3463 gelişmiş durum kodunda (X.Y.Z) ve DSN gövdesindeki Diagnostic-Code alanında saklıdır. Ham yanıttan yapısal karara giden boru hattını baştan kuralım.
SMTP yanıtı üç katmandır, ilk hane yalan söyler
Tek bir reddin içinde üç ayrı bilgi katmanı vardır ve çoğu bounce işleyici sadece en kabasını okur:
Hard/Soft Bounce Ayrımı ve Otomatik Liste Temizliği | evilmail.pro — EvilMail Blog
= kalıcı-geçici (persistent transient),
5
= kalıcı hata. Aksiyon için tek başına yetersiz.
Enhanced status code (RFC 3463):class.subject.detail biçiminde X.Y.Z. Asıl niyet burada. 5.1.1, 5.2.2 ve 5.7.1 — üçü de aynı 550 altında gelir ama tamamen farklı üç aksiyon gerektirir.
Diagnostic-Code / serbest metin: Alıcı MTA'nın yazdığı satır. "account does not exist" ile "rate limited, try again later" arasındaki fark burada netleşir, çünkü bazı MTA'lar enhanced code'u tembelce atlar.
550'nin neden yetmediğini örnekle görelim. Üç gerçek yanıt, hepsi 550 ailesinden:
text
550 5.1.1 The email account that you tried to reach does not exist → HARD
550 5.7.1 Our system has detected that this message is likely spam → AMBIGUOUS
550 5.2.2 The email account that you tried to reach is over quota → AMBIGUOUS
İlki silinmeli. İkincisi bir reputation sinyalidir — adresi silersen sorunu maskelersin, oysa gönderen IP'ni ya da domain'ini düzeltmen gerekir. Üçüncüsü kalıcı sınıfta (5) gelir ama pratikte sık geri kazanılır: kullanıcı kutusunu boşaltır. Üçünü de kör bir 5xx → sil kuralına sokmak hem teslim edilebilir adresleri katleder hem reputation problemini gizler.
Yanıttan karara: boru hattının şekli
Üç kova, üç zamanlama: HARD anında suppress edilir, SOFT sayaçla eşiğe kadar tekrar denenir, AMBIGUOUS ise soft gibi davranır ama daha agresif eşikle ve ayrı bir izleme etiketiyle — çünkü altında yatan çoğu zaman senin reputation'ındır.
Sınıflandırma tablosu: kod → sınıf → aksiyon
Enhanced status code'u aksiyona bağlayan çekirdek harita. Bunu ezberden değil, kendi log'undaki gerçek satırlardan doğrulayarak genişlet.
5.1.1, 5.1.10 — yok hesap / null MX → HARD. Gmail: 550-5.1.1 The email account that you tried to reach does not exist.
5.4.1 — Outlook/Exchange Recipient address rejected: Access denied → HARD (var olmayan alıcının Microsoft'taki tipik karşılığı).
554 — Yahoo genel teslim hatası → metne göre HARD ya da AMBIGUOUS: 554 5.7.9 policy ise belirsiz, 554 delivery error: no such user ise hard.
5.2.2 — mailbox full / over quota → AMBIGUOUS. Kalıcı sınıfta gelir ama geri kazanılır.
5.7.1 — policy / blocked / spam reddi → AMBIGUOUS. Adresle ilgili değil, seninle ilgili; silmek yanlış.
4.2.2 — geçici kota → SOFT.
451 4.7.1 — greylisting → SOFT; retry ile kendiliğinden geçer. Bunu hard sayan sistemler kusursuz teslim edilebilir adresleri boşuna siler.
Kritik nokta: sınıf hanesi (4 vs 5) bir başlangıç sinyalidir, nihai karar değil. 5.2.2 kalıcı hanesine rağmen soft-benzeri, 5.7.1 ise silinmemesi gereken bir 5 koddur.
Bounce'u nereden okursun: DSN ayrıştırma
Bounce iki yerden gelir. Senkron: gönderim oturumundaki anlık SMTP yanıtı (Postfix bounce/smtp log satırı). Asenkron: dakikalar sonra MAILER-DAEMON'dan gelen DSN maili. İkincisi Content-Type: multipart/report; report-type=delivery-status yapısındadır ve asıl yapısal veri message/delivery-status bölümündedir:
Üç alan işini görür: Action: failed, Status: 5.1.1, Diagnostic-Code. Action ilk filtren — failed dışındakileri (delayed, relayed) bounce sayma.
İki tuzak. Birincisi: ARF bounce değildir.report-type=feedback-report (RFC 5965) bir spam şikayetidir, teslim hatası değil. Bunu bounce iş akışına sokarsan aktif, mutlu aboneleri yakarsın; ARF ayrı bir suppression sebebiyle (complaint) işlenir. İkincisi: hangi adresin bounce ettiğini `To` başlığından tahmin etme. VERP/Return-Path kullan — her gönderime bmsg+<encoded>@bounce.evilmail.pro biçiminde benzersiz bir zarf gönderici koy; bounce döndüğünde encoded sana kesin adresi ve kampanyayı verir. Aksi halde forward'lanmış ya da alias'lanmış kutularda yanlış adresi öldürürsün.
Kod: DSN'den yapısal karara
Ayrıştırıcı fanteziye ihtiyaç duymaz. Enhanced code'u regex'le çek, yoksa basic code'a düş, haritadan sınıfı bul. Belirsiz metinler için küçük ve denetlenmiş bir keyword listesi yeterli — ML değil.
python
import re
from dataclasses import dataclass
ENHANCED = re.compile(r'\b([245])\.\d{1,3}\.\d{1,3}\b')
BASIC = re.compile(r'\b([245]\d\d)\b')
# X.Y.Z -> sınıf
CODE_MAP = {
"5.1.1": "hard", "5.1.10": "hard", "5.4.1": "hard",
"5.2.2": "ambiguous", "5.7.1": "ambiguous",
"4.2.2": "soft", "4.7.1": "soft", "4.7.0": "soft",
"4.4.1": "soft", "4.4.2": "soft",
}
HARD_HINTS = ("does not exist", "user unknown", "no such user",
"invalid recipient", "address rejected")
SOFT_HINTS = ("try again", "rate limit", "greylist",
"temporarily", "over quota", "throttl")
@dataclass
class Verdict:
address: str
code: str
klass: str
action: str
def classify(address: str, status: str, diagnostic: str) -> Verdict:
m = ENHANCED.search(status) or ENHANCED.search(diagnostic)
code = m.group(0) if m else ""
klass = CODE_MAP.get(code)
if klass is None: # enhanced code haritada yok -> metne bak
text = diagnostic.lower()
if any(h in text for h in HARD_HINTS):
klass = "hard"
elif any(h in text for h in SOFT_HINTS):
klass = "soft"
else: # sadece basic code kaldı
b = (BASIC.search(status) or BASIC.search(diagnostic))
klass = "hard" if b and b.group(0).startswith("5") else "soft"
action = {"hard": "suppress_now",
"soft": "retry_count",
"ambiguous": "retry_strict"}[klass]
return Verdict(address, code or "n/a", klass, action)
Buradaki sıralama önemli: önce enhanced code, sonra metin, en son basic code fallback. Basic code'a düşmeyi mümkün olduğunca geciktir, çünkü tek başına 5 hanesi 5.2.2 ile 5.1.1'i ayıramaz ve seni yanlış tarafa iter.
Durum makinesi ve eşikler: ne zaman gerçekten silinir
classify() tek bir olayı sınıflandırır. Silme kararı ise adres başına zaman içindeki geçmişe bakar. Tek bir soft bounce hiçbir şey ifade etmez; art arda beşi çok şey ifade eder.
Somut eşikler, üretimde çalışan değerler:
HARD: 1 olayda suppressed. Tekrar deneme yok — adres yok, ısrar reputation'a zarar verir.
SOFT: 4-5 ardışık soft VEYA 7-14 günlük pencerede hiç başarılı teslim yoksa suppress. Araya bir başarılı teslim girerse sayaç sıfırlanır — dolu kutu boşaldı, sunucu ayağa kalktı demektir.
AMBIGUOUS: 3 olayda suppress. Daha sıkı, çünkü 5.7.1 gibi kodlar birikirse bu bir adres sorunu değil, senin domain'inin bloklandığının işaretidir; o adresi bırakıp reputation'a bakmalısın.
Suppression list tek doğruluk kaynağıdır. Şeması sade:
sql
CREATE TABLE suppression (
address TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- hard | soft_threshold | ambiguous | complaint
class TEXT NOT NULL, -- hard | soft | ambiguous
last_code TEXT, -- '5.1.1'
bounce_count INT DEFAULT 1,
first_bounce_at TIMESTAMPTZ NOT NULL,
suppressed_at TIMESTAMPTZ
);
MTA'ya geri besleme: Postfix tarafında suppressed adresleri gönderim öncesi check_recipient_access hash map'i ile ya da uygulama katmanında bir pre-send lookup ile bloklarsın. Kritik olan, filtrenin kuyruğa girmeden önce çalışması — çöp adresi denemek bile reputation maliyeti üretir.
Otomasyon boru hattı ve izleme
Uçtan uca akış tek yönlü: MTA log/DSN → parser (cron ya da log tail) → classifier → suppression DB → pre-send filter. Debian'da /var/log/mail.log, RHEL'de /var/log/maillog. pflogsumm ile günlük hard/soft dağılımını çıkar; ani soft artışı greylist/rate-limit/blok, ani hard artışı ise kötü bir liste kaynağı (satın alınmış liste, bozuk import) demektir.
Hedef eşikler: toplam bounce rate %2'nin altında, hard bounce anında sıfıra yakın. %5 birçok ESP'nin throttle ya da askı sınırıdır; oraya varmadan alarm kurulmalı. Soft/ambiguous oranındaki tırmanış en erken reputation uyarı sinyalidir — henüz kimse seni bloklamadan önce.
evilmail.pro tarafında bu teorik bir konu değil: temp-email ve mail altyapısı sağlayıcısı olarak trafiğimiz doğası gereği milyonlarca uçucu adrese temas ediyor. Suppression otomasyonu olmadan bu hacimde temiz IP tutmak imkânsız; bu boru hattı bizim için opsiyon değil, altyapının kendisi.
Pratik kontrol listesi
VERP/Return-Path ile bounce → adres eşlemesi kurulu mu (To başlığına güvenmiyor musun)
Enhanced status code parse + basic code fallback sırası doğru mu (önce X.Y.Z, en son ilk hane)
Üç kova ayrımı (hard/soft/ambiguous) uygulandı mı, yoksa hâlâ 5xx→sil mı çalışıyor
Soft eşiği (4-5 olay / 7-14 gün) ve başarılı teslimde sayaç sıfırlama test edildi mi
ARF/FBL şikayetleri bounce'tan ayrı, complaint sebebiyle mi işleniyor
Suppression list gönderim öncesi (pre-send) filtrede mi, sonrasında değil
Bounce rate ve sınıf dağılımı için dashboard/alert var mı (soft artışı = erken uyarı)
Kod hanesini değil, X.Y.Z'yi ve Diagnostic-Code'u oku; gerisi eşik ayarıdır.