teknikbakis

Dört ayda 17 haftalık özet, 17 kez “250 OK” ve tek bir teslim yok

Uygulama her hafta “gönderildi” yazdı, hata sayacı sıfırda kaldı. Posta sunucusu iletiyi kabul edip hemen ardından geri çeviriyordu. Kazıdıkça aynı hatta iki sessiz katman daha çıktı: imzasız giden DKIM ve 76. karakterde ikiye bölünen bağlantı.

DOSYA TB-2026-007
SİSTEM Debian · exim 4.96 · Python
KATMAN E-posta teslimi / MTA
SÜRE 4 ay sessiz · 2 gün çözüm
DURUM ÇÖZÜLDÜ
KANIT 4 ek

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 gelen 250, 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:

EK-1 Gönderim yapmadan yönlendirme kararı
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ı:

EK-2 mainlog'daki kalıcı hata satırları
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.

ÇIKARIM

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:

EK-3 Test iletisinden sonra mainlog
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.

EK-4 set_content() çıktısında bağlantı
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 -bt ile; imza günlükte Tainted yokluğu ve alıcıda dkim=pass ile; 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. context verilmeyen starttls(), ş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.