Semptom
Kendi işlettiğim bir izleme platformu her pazar sabaha karşı haftalık bir özet postası
üretiyor. Kod aynı makinedeki posta sunucusuna (127.0.0.1:25) bağlanıyor,
iletiyi veriyor, 250 yanıtını alıyor ve günlüğe tek satır yazıyor:
Email gonderildi
7 Haziran ile 27 Eylül 2026 arasında bu satır 17 kez yazıldı. Hata sayacı sıfırdı, izleme yeşildi. 17 postanın 17'si de hiçbir kutuya varmadı. Etki sınırlı kaldı, çünkü günlükteki tek alıcı kodun varsayılan rapor adresiydi; dışarıdaki bir müşteriye giden posta yoktu. Ama aynı yol yakında çok daha önemli bir şey taşıyacaktı.
Hatayı bir alarm bulmadı. 27 Eylül'de aynı sunucudan tek kullanımlık doğrulama kodu (OTP) gönderecek yeni bir özellik üzerinde çalışırken posta yoluna baktığımda ortaya çıktı. Eski yapılandırmayla o kodlar da hiçbir kullanıcıya ulaşmayacaktı.
Yanlış yollar
Bu vakada uzun bir yanlış hipotez zinciri yok. Yanlış yol, dört ay boyunca hiç bakmamaktı; beni bakmaktan alıkoyan da birbirini doğrulayan dört yeşil işaretti:
- Uygulama günlüğü. “gönderildi”, SMTP konuşmasının istisna fırlatmadan bittiğini söyler. Postanın sonra nereye gittiğini bilmez.
- Sıfır hata sayacı. Sayaç yalnız uygulamanın gördüğü hataları sayar. Ret, uygulama bağlantıyı kapattıktan sonra posta sunucusunun içinde oluyordu.
- SMTP
250. İleti verisinden sonra gelen250, sunucunun iletinin sorumluluğunu üstlendiğini bildirir; teslim ettiğini değil. Burada sorumluluğu üstlenen, dışarıya hiç posta çıkarmayan bir sunucuydu. - Boş kuyruk. Kuyruğu izleyen hiçbir şey yoktu. Olsaydı bile yığılma görmeyecekti; hata kendi izini iki günde siliyordu (nedeni aşağıda).
Sonraki katmanlarda da aynı türden yanıltıcı işaretler çıktı. DKIM kaydı DNS'te doğruydu, bu yüzden insanın ilk baktığı yer DNS olur; sorun sunucudaydı. Bölünmüş bağlantı Outlook'ta kusursuz görünüyordu, çünkü istemci kırılımı birleştiriyordu. Ters DNS'i doğrularken de bir genel çözümleyici sunucunun A kaydını boş döndürdü; o sonuca güvenseydim FCrDNS'in kırık olduğuna karar verip PTR kaydının peşine düşecektim.
Doğru test
Ayırt edici soru şuydu: posta sunucusu iletiyi kabul ettikten sonra onunla ne yaptı? Bunu tek bir posta göndermeden sormanın yolu exim'in adres sınama kipi:
exim4 -bt probe@example.com
probe@example.com is undeliverable: Mailing to remote domains not supported
-bt hiçbir şey göndermez; yalnız yönlendiricilerin bu adres için vereceği
kararı gösterir. Dört ayın cevabı bu tek satırdaydı.
İkinci kaynak exim'in ana günlüğü. Her satırdaki işaret iletinin akıbetini söyler:
<= geldi, => teslim edildi, == ertelendi,
** kalıcı olarak başarısız. Dört ay boyunca dış alıcıya yazılmış tek bir
=> satırı yoktu; her gönderimin karşılığı bir ** satırıydı:
grep -F ' ** ' /var/log/exim4/mainlog
… ** alici@ornek-alan.com R=nonlocal: Mailing to remote domains not supported
Tarih ve ileti kimliği kısaltıldı. R=nonlocal, reddi veren yönlendiricinin adı.
Diğer katmanların testi de aynı ilkeye dayanıyor: sonucu iddia eden yere değil, sonucun iz bıraktığı yere bak.
- İmza: test iletisinden sonra
grep -E "Tainted|unable to open" /var/log/exim4/mainlog. Çıktı boş değilse ileti imzasız gitmiştir, 250 ne derse desin. - Bağlantı: alıcının istemcisinde değil, ham postada (“Orijinali göster”, “Kaynağı görüntüle”) bağlantının tek satırda, tek parça durup durmadığı.
- FCrDNS: PTR'nin gösterdiği adın A kaydını genel bir çözümleyiciye değil, alanın yetkili ad sunucusuna sormak.
dig +short -x 198.51.100.25
sunucu.ornek-alan.com.
dig +short A sunucu.ornek-alan.com @ns1.ornek-alan.com
198.51.100.25
Kök neden
1. Kabul edildi, sonra reddedildi
Debian'ın exim4 paketi kurulumda bir yapılandırma türü sorar. Bu sunucuda
seçili tür şuydu (dosya tarihi 31 Mayıs 2026):
# /etc/exim4/update-exim4.conf.conf
dc_eximconfig_configtype='local'
local “yalnız yerel teslim” demektir. Bu türde nonlocal adlı
yönlendirici her dış alıcıyı :fail: ile reddeder. Ama ret SMTP konuşması
sırasında değil, ileti kuyruğa alındıktan sonra olur. Uygulama bağlanır, iletiyi verir,
250 alır ve çıkar; exim hemen ardından teslimi dener, başarısız sayar ve
göndericiye bir geri dönüş (bounce) üretir.
Geri dönüşün de gideceği yer yoktu: gönderici adresi de uzak bir alandaydı ve aynı
yönlendirici onu da reddetti. Teslim edilemeyen geri dönüş exim'de dondurulur; Debian'ın
ignore_bounce_errors_after = 2d ayarı da onu iki gün sonra sessizce siler.
Haftada bir gönderimde kuyrukta yığılma bile oluşmuyordu. Hata kendi izini temizliyordu.
250 bir el sıkışmadır, teslim kanıtı değil: iletinin bir sonraki halkaya
geçtiğini söyler ve o halka senin kendi sunucun olabilir. Teslimin kanıtı, alıcının MX'ine
karşı yazılmış bir => satırıdır; daha da kesini, alıcı kutusundaki ham postadır.
2. İmzalanmadı, yine de 250
Yapılandırma türünü internet'e alıp DKIM imzalamayı açtım. Test iletisi gitti,
250 döndü, DNS'teki DKIM kaydı doğruydu. Ana günlük ise başka bir şey söylüyordu:
grep -E "Tainted|unable to open" /var/log/exim4/mainlog
… Tainted filename …
… unable to open file for reading …
Satırların geri kalanı kısaltıldı. İleti bu iki satıra rağmen imzasız olarak teslim edildi.
Sebep, internette en çok kopyalanan exim DKIM makrosu:
DKIM_DOMAIN = ${lc:${domain:$h_from:}}
DKIM_PRIVATE_KEY = /etc/exim4/dkim/${dkim_domain}.key
exim 4.94'ten beri iletiden gelen her değer kirli (tainted) işaretlenir ve kirli
bir değerden türetilmiş yolla dosya açılmaz. Alan adı From başlığından geliyor;
yani anahtar dosyasının yolu, iletiyi yazan herkesin yazabileceği bir başlıktan türüyor.
Debian'daki exim 4.96 dosyayı açmayı reddediyor ve iletiyi imzasız gönderiyor. Ret yok,
kuyrukta bekleme yok: yine 250.
İmzasız ileti çoğu alıcıda yine kabul görür, çünkü DMARC SPF hizalamasıyla geçer. Fark, ileti bir yönlendirmeden geçtiğinde ortaya çıkar: SPF kırılır, imza da olmadığı için DMARC düşer.
3. Bağlantı 76. karakterde ikiye bölündü
Posta artık çıkıyordu. Ertesi gün, 28 Eylül'de, uygulama günlüğünde bir parola sıfırlama
bağlantısının kesik bir belirteçle istendiğini ve 410 aldığını gördüm. İsteği
yapan adres Microsoft'un bağlantı tarama altyapısına aitti. Aynı kusur davet postalarında da vardı.
Kaynak, Python'un EmailMessage.set_content() sezgisi. Satırlardan biri 78
bayttan uzunsa gövde 7bit/8bit bırakılmaz; quoted-printable'a (base64 daha kısa çıkıyorsa
ona) çevrilir. Türkçe karakterler UTF-8'de iki bayt tuttuğu için eşik daha erken aşılır;
uzun bir bağlantı ise tek başına aşar. Quoted-printable satırı en fazla 76 karakterdir:
taşan kısım satır sonuna konan = ile alta kayar, bağlantıdaki =
de =3D olur.
Content-Transfer-Encoding: quoted-printable
…
https://panel.ornek-alan.com/parola-sifirla?t=3DHk3vQ9pLm2XcT7wRbN4sYd8fJa6uE=
z1gKo5iVh0q
Python 3.13'te örnek belirteçle yeniden üretildi. Outlook iki satırı birleştirip doğru bağlantıyı gösterir; ham metinden adres ayıklayan bir tarayıcı ise kesik belirteci alır.
Kesik belirteç, ironik biçimde, bu kez bağlantıyı korudu. Tarayıcı belirtecin tamamını
alsaydı ve uç nokta belirteci GET isteğinde tüketseydi, kullanıcı tıkladığında
bağlantı çoktan yanmış olacaktı.
Yan bulgu: doğrulanmayan TLS
Aynı kodda starttls() ve SMTP_SSL(), context
verilmeden çağrılıyordu. Bu durumda smtplib sertifikayı doğrulamayan bir bağlam
kullanır; Python 3.11 ve 3.13'te ölçtüm. Kod henüz dış bir sunucuya parola vermiyordu,
ama verdiği gün araya giren biri o parolayı okuyabilirdi.
Çözüm
Yönlendirme ve imza. Tür internet oldu; dinleme yalnız geri
döngü adreslerinde, röle ağı yok. SPF kaydında IPv6 olmadığı için IPv6 kapatıldı, yoksa
exim IPv6 üzerinden çıkıp SPF'te düşebilirdi. HELO adı PTR ile aynı. DKIM'de alan adı ve
anahtar yolu artık sabit metin:
# /etc/exim4/update-exim4.conf.conf
dc_eximconfig_configtype='internet'
dc_local_interfaces='127.0.0.1 ; ::1'
dc_relay_nets=''
# /etc/exim4/exim4.conf.localmacros
disable_ipv6 = true
DKIM_CANON = relaxed
DKIM_SELECTOR = s1
DKIM_DOMAIN = ornek-alan.com
DKIM_PRIVATE_KEY = ${if eq{${lc:${domain:$h_from:}}}{ornek-alan.com}{/etc/exim4/dkim/ornek-alan.com.key}{}}
update-exim4.conf && exim4 -bV && systemctl restart exim4
exim4 -bt alici@ornek-alan.com # artık: router = dnslookup, transport = remote_smtp
Koşul, yalnız kendi alanımızdan çıkan postayı imzalıyor; boş sonuç exim'de “imzalama” demek. Kirli değer karşılaştırmada kullanılabilir, dosya yolunda kullanılamaz. İfadenin sonucu yapılandırmadaki sabit metin olduğu için temiz.
Teslim edildi, Gelen kutusu değil. SPF, DKIM ve DMARC Gmail'de de
Microsoft 365'te de pass verdi. Microsoft yine de iletiyi Gereksiz klasörüne
koydu (SCL 5). IP, altı DNS kara listesinin hiçbirinde değildi; her listeyi önce kendi test
kaydıyla sorgulayıp sorgunun gerçekten çalıştığını doğrulamıştım. Sorun kimlik değil
itibardı: yeni bir IP ve sağlayıcının jenerik PTR adı. Alıcı kurumlardan bizi izin listesine
almalarını isteyemezdim; exim'i bir e-posta teslim sağlayıcısının aktarıcısına (smarthost)
bağladım. Kuyruk ve günlük yine bende, imzayı artık sağlayıcı atıyor ve yerel DKIM kapandı.
Bedeli, sağlayıcının veri işleyen konumuna geçmesi. İlk denemede Microsoft yine Gereksiz'e
koydu, çünkü alan adının itibarı da yeniydi; ertesi gün gerçek bir parola sıfırlama postası
bir buçuk dakikada Gelen kutusuna düştü.
Bağlantı tek parça. Bağlantı taşıyan her otomatik postada aktarım
kodlaması açıkça seçiliyor. Satırlar 998 baytın altında kaldığı ve aktarıcı 8BITMIME
desteklediği sürece gövde olduğu gibi gider. Tek kullanımlık bağlantılarda kural da değişti:
GET hiçbir şey tüketmez, belirteç yalnız formun POST'unda harcanır.
msg.set_content(govde, cte="8bit")
TLS. Dış bir SMTP sunucusuna parola verilecekse bağlam açıkça kurulur:
import smtplib, ssl
ctx = ssl.create_default_context()
with smtplib.SMTP(sunucu, 587, timeout=30) as s:
s.starttls(context=ctx)
s.login(kullanici, parola)
İzleme. İzleme betiğine kuyruk kontrolü eklendi: bir saatten uzun bekleyen ileti varsa bildirim geliyor. Elle bakmak için üç komut yeter:
exim4 -bpc # kuyruktaki ileti sayısı
exiqgrep -z -i # donmuş iletilerin kimlikleri
grep -F ' ** ' /var/log/exim4/mainlog # kalıcı hatalar
Kalıcı ders
-
250'yi teslim sanma. Kendi sunucunun verdiği
250, zincirin ilk ve en kolay halkasıdır. Kanıt, alıcı MX'ine karşı yazılmış=>satırı; son kanıt, alıcı kutusundaki ham posta. -
Yokluk alarm üretmez. Gelmeyen posta hiçbir sayaca girmez, exim donmuş
geri dönüşü iki günde siler. İzleme kuyruğa ve
**satırlarına bakmalı; uygulamanın kendi “gönderildi”sine değil. -
Her katmanı kendi iziyle doğrula. Yönlendirme
-btile; imza günlükteTaintedyokluğu ve alıcıdadkim=passile; bağlantı ham postada; ters DNS yetkili ad sunucusunda; itibar ise iletinin düştüğü klasörde. -
Tek kullanımlık bağlantı
GET'te tüketilmez. Bağlantıyı kullanıcıdan önce bir tarayıcı açacak; açmasa bile buna göre tasarla. -
Python'da TLS bağlamını açıkça ver.
contextverilmeyenstarttls(), şifreli ama kimliği sorgulanmamış bir bağlantıdır.
Bu dosyadaki üç katmanın ortak adı sessiz başarı: her biri bir başarı sinyali üretirken asıl işi yapmıyordu. Desenin başka yüzleri TB-2026-009'da.