Tüm yazılar

OfficeFighters — MailKit ve outbox ile dayanıklı e-posta gönderimi

E-postayı veritabanı işlemiyle kuyruğa almak, korumalı yük, atomik sahiplenme, üstel tekrar denemesi ve SMTP hata sınıflandırması.

t.Turgay
OfficeFighters — MailKit ve outbox ile dayanıklı e-posta gönderimi

Bir hesap işleminin tamamlanması ile kullanıcının e-postayı alması aynı anda gerçekleşmek zorunda değil. OfficeFighters’da e-posta üretme işi EmailOutboxWriter, gönderme işi OutboxDispatcher ve SMTP adapterı üzerinden ayrılıyor. Bu yazıda kaynakta bulunan kuyruk durumlarını ve hata yollarını izleyerek, başarılı HTTP yanıtının e-posta teslimiyle nasıl ilişkilendirildiğini inceliyorum.

Önce gönderim niyetini kaydetmek

EmailOutboxWriter mesajı JSON baytlarına çeviriyor, Data Protection ile koruyor ve Pending durumunda email_outbox satırı ekliyor. Metot SaveChanges çağırmıyor. Böylece çağıran iş akışı, kendi verisiyle e-posta niyetini aynı DbContext ve transaction içinde kaydedebiliyor. Üreticinin hangi transaction kapsamında çalıştığı bu garantinin parçası; yalnız outbox tablosu bulunması atomikliği tek başına sağlamaz.

Satırdaki idempotency_key için benzersiz indeks var. Aynı mantıksal gönderim için aynı anahtarı kullanmak, tekrar işlenen bir komutun iki farklı gönderim niyeti oluşturmasını engelleyebilir. Anahtarın mesaj kimliği olarak her çağrıda rastgele üretilmesi ise bu iş kuralını sağlamaz; anahtarın neyi temsil ettiği üretici tarafından belirlenir.

Token içeren gövdeyi korumak

PayloadProtected alanı outbox-v1 purpose ile üretiliyor. Bu alan e-posta gövdesiyle birlikte geçici bağlantı veya token taşıyabildiği için düz metin tutulmuyor. Gönderici aynı purpose ile yükü açıyor. Anahtar halkasının kaybı, bekleyen satırların çözülememesine yol açıyor; dispatcher bunu kalıcı ret olarak işliyor. Bu nedenle veritabanı yedeğinin yanında anahtar yaşam döngüsü de işletim tasarımının parçası.

Korunan payload, satırdaki bütün metadata’nın şifrelendiği anlamına gelmiyor. Alıcı adresi ve template key ayrı alanlar. İnceleme veya yedek erişimi planlarken hangi alanın korunduğunu tam olarak bilmek gerekiyor.

Worker ince, dispatcher işin sahibi

EmailOutboxWorker yaklaşık iki saniyelik döngüde vadesi gelmiş işleri, kaynakta yirmilik partiyle dispatcher’a bırakıyor. Dispatcher önce Pending satır kimliklerini seçiyor. Ardından her satır için durum koşullu UPDATE ile Sending’e çevriliyor; yalnız etkilenen satır sayısı bir ise gönderime geçiliyor.

// OutboxDispatcher: only one worker claims this pending row.
var claimed = await db.EmailOutboxes
    .Where(o => o.Id == id && o.Status == (short)OutboxStatus.Pending)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Status, (short)OutboxStatus.Sending)
        .SetProperty(o => o.NextAttemptAt, now), ct);
if (claimed != 1) continue;

İki worker aynı kimliği okusa bile aynı Pending satırını normal akışta yalnız biri sahiplenir. Asıl koruma önce SELECT yapılmış olması değil, UPDATE’in durum koşulu ve etkilenen satır sayısıdır. Bu küçük ayrım, çok örnekli çalışmada beklenmedik çift gönderimleri anlamak için önemli.

Geçici hata ile kalıcı ret farklı ilerliyor

SmtpEmailSender MailKit kullanıyor; alıcıyı ayrıştırıp doğruluyor, metin ve HTML gövde hazırlıyor ve ayara göre STARTTLS ile bağlantı kuruyor. Zaman aşımı on beş saniye. SMTP 5xx yanıtı kalıcı ret sayılıyor; ağ ve geçici protokol sorunları tekrar denenebilir sonuca dönüşüyor. Sağlayıcı ayrıntıları istemciye ham olarak taşınmıyor.

// OutboxDispatcher.Backoff: jitter is between 0 and 25 percent.
var baseSeconds = Math.Min(
    10 * Math.Pow(2, Math.Max(0, attempt - 1)), 3600);
return TimeSpan.FromSeconds(
    baseSeconds * (1 + Random.Shared.NextDouble() * 0.25));

Dispatcher her denemeden sonra AttemptCount artırıyor. Geçici hatada Pending’e dönülüp sonraki zaman hesaplanıyor; sekiz deneme sınırında Failed oluyor. Formül on saniyeden başlayan üstel taban ve yüzde sıfır ile yirmi beş arasında jitter kullanıyor. Bir saat sınırı tabana uygulanıyor; jitter sonrası gerçek bekleme biraz daha uzun olabilir.

Başarılı gönderimden sonra hassas yükü kaldırmak

Başarıda durum Sent oluyor, SentAt ve provider kimliği tutuluyor, korumalı payload temizleniyor. Kalıcı rette de payload kaldırılıyor. Geçici hatalar deneme sınırını doldurduğunda payload saklama süresi boyunca kalabiliyor; yönetici sağlayıcı sorununu düzelttikten sonra yeniden deneme için bu ayrım kullanılıyor.

Çökme penceresi ve teslim semantiği

Sending durumunda beş dakikadan eski kalan satırlar tekrar Pending’e alınıyor. Bu, süreç gönderim ortasında kapandığında işin sonsuza kadar kilitli kalmasını önlüyor. Ancak SMTP sunucusu mesajı kabul ettikten hemen sonra uygulama Sent kaydını yazamadan kapanabilir. Sonraki denemede aynı ileti tekrar kabul edilebilir. Atomik sahiplenme ve üretici idempotency anahtarı, bu dış sistem belirsizliğini ortadan kaldırmaz.

Kontrol setinde aynı satırı iki worker’ın sahiplenmesi, geçici hata ardından başarı, kalıcı ret, çözülemeyen payload ve eski Sending kaydı ayrı ele alınmalı. Kaynaktaki OutboxDispatcherTests sahte göndericilerle bu yolları çalıştırıyor. Gerçek teslim sağlığı için bekleyen en eski işin yaşı ve Failed sayısı da izlenmeli; yalnız worker’ın ayakta olması yeterli bir başarı ölçütü değil.

Kaynaklar / References

Microsoft Learn — Data Protection

OfficeFighters serisinin diğer yazıları

Bu yörüngenin sonuna geldin.
Diğer yazıları keşfet
turgay.Biraz kod, biraz piksel, bolca merak.© 2026 · Dünya’dan sevgilerle